Merge branch 'main' into feat/2d-editor
This commit is contained in:
@@ -7,8 +7,16 @@ import {
|
||||
spatialGridManager,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer'
|
||||
import { memo, type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { type HoverStyles, InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer'
|
||||
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,21 @@ 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
|
||||
const EDITOR_HOVER_STYLES: HoverStyles = {
|
||||
default: { visibleColor: 0x00_aaff, hiddenColor: 0xf3_ff47, strength: 5, pulse: true },
|
||||
delete: { visibleColor: 0xef_4444, hiddenColor: 0x99_1b1b, strength: 6, pulse: false },
|
||||
'paint-ready': { visibleColor: 0xf5_9e0b, hiddenColor: 0xfd_e068, strength: 5, pulse: true },
|
||||
'paint-disabled': {
|
||||
visibleColor: 0x94_a3b8,
|
||||
hiddenColor: 0x47_5569,
|
||||
strength: 4,
|
||||
pulse: false,
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire up module-level singletons (spatial grid, space detection, SFX) for
|
||||
@@ -501,6 +524,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 +619,165 @@ 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 badgeRef = useRef<HTMLDivElement>(null)
|
||||
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' : `Paint ${activePaintTarget}`
|
||||
const icon = 'mdi:format-color-fill'
|
||||
|
||||
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}
|
||||
icon={icon}
|
||||
label={label}
|
||||
position={{ x: 0, y: 0 }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Viewer canvas: memoized, subscribes to viewMode/floorplanPaneRatio internally ──
|
||||
@@ -684,6 +885,10 @@ const ViewerCanvas = memo(function ViewerCanvas({
|
||||
containerRef={viewer3dRef}
|
||||
isVersionPreviewMode={isVersionPreviewMode}
|
||||
/>
|
||||
<PaintCursorLayer
|
||||
containerRef={viewer3dRef}
|
||||
isVersionPreviewMode={isVersionPreviewMode}
|
||||
/>
|
||||
{!showLoader && isCameraControlsHintVisible && !isFirstPersonMode ? (
|
||||
<ViewerCanvasControlsHint
|
||||
isPreviewMode={isPreviewMode}
|
||||
@@ -691,7 +896,10 @@ const ViewerCanvas = memo(function ViewerCanvas({
|
||||
/>
|
||||
) : null}
|
||||
<SelectionPersistenceManager enabled={hasLoadedInitialScene && !showLoader} />
|
||||
<Viewer selectionManager={isFirstPersonMode ? 'default' : 'custom'}>
|
||||
<Viewer
|
||||
hoverStyles={EDITOR_HOVER_STYLES}
|
||||
selectionManager={isFirstPersonMode ? 'default' : 'custom'}
|
||||
>
|
||||
<ViewerSceneContent
|
||||
isFirstPersonMode={isFirstPersonMode}
|
||||
isLoading={isLoading}
|
||||
@@ -823,7 +1031,7 @@ export default function Editor({
|
||||
const showLoader = isLoading || isSceneLoading
|
||||
|
||||
const previewViewerContent = (
|
||||
<Viewer selectionManager="default">
|
||||
<Viewer hoverStyles={EDITOR_HOVER_STYLES} selectionManager="default">
|
||||
<ExportManager />
|
||||
<ViewerZoneSystem />
|
||||
<CeilingSystem />
|
||||
|
||||
@@ -2,27 +2,56 @@ import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
type BuildingNode,
|
||||
type CeilingNode,
|
||||
emitter,
|
||||
type FenceNode,
|
||||
getMaterialPresetByRef,
|
||||
type ItemNode,
|
||||
type NodeEvent,
|
||||
type RoofEvent,
|
||||
type RoofNode,
|
||||
type RoofSegmentEvent,
|
||||
resolveLevelId,
|
||||
sceneRegistry,
|
||||
resolveMaterial,
|
||||
type SlabNode,
|
||||
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 {
|
||||
applyMaterialPresetToMaterials,
|
||||
createMaterial,
|
||||
createMaterialFromPresetRef,
|
||||
getRoofMaterialArray,
|
||||
getStairBodyMaterials,
|
||||
getStairRailingMaterial,
|
||||
getVisibleWallMaterials,
|
||||
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 {
|
||||
type ActivePaintMaterial,
|
||||
buildRoofSurfaceMaterialPatch,
|
||||
buildSingleSurfaceMaterialPatch,
|
||||
buildStairSurfaceMaterialPatch,
|
||||
buildWallSurfaceMaterialPatch,
|
||||
hasActivePaintMaterial,
|
||||
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 => {
|
||||
@@ -52,6 +81,16 @@ type ModifierKeys = {
|
||||
ctrl: boolean
|
||||
}
|
||||
|
||||
type PaintPreviewCleanup = () => void
|
||||
|
||||
type PaintInteraction = {
|
||||
key: string
|
||||
apply: (() => void) | null
|
||||
hoverMode: HoverHighlightMode
|
||||
hoveredId: AnyNodeId
|
||||
preview: (() => PaintPreviewCleanup | null) | null
|
||||
}
|
||||
|
||||
interface SelectionStrategy {
|
||||
types: SelectableNodeType[]
|
||||
handleSelect: (node: AnyNode, nativeEvent?: any, modifierKeys?: ModifierKeys) => void
|
||||
@@ -175,10 +214,197 @@ function getIntersectionMaterialIndex(
|
||||
return group?.materialIndex
|
||||
}
|
||||
|
||||
function setSelectedMaterialTargetForNode(
|
||||
node: AnyNode,
|
||||
role: MaterialTargetRole | null,
|
||||
) {
|
||||
function getRegisteredNodeObject(nodeId: string): Object3D | null {
|
||||
return sceneRegistry.nodes.get(nodeId) ?? null
|
||||
}
|
||||
|
||||
function getRegisteredMesh(nodeId: string): Mesh | null {
|
||||
const object = getRegisteredNodeObject(nodeId)
|
||||
return object && (object as Mesh).isMesh ? (object as Mesh) : null
|
||||
}
|
||||
|
||||
function previewMeshMaterial(mesh: Mesh, material: Material | Material[]): PaintPreviewCleanup {
|
||||
const previousMaterial = mesh.material
|
||||
mesh.material = material
|
||||
return () => {
|
||||
mesh.material = previousMaterial
|
||||
}
|
||||
}
|
||||
|
||||
function previewCursor(cursor: string): PaintPreviewCleanup {
|
||||
const previousCursor = document.body.style.cursor
|
||||
document.body.style.cursor = cursor
|
||||
return () => {
|
||||
document.body.style.cursor = previousCursor
|
||||
}
|
||||
}
|
||||
|
||||
function getSingleSurfacePreviewMaterial(material: ActivePaintMaterial): Material | null {
|
||||
if (material.materialPreset) {
|
||||
return createMaterialFromPresetRef(material.materialPreset)
|
||||
}
|
||||
|
||||
if (material.material) {
|
||||
return createMaterial(material.material)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function applyWallPaintPreview(
|
||||
node: WallNode,
|
||||
role: WallSurfaceSide,
|
||||
material: ActivePaintMaterial,
|
||||
): PaintPreviewCleanup | null {
|
||||
const mesh = getRegisteredMesh(node.id)
|
||||
if (!mesh) return null
|
||||
|
||||
const previewNode = {
|
||||
...node,
|
||||
...buildWallSurfaceMaterialPatch(node, role, material.material, material.materialPreset),
|
||||
}
|
||||
|
||||
return previewMeshMaterial(mesh, getVisibleWallMaterials(previewNode))
|
||||
}
|
||||
|
||||
function applyRoofPaintPreview(
|
||||
node: RoofNode,
|
||||
role: 'top' | 'edge' | 'wall',
|
||||
material: ActivePaintMaterial,
|
||||
): PaintPreviewCleanup | null {
|
||||
const root = getRegisteredNodeObject(node.id)
|
||||
const mesh = root?.getObjectByName('merged-roof') as Mesh | undefined
|
||||
if (!mesh) return null
|
||||
|
||||
const previewNode = {
|
||||
...node,
|
||||
...buildRoofSurfaceMaterialPatch(node, role, material.material, material.materialPreset),
|
||||
}
|
||||
const previewMaterial = getRoofMaterialArray(previewNode)
|
||||
if (!previewMaterial) return null
|
||||
|
||||
return previewMeshMaterial(mesh, previewMaterial)
|
||||
}
|
||||
|
||||
function applyStairPaintPreview(
|
||||
node: StairNode,
|
||||
role: StairSurfaceMaterialRole,
|
||||
material: ActivePaintMaterial,
|
||||
): PaintPreviewCleanup | null {
|
||||
const root = getRegisteredNodeObject(node.id)
|
||||
if (!root) return null
|
||||
|
||||
const previewNode = {
|
||||
...node,
|
||||
...buildStairSurfaceMaterialPatch(node, role, material.material, material.materialPreset),
|
||||
}
|
||||
const bodyMaterials = getStairBodyMaterials(previewNode)
|
||||
const railingMaterial = getStairRailingMaterial(previewNode)
|
||||
const restores: PaintPreviewCleanup[] = []
|
||||
|
||||
root.traverse((object) => {
|
||||
if (!(object as Mesh).isMesh) return
|
||||
const mesh = object as Mesh
|
||||
if (mesh.name.startsWith('stair-railing')) {
|
||||
restores.push(previewMeshMaterial(mesh, railingMaterial))
|
||||
return
|
||||
}
|
||||
if (Array.isArray(mesh.material) && mesh.material.length === 2) {
|
||||
restores.push(previewMeshMaterial(mesh, bodyMaterials))
|
||||
return
|
||||
}
|
||||
if (mesh.name === 'merged-stair') {
|
||||
restores.push(previewMeshMaterial(mesh, bodyMaterials))
|
||||
return
|
||||
}
|
||||
if (mesh.name.startsWith('stair-side')) {
|
||||
restores.push(previewMeshMaterial(mesh, bodyMaterials[1]))
|
||||
}
|
||||
})
|
||||
|
||||
if (restores.length === 0) return null
|
||||
|
||||
return () => {
|
||||
for (let index = restores.length - 1; index >= 0; index -= 1) {
|
||||
restores[index]?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applySingleSurfacePaintPreview(
|
||||
node: FenceNode | SlabNode | CeilingNode,
|
||||
material: ActivePaintMaterial,
|
||||
): PaintPreviewCleanup | null {
|
||||
if (node.type === 'ceiling') {
|
||||
const root = getRegisteredMesh(node.id)
|
||||
const overlay = root?.getObjectByName('ceiling-grid') as Mesh | undefined
|
||||
if (!root || !overlay) return null
|
||||
|
||||
const previewColor =
|
||||
getMaterialPresetByRef(material.materialPreset)?.mapProperties.color ??
|
||||
resolveMaterial(material.material).color ??
|
||||
'#999999'
|
||||
|
||||
const previousRootMaterial = root.material
|
||||
const previousOverlayMaterial = overlay.material
|
||||
const rootPreviewMaterial = Array.isArray(previousRootMaterial)
|
||||
? previousRootMaterial.map((entry) => entry.clone())
|
||||
: previousRootMaterial.clone()
|
||||
const overlayPreviewMaterial = Array.isArray(previousOverlayMaterial)
|
||||
? previousOverlayMaterial.map((entry) => entry.clone())
|
||||
: previousOverlayMaterial.clone()
|
||||
|
||||
const applyColor = (input: Material | Material[]) => {
|
||||
const materials = Array.isArray(input) ? input : [input]
|
||||
for (const entry of materials) {
|
||||
const materialWithColor = entry as Material & { color?: Color; needsUpdate?: boolean }
|
||||
if (materialWithColor.color instanceof Color) {
|
||||
materialWithColor.color = new Color(previewColor)
|
||||
}
|
||||
materialWithColor.needsUpdate = true
|
||||
}
|
||||
}
|
||||
|
||||
applyColor(rootPreviewMaterial)
|
||||
applyColor(overlayPreviewMaterial)
|
||||
root.material = rootPreviewMaterial
|
||||
overlay.material = overlayPreviewMaterial
|
||||
|
||||
return () => {
|
||||
root.material = previousRootMaterial
|
||||
overlay.material = previousOverlayMaterial
|
||||
}
|
||||
}
|
||||
|
||||
const mesh = getRegisteredMesh(node.id)
|
||||
if (!mesh) return null
|
||||
|
||||
const previewMaterial = getSingleSurfacePreviewMaterial(material)
|
||||
if (!previewMaterial) return null
|
||||
|
||||
if (node.type === 'slab') {
|
||||
const slabMaterial = previewMaterial.clone()
|
||||
applyMaterialPresetToMaterials(slabMaterial, getMaterialPresetByRef(material.materialPreset))
|
||||
const previewMeshMaterialInput = slabMaterial as Material & {
|
||||
alphaMap?: unknown
|
||||
depthWrite?: boolean
|
||||
needsUpdate?: boolean
|
||||
opacity?: number
|
||||
side?: number
|
||||
transparent?: boolean
|
||||
}
|
||||
previewMeshMaterialInput.transparent = false
|
||||
previewMeshMaterialInput.opacity = 1
|
||||
previewMeshMaterialInput.alphaMap = null
|
||||
previewMeshMaterialInput.depthWrite = true
|
||||
previewMeshMaterialInput.needsUpdate = true
|
||||
return previewMeshMaterial(mesh, slabMaterial)
|
||||
}
|
||||
|
||||
return previewMeshMaterial(mesh, previewMaterial)
|
||||
}
|
||||
|
||||
function setSelectedMaterialTargetForNode(node: AnyNode, role: MaterialTargetRole | null) {
|
||||
if (!role) {
|
||||
const currentTarget = useEditor.getState().selectedMaterialTarget
|
||||
if (currentTarget?.nodeId !== node.id) {
|
||||
@@ -209,6 +435,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
|
||||
@@ -475,13 +702,292 @@ export const SelectionManager = () => {
|
||||
const curvingFence = useEditor((s) => s.curvingFence)
|
||||
|
||||
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
|
||||
|
||||
let activePreview: { key: string; restore: PaintPreviewCleanup } | null = null
|
||||
|
||||
const clearActivePreview = () => {
|
||||
activePreview?.restore()
|
||||
activePreview = null
|
||||
}
|
||||
|
||||
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): PaintInteraction | 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 && hasActivePaintMaterial(activePaintMaterial)
|
||||
return {
|
||||
key: `wall:${node.id}:${role ?? 'unsupported'}`,
|
||||
hoveredId: node.id as AnyNodeId,
|
||||
hoverMode:
|
||||
compatible && hasActivePaintMaterial(activePaintMaterial) && role
|
||||
? 'paint-ready'
|
||||
: 'paint-disabled',
|
||||
apply:
|
||||
compatible && hasActivePaintMaterial(activePaintMaterial)
|
||||
? () => {
|
||||
useScene
|
||||
.getState()
|
||||
.updateNode(
|
||||
node.id as AnyNodeId,
|
||||
buildWallSurfaceMaterialPatch(
|
||||
node as WallNode,
|
||||
role!,
|
||||
activePaintMaterial.material,
|
||||
activePaintMaterial.materialPreset,
|
||||
),
|
||||
)
|
||||
}
|
||||
: null,
|
||||
preview:
|
||||
compatible && hasActivePaintMaterial(activePaintMaterial) && role
|
||||
? () => applyWallPaintPreview(node as WallNode, role, activePaintMaterial)
|
||||
: () => previewCursor('not-allowed'),
|
||||
}
|
||||
}
|
||||
|
||||
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 && hasActivePaintMaterial(activePaintMaterial)
|
||||
return {
|
||||
key: `roof:${roofNode.id}:${role ?? 'unsupported'}`,
|
||||
hoveredId: roofNode.id as AnyNodeId,
|
||||
hoverMode:
|
||||
compatible && hasActivePaintMaterial(activePaintMaterial) && role
|
||||
? 'paint-ready'
|
||||
: 'paint-disabled',
|
||||
apply:
|
||||
compatible && hasActivePaintMaterial(activePaintMaterial)
|
||||
? () => {
|
||||
useScene
|
||||
.getState()
|
||||
.updateNode(
|
||||
roofNode.id as AnyNodeId,
|
||||
buildRoofSurfaceMaterialPatch(
|
||||
roofNode as RoofNode,
|
||||
role!,
|
||||
activePaintMaterial.material,
|
||||
activePaintMaterial.materialPreset,
|
||||
),
|
||||
)
|
||||
}
|
||||
: null,
|
||||
preview:
|
||||
compatible && hasActivePaintMaterial(activePaintMaterial) && role
|
||||
? () => applyRoofPaintPreview(roofNode as RoofNode, role, activePaintMaterial)
|
||||
: () => previewCursor('not-allowed'),
|
||||
}
|
||||
}
|
||||
|
||||
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 && hasActivePaintMaterial(activePaintMaterial)
|
||||
return {
|
||||
key: `stair:${stairNode.id}:${role ?? 'unsupported'}`,
|
||||
hoveredId: stairNode.id as AnyNodeId,
|
||||
hoverMode:
|
||||
compatible && hasActivePaintMaterial(activePaintMaterial) && role
|
||||
? 'paint-ready'
|
||||
: 'paint-disabled',
|
||||
apply:
|
||||
compatible && hasActivePaintMaterial(activePaintMaterial)
|
||||
? () => {
|
||||
useScene
|
||||
.getState()
|
||||
.updateNode(
|
||||
stairNode.id as AnyNodeId,
|
||||
buildStairSurfaceMaterialPatch(
|
||||
stairNode as StairNode,
|
||||
role!,
|
||||
activePaintMaterial.material,
|
||||
activePaintMaterial.materialPreset,
|
||||
),
|
||||
)
|
||||
}
|
||||
: null,
|
||||
preview:
|
||||
compatible && hasActivePaintMaterial(activePaintMaterial) && role
|
||||
? () => applyStairPaintPreview(stairNode as StairNode, role, activePaintMaterial)
|
||||
: () => previewCursor('not-allowed'),
|
||||
}
|
||||
}
|
||||
|
||||
if (node.type === 'fence' || node.type === 'slab' || node.type === 'ceiling') {
|
||||
const compatible = hasActivePaintMaterial(activePaintMaterial)
|
||||
|
||||
return {
|
||||
key: `${node.type}:${node.id}:surface`,
|
||||
hoveredId: node.id as AnyNodeId,
|
||||
hoverMode: compatible ? 'paint-ready' : 'paint-disabled',
|
||||
apply: compatible
|
||||
? () => {
|
||||
useScene
|
||||
.getState()
|
||||
.updateNode(
|
||||
node.id as AnyNodeId,
|
||||
buildSingleSurfaceMaterialPatch<FenceNode | SlabNode | CeilingNode>(
|
||||
activePaintMaterial.material,
|
||||
activePaintMaterial.materialPreset,
|
||||
),
|
||||
)
|
||||
}
|
||||
: null,
|
||||
preview: compatible
|
||||
? () =>
|
||||
applySingleSurfacePaintPreview(
|
||||
node as FenceNode | SlabNode | CeilingNode,
|
||||
activePaintMaterial,
|
||||
)
|
||||
: () => previewCursor('not-allowed'),
|
||||
}
|
||||
}
|
||||
|
||||
const disabledNodeTypes = ['item', 'window', 'door', 'zone']
|
||||
if (disabledNodeTypes.includes(node.type)) {
|
||||
return {
|
||||
key: `${node.type}:${node.id}:unsupported`,
|
||||
hoveredId: node.id as AnyNodeId,
|
||||
hoverMode: 'paint-disabled',
|
||||
apply: null,
|
||||
preview: () => previewCursor('not-allowed'),
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const onEnter = (event: NodeEvent) => {
|
||||
if (boxSelectHandled) return
|
||||
|
||||
const interaction = getPaintInteraction(event)
|
||||
if (!interaction) return
|
||||
|
||||
event.stopPropagation()
|
||||
|
||||
if (activePreview?.key === interaction.key) {
|
||||
return
|
||||
}
|
||||
|
||||
clearActivePreview()
|
||||
useViewer.setState({ hoveredId: interaction.hoveredId })
|
||||
setHoverHighlightMode(interaction.hoverMode)
|
||||
|
||||
const restore = interaction.preview?.()
|
||||
if (restore) {
|
||||
activePreview = { key: interaction.key, restore }
|
||||
}
|
||||
}
|
||||
|
||||
const onLeave = (event: NodeEvent) => {
|
||||
const interaction = getPaintInteraction(event)
|
||||
if (!interaction) return
|
||||
|
||||
if (activePreview?.key !== interaction.key) {
|
||||
return
|
||||
}
|
||||
|
||||
clearActivePreview()
|
||||
if (useViewer.getState().hoveredId === interaction.hoveredId) {
|
||||
useViewer.setState({ hoveredId: null })
|
||||
}
|
||||
setHoverHighlightMode('default')
|
||||
}
|
||||
|
||||
const onClick = (event: NodeEvent) => {
|
||||
if (boxSelectHandled) return
|
||||
|
||||
const interaction = getPaintInteraction(event)
|
||||
if (!interaction) return
|
||||
|
||||
event.stopPropagation()
|
||||
|
||||
if (!interaction.apply) {
|
||||
return
|
||||
}
|
||||
|
||||
interaction.apply()
|
||||
if (activePreview?.key === interaction.key) {
|
||||
activePreview = null
|
||||
} else {
|
||||
clearActivePreview()
|
||||
}
|
||||
setHoverHighlightMode(interaction.hoverMode)
|
||||
}
|
||||
|
||||
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}:enter` as any, onEnter as any)
|
||||
emitter.on(`${type}:leave` as any, onLeave as any)
|
||||
emitter.on(`${type}:click` as any, onClick as any)
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const type of allTypes) {
|
||||
emitter.off(`${type}:enter` as any, onEnter as any)
|
||||
emitter.off(`${type}:leave` as any, onLeave as any)
|
||||
emitter.off(`${type}:click` as any, onClick as any)
|
||||
}
|
||||
clearActivePreview()
|
||||
useViewer.setState({ hoveredId: null })
|
||||
setHoverHighlightMode('default')
|
||||
}
|
||||
}, [curvingWall, mode, movingNode, setHoverHighlightMode])
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Meta') modifierKeysRef.current.meta = true
|
||||
@@ -597,6 +1103,14 @@ export const SelectionManager = () => {
|
||||
nextMaterialTargetHandled = true
|
||||
}
|
||||
|
||||
if (
|
||||
(node.type === 'fence' || node.type === 'slab' || node.type === 'ceiling') &&
|
||||
nodeToSelect.type === node.type
|
||||
) {
|
||||
setSelectedMaterialTargetForNode(nodeToSelect, 'surface')
|
||||
nextMaterialTargetHandled = true
|
||||
}
|
||||
|
||||
if (!nextMaterialTargetHandled && useEditor.getState().selectedMaterialTarget) {
|
||||
useEditor.getState().setSelectedMaterialTarget(null)
|
||||
}
|
||||
@@ -912,7 +1426,12 @@ 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 !== 'fence' &&
|
||||
selectedNode.type !== 'slab' &&
|
||||
selectedNode.type !== 'ceiling' &&
|
||||
selectedNode.type !== 'stair' &&
|
||||
selectedNode.type !== 'roof')
|
||||
) {
|
||||
setSelectedMaterialTarget(null)
|
||||
return
|
||||
@@ -1022,7 +1541,8 @@ const SelectionMaterialSync = () => {
|
||||
}, [hoverHighlightMode, hoveredId, previewSelectedIds, selectedIds, syncSelectionMaterials])
|
||||
|
||||
useEffect(() => {
|
||||
return useScene.subscribe(() => {
|
||||
return useScene.subscribe((state, prevState) => {
|
||||
if (state.nodes === prevState.nodes) return
|
||||
syncSelectionMaterials()
|
||||
})
|
||||
}, [syncSelectionMaterials])
|
||||
|
||||
@@ -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',
|
||||
imageSrc: '/icons/paint.png',
|
||||
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,13 @@
|
||||
'use client'
|
||||
|
||||
import { useScene } from '@pascal-app/core'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { TooltipProvider } from './../../../components/ui/primitives/tooltip'
|
||||
import { MaterialPicker } from './../../../components/ui/controls/material-picker'
|
||||
import { useReducedMotion } from './../../../hooks/use-reduced-motion'
|
||||
import { 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 +17,49 @@ 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])
|
||||
|
||||
return (
|
||||
<div className="w-[42rem] max-w-[calc(100vw-2rem)]">
|
||||
<MaterialPicker
|
||||
onChange={(material) => {
|
||||
setActivePaintMaterial({ material, sourceTarget: activePaintTarget })
|
||||
}}
|
||||
onSelectMaterialPreset={(materialPreset) => {
|
||||
setActivePaintMaterial({ materialPreset, sourceTarget: activePaintTarget })
|
||||
}}
|
||||
selectedMaterialPreset={activePaintMaterial?.materialPreset}
|
||||
value={activePaintMaterial?.material}
|
||||
/>
|
||||
</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 +180,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,
|
||||
@@ -49,6 +50,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)
|
||||
|
||||
@@ -150,6 +152,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 ───────────────────────────────────────────────────────────
|
||||
{
|
||||
@@ -355,7 +372,7 @@ export function EditorCommands() {
|
||||
icon: <Box className="h-4 w-4" />,
|
||||
keywords: ['export', 'glb', 'gltf', '3d', 'model', 'download'],
|
||||
execute: () => run(() => exportScene()),
|
||||
} as const,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
|
||||
@@ -1,59 +1,117 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
getMaterialsForTarget,
|
||||
getCatalogMaterialById,
|
||||
getLibraryMaterialIdFromRef,
|
||||
getMaterialsForCategory,
|
||||
MATERIAL_CATEGORIES,
|
||||
toLibraryMaterialRef,
|
||||
type MaterialSchema,
|
||||
type MaterialTarget,
|
||||
} from '@pascal-app/core'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
|
||||
type MaterialPickerProps = {
|
||||
nodeType?: MaterialTarget
|
||||
value?: MaterialSchema
|
||||
selectedMaterialPreset?: string
|
||||
onChange?: (material: MaterialSchema) => void
|
||||
onSelectMaterialPreset?: (materialPreset: string) => void
|
||||
hideSideControl?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function MaterialPicker({
|
||||
nodeType,
|
||||
value,
|
||||
selectedMaterialPreset,
|
||||
onChange,
|
||||
onSelectMaterialPreset,
|
||||
hideSideControl = false,
|
||||
disabled = false,
|
||||
}: MaterialPickerProps) {
|
||||
const setPaintPanelOpen = useEditor((state) => state.setPaintPanelOpen)
|
||||
const [showCustom, setShowCustom] = useState<boolean>(!!value?.properties)
|
||||
const catalogItems = nodeType ? getMaterialsForTarget(nodeType) : []
|
||||
const [selectedCategory, setSelectedCategory] = useState<(typeof MATERIAL_CATEGORIES)[number]>(
|
||||
MATERIAL_CATEGORIES[0],
|
||||
)
|
||||
const catalogScrollRef = useRef<HTMLDivElement>(null)
|
||||
const categoryScrollRef = useRef<HTMLDivElement>(null)
|
||||
const catalogItems =
|
||||
selectedCategory === 'other'
|
||||
? getMaterialsForCategory('other')
|
||||
: getMaterialsForCategory(selectedCategory)
|
||||
|
||||
useEffect(() => {
|
||||
setShowCustom(!!value?.properties && !selectedMaterialPreset)
|
||||
}, [selectedMaterialPreset, value?.properties])
|
||||
|
||||
const currentProps = value?.properties || {
|
||||
color: '#ffffff',
|
||||
roughness: 0.5,
|
||||
metalness: 0,
|
||||
opacity: 1,
|
||||
transparent: false,
|
||||
side: 'front' as const,
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!selectedMaterialPreset && value?.properties) {
|
||||
setSelectedCategory('other')
|
||||
return
|
||||
}
|
||||
|
||||
const catalogId =
|
||||
getLibraryMaterialIdFromRef(selectedMaterialPreset) ?? value?.id ?? undefined
|
||||
const selectedCatalogEntry = getCatalogMaterialById(catalogId)
|
||||
if (selectedCatalogEntry?.category) {
|
||||
setSelectedCategory(selectedCatalogEntry.category)
|
||||
}
|
||||
}, [selectedMaterialPreset, value?.id])
|
||||
|
||||
const selectedCatalogId =
|
||||
selectedMaterialPreset ?? (value?.id ? toLibraryMaterialRef(value.id) : undefined)
|
||||
|
||||
const handleCatalogSelect = (materialId: string) => {
|
||||
if (disabled) return
|
||||
setShowCustom(false)
|
||||
setPaintPanelOpen(false)
|
||||
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])
|
||||
|
||||
useEffect(() => {
|
||||
const container = categoryScrollRef.current
|
||||
if (!container) return
|
||||
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
const deltaX = event.deltaX
|
||||
const deltaY = event.deltaY
|
||||
const nextScrollLeft = container.scrollLeft + deltaX + deltaY
|
||||
|
||||
if (nextScrollLeft === container.scrollLeft) return
|
||||
|
||||
event.preventDefault()
|
||||
container.scrollLeft = nextScrollLeft
|
||||
}
|
||||
|
||||
container.addEventListener('wheel', handleWheel, { passive: false })
|
||||
return () => {
|
||||
container.removeEventListener('wheel', handleWheel)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleCustomOpen = () => {
|
||||
if (disabled) return
|
||||
setShowCustom(true)
|
||||
setPaintPanelOpen(true)
|
||||
onChange?.({
|
||||
preset: 'custom',
|
||||
properties: {
|
||||
@@ -67,159 +125,88 @@ export function MaterialPicker({
|
||||
})
|
||||
}
|
||||
|
||||
const handlePropertyChange = (
|
||||
prop: keyof typeof currentProps,
|
||||
val: (typeof currentProps)[keyof typeof currentProps],
|
||||
) => {
|
||||
if (disabled) return
|
||||
onChange?.({
|
||||
preset: 'custom',
|
||||
properties: {
|
||||
...currentProps,
|
||||
[prop]: val,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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">
|
||||
{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">
|
||||
{catalogItems.map((item) => (
|
||||
<button
|
||||
className={`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'
|
||||
}`}
|
||||
key={item.id}
|
||||
onClick={() => handleCatalogSelect(item.id)}
|
||||
title={item.label}
|
||||
type="button"
|
||||
>
|
||||
{item.previewThumbnailUrl ? (
|
||||
<img
|
||||
alt={item.label}
|
||||
className="h-full w-full object-cover"
|
||||
src={item.previewThumbnailUrl}
|
||||
/>
|
||||
) : item.previewColor ? (
|
||||
<div className="h-full w-full" style={{ backgroundColor: item.previewColor }} />
|
||||
) : (
|
||||
<div className="h-full w-full bg-gray-100" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{onChange ? (
|
||||
<button
|
||||
className={`flex h-14 w-14 shrink-0 items-center justify-center rounded-lg border text-[10px] font-medium transition-all ${
|
||||
showCustom
|
||||
? 'border-blue-500 bg-blue-50 text-blue-700 ring-2 ring-blue-500/30'
|
||||
: 'border-gray-300 bg-white text-gray-500 hover:border-gray-400'
|
||||
}`}
|
||||
onClick={handleCustomOpen}
|
||||
title="Custom"
|
||||
type="button"
|
||||
>
|
||||
Custom
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCustom && onChange && (
|
||||
<div className="space-y-2 pt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="w-16 text-gray-500 text-xs">Color</label>
|
||||
<input
|
||||
className="h-7 w-12 cursor-pointer rounded border border-gray-300"
|
||||
onChange={(e) => handlePropertyChange('color', e.target.value)}
|
||||
type="color"
|
||||
value={currentProps.color}
|
||||
/>
|
||||
<input
|
||||
className="h-7 flex-1 rounded border border-gray-300 px-2 text-xs"
|
||||
onChange={(e) => handlePropertyChange('color', e.target.value)}
|
||||
type="text"
|
||||
value={currentProps.color}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="w-16 text-gray-500 text-xs">Roughness</label>
|
||||
<input
|
||||
className="h-1.5 flex-1 cursor-pointer appearance-none rounded-lg bg-gray-200"
|
||||
max={1}
|
||||
min={0}
|
||||
onChange={(e) => handlePropertyChange('roughness', Number.parseFloat(e.target.value))}
|
||||
step={0.01}
|
||||
type="range"
|
||||
value={currentProps.roughness}
|
||||
/>
|
||||
<span className="w-8 text-right text-gray-400 text-xs">
|
||||
{currentProps.roughness.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="w-16 text-gray-500 text-xs">Metalness</label>
|
||||
<input
|
||||
className="h-1.5 flex-1 cursor-pointer appearance-none rounded-lg bg-gray-200"
|
||||
max={1}
|
||||
min={0}
|
||||
onChange={(e) => handlePropertyChange('metalness', Number.parseFloat(e.target.value))}
|
||||
step={0.01}
|
||||
type="range"
|
||||
value={currentProps.metalness}
|
||||
/>
|
||||
<span className="w-8 text-right text-gray-400 text-xs">
|
||||
{currentProps.metalness.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="w-16 text-gray-500 text-xs">Opacity</label>
|
||||
<input
|
||||
className="h-1.5 flex-1 cursor-pointer appearance-none rounded-lg bg-gray-200"
|
||||
max={1}
|
||||
min={0}
|
||||
onChange={(e) => {
|
||||
const opacity = Number.parseFloat(e.target.value)
|
||||
handlePropertyChange('opacity', opacity)
|
||||
if (opacity < 1 && !currentProps.transparent) {
|
||||
handlePropertyChange('transparent', true)
|
||||
}
|
||||
}}
|
||||
step={0.01}
|
||||
type="range"
|
||||
value={currentProps.opacity}
|
||||
/>
|
||||
<span className="w-8 text-right text-gray-400 text-xs">
|
||||
{currentProps.opacity.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!hideSideControl && (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="w-16 text-gray-500 text-xs">Side</label>
|
||||
<select
|
||||
className="h-7 flex-1 rounded border border-gray-300 px-2 text-xs"
|
||||
onChange={(e) =>
|
||||
handlePropertyChange('side', e.target.value as 'front' | 'back' | 'double')
|
||||
}
|
||||
value={currentProps.side}
|
||||
>
|
||||
<option value="front">Front</option>
|
||||
<option value="back">Back</option>
|
||||
<option value="double">Double</option>
|
||||
</select>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div
|
||||
className="w-full max-w-full overflow-x-auto overflow-y-hidden"
|
||||
ref={categoryScrollRef}
|
||||
style={{ msOverflowStyle: 'none', scrollbarWidth: 'none' }}
|
||||
>
|
||||
<div className="flex min-w-max gap-1 pb-1">
|
||||
{MATERIAL_CATEGORIES.map((category) => (
|
||||
<button
|
||||
className={`shrink-0 px-2 font-medium text-[11px] uppercase tracking-[0.12em] transition-all ${
|
||||
selectedCategory === category
|
||||
? 'bg-transparent text-foreground'
|
||||
: 'bg-transparent text-muted-foreground opacity-70 hover:text-foreground hover:opacity-100'
|
||||
}`}
|
||||
key={category}
|
||||
onClick={() => {
|
||||
setSelectedCategory(category)
|
||||
if (showCustom) {
|
||||
setShowCustom(false)
|
||||
}
|
||||
if (category !== 'other') {
|
||||
setPaintPanelOpen(false)
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{category.charAt(0).toUpperCase() + category.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<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={`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'
|
||||
}`}
|
||||
key={item.id}
|
||||
onClick={() => handleCatalogSelect(item.id)}
|
||||
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}
|
||||
className="h-full w-full object-cover"
|
||||
src={item.previewThumbnailUrl}
|
||||
/>
|
||||
) : item.previewColor ? (
|
||||
<div className="h-full w-full" style={{ backgroundColor: item.previewColor }} />
|
||||
) : (
|
||||
<div className="h-full w-full bg-gray-100" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{selectedCategory === 'other' && onChange ? (
|
||||
<button
|
||||
className={`flex h-14 w-14 shrink-0 items-center justify-center rounded-lg border text-[10px] font-medium transition-all ${
|
||||
showCustom
|
||||
? 'border-blue-500 ring-2 ring-blue-500/30'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
}`}
|
||||
onClick={handleCustomOpen}
|
||||
title="Custom"
|
||||
type="button"
|
||||
>
|
||||
Custom
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { type AnyNode, type CeilingNode, type MaterialSchema, useScene } from '@pascal-app/core'
|
||||
import { type AnyNode, type CeilingNode, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Edit, Move, Plus, Trash2 } from 'lucide-react'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||
import { MaterialPicker } from '../controls/material-picker'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { SliderControl } from '../controls/slider-control'
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
@@ -32,20 +31,6 @@ export function CeilingPanel() {
|
||||
[selectedId, updateNode],
|
||||
)
|
||||
|
||||
const handleMaterialChange = useCallback(
|
||||
(material: MaterialSchema) => {
|
||||
handleUpdate({ material, materialPreset: undefined })
|
||||
},
|
||||
[handleUpdate],
|
||||
)
|
||||
|
||||
const handleMaterialPresetChange = useCallback(
|
||||
(materialPreset: string) => {
|
||||
handleUpdate({ materialPreset, material: undefined })
|
||||
},
|
||||
[handleUpdate],
|
||||
)
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSelection({ selectedIds: [] })
|
||||
setEditingHole(null)
|
||||
@@ -257,15 +242,6 @@ export function CeilingPanel() {
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Material">
|
||||
<MaterialPicker
|
||||
nodeType="ceiling"
|
||||
onChange={handleMaterialChange}
|
||||
onSelectMaterialPreset={handleMaterialPresetChange}
|
||||
selectedMaterialPreset={node.materialPreset}
|
||||
value={node.material}
|
||||
/>
|
||||
</PanelSection>
|
||||
<ActionGroup>
|
||||
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||
</ActionGroup>
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
type AnyNodeId,
|
||||
DoorNode,
|
||||
emitter,
|
||||
type MaterialSchema,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
@@ -15,7 +14,6 @@ import { usePresetsAdapter } from '../../../contexts/presets-context'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||
import { MaterialPicker } from '../controls/material-picker'
|
||||
import { MetricControl } from '../controls/metric-control'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { SegmentedControl } from '../controls/segmented-control'
|
||||
@@ -46,13 +44,6 @@ export function DoorPanel() {
|
||||
[selectedId, updateNode],
|
||||
)
|
||||
|
||||
const handleMaterialChange = useCallback(
|
||||
(material: MaterialSchema) => {
|
||||
handleUpdate({ material })
|
||||
},
|
||||
[handleUpdate],
|
||||
)
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [setSelection])
|
||||
@@ -592,9 +583,6 @@ export function DoorPanel() {
|
||||
/>
|
||||
</ActionGroup>
|
||||
</PanelSection>
|
||||
<PanelSection title="Material">
|
||||
<MaterialPicker onChange={handleMaterialChange} value={node.material} />
|
||||
</PanelSection>
|
||||
</PanelWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
@@ -11,9 +12,11 @@ import {
|
||||
normalizeWallCurveOffset,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Move, Spline } from 'lucide-react'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||
@@ -83,33 +86,9 @@ export function FencePanel() {
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [setSelection])
|
||||
|
||||
const handleMove = useCallback(() => {
|
||||
if (!node) return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
setMovingNode(node)
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [node, setMovingNode, setSelection])
|
||||
|
||||
const handleCurve = useCallback(() => {
|
||||
if (!node) return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
setCurvingFence(node)
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [node, setCurvingFence, setSelection])
|
||||
|
||||
const handleMaterialPresetChange = useCallback(
|
||||
(materialPreset: string) => {
|
||||
handleUpdate({ materialPreset, material: undefined })
|
||||
},
|
||||
[handleUpdate],
|
||||
)
|
||||
|
||||
const handleCustomMaterialChange = useCallback(
|
||||
(material: MaterialSchema) => {
|
||||
handleUpdate({ material, materialPreset: undefined })
|
||||
},
|
||||
[handleUpdate],
|
||||
)
|
||||
|
||||
if (!(node && node.type === 'fence' && selectedId && selectedCount === 1)) return null
|
||||
|
||||
@@ -243,27 +222,6 @@ export function FencePanel() {
|
||||
value={node.edgeInset}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Material">
|
||||
<MaterialPicker
|
||||
nodeType="fence"
|
||||
onChange={handleCustomMaterialChange}
|
||||
onSelectMaterialPreset={handleMaterialPresetChange}
|
||||
selectedMaterialPreset={node.materialPreset}
|
||||
value={node.material}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Actions">
|
||||
<ActionGroup>
|
||||
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||
<ActionButton
|
||||
icon={<Spline className="h-3.5 w-3.5" />}
|
||||
label="Curve"
|
||||
onClick={handleCurve}
|
||||
/>
|
||||
</ActionGroup>
|
||||
</PanelSection>
|
||||
</PanelWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
'use client'
|
||||
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { Input } from '../primitives/input'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
|
||||
function buildDefaultCustomMaterial() {
|
||||
return {
|
||||
preset: 'custom' as const,
|
||||
properties: {
|
||||
color: '#ffffff',
|
||||
roughness: 0.5,
|
||||
metalness: 0,
|
||||
opacity: 1,
|
||||
transparent: false,
|
||||
side: 'front' as const,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function PaintPanel() {
|
||||
const activePaintMaterial = useEditor((state) => state.activePaintMaterial)
|
||||
const activePaintTarget = useEditor((state) => state.activePaintTarget)
|
||||
const setActivePaintMaterial = useEditor((state) => state.setActivePaintMaterial)
|
||||
const setPaintPanelOpen = useEditor((state) => state.setPaintPanelOpen)
|
||||
|
||||
const customMaterial =
|
||||
activePaintMaterial?.material?.properties && !activePaintMaterial.materialPreset
|
||||
? activePaintMaterial.material
|
||||
: null
|
||||
|
||||
if (!customMaterial) return null
|
||||
|
||||
const currentProps = customMaterial.properties ?? buildDefaultCustomMaterial().properties
|
||||
|
||||
const updateCustomMaterial = (
|
||||
updates: Partial<typeof currentProps>,
|
||||
nextTransparent = currentProps.transparent,
|
||||
) => {
|
||||
setActivePaintMaterial({
|
||||
material: {
|
||||
preset: 'custom',
|
||||
properties: {
|
||||
...currentProps,
|
||||
...updates,
|
||||
transparent: nextTransparent,
|
||||
},
|
||||
},
|
||||
sourceTarget: activePaintMaterial?.sourceTarget ?? activePaintTarget,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<PanelWrapper
|
||||
onClose={() => setPaintPanelOpen(false)}
|
||||
title="Material"
|
||||
width={320}
|
||||
>
|
||||
<PanelSection title="Custom Material">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<label className="block font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
|
||||
Color
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
className="h-10 w-14 cursor-pointer rounded-md border border-input bg-transparent"
|
||||
onChange={(e) => updateCustomMaterial({ color: e.target.value })}
|
||||
type="color"
|
||||
value={currentProps.color}
|
||||
/>
|
||||
<Input
|
||||
onChange={(e) => updateCustomMaterial({ color: e.target.value })}
|
||||
value={currentProps.color}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
|
||||
Roughness
|
||||
</label>
|
||||
<span className="font-mono text-muted-foreground text-xs">
|
||||
{currentProps.roughness.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-accent"
|
||||
max={1}
|
||||
min={0}
|
||||
onChange={(e) => updateCustomMaterial({ roughness: Number.parseFloat(e.target.value) })}
|
||||
step={0.01}
|
||||
type="range"
|
||||
value={currentProps.roughness}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
|
||||
Metalness
|
||||
</label>
|
||||
<span className="font-mono text-muted-foreground text-xs">
|
||||
{currentProps.metalness.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-accent"
|
||||
max={1}
|
||||
min={0}
|
||||
onChange={(e) => updateCustomMaterial({ metalness: Number.parseFloat(e.target.value) })}
|
||||
step={0.01}
|
||||
type="range"
|
||||
value={currentProps.metalness}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
|
||||
Opacity
|
||||
</label>
|
||||
<span className="font-mono text-muted-foreground text-xs">
|
||||
{currentProps.opacity.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-accent"
|
||||
max={1}
|
||||
min={0}
|
||||
onChange={(e) => {
|
||||
const opacity = Number.parseFloat(e.target.value)
|
||||
updateCustomMaterial({ opacity }, opacity < 1 || currentProps.transparent)
|
||||
}}
|
||||
step={0.01}
|
||||
type="range"
|
||||
value={currentProps.opacity}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
|
||||
Side
|
||||
</label>
|
||||
<select
|
||||
className="h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:bg-input/30"
|
||||
onChange={(e) =>
|
||||
updateCustomMaterial({ side: e.target.value as 'front' | 'back' | 'double' })
|
||||
}
|
||||
value={currentProps.side}
|
||||
>
|
||||
<option value="front">Front</option>
|
||||
<option value="back">Back</option>
|
||||
<option value="double">Double</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</PanelSection>
|
||||
</PanelWrapper>
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { CeilingPanel } from './ceiling-panel'
|
||||
import { DoorPanel } from './door-panel'
|
||||
import { FencePanel } from './fence-panel'
|
||||
import { ItemPanel } from './item-panel'
|
||||
import { PaintPanel } from './paint-panel'
|
||||
import { ReferencePanel } from './reference-panel'
|
||||
import { RoofPanel } from './roof-panel'
|
||||
import { RoofSegmentPanel } from './roof-segment-panel'
|
||||
@@ -19,6 +20,9 @@ import { WindowPanel } from './window-panel'
|
||||
export function PanelManager() {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
|
||||
const isPaintPanelOpen = useEditor((s) => s.isPaintPanelOpen)
|
||||
const mode = useEditor((s) => s.mode)
|
||||
const activePaintMaterial = useEditor((s) => s.activePaintMaterial)
|
||||
// Only subscribe to the *type* of the single-selected node — string primitive
|
||||
// so we don't re-render on unrelated scene mutations.
|
||||
const selectedNodeType = useScene((s) => {
|
||||
@@ -32,6 +36,15 @@ export function PanelManager() {
|
||||
return <ReferencePanel />
|
||||
}
|
||||
|
||||
if (
|
||||
isPaintPanelOpen &&
|
||||
mode === 'material-paint' &&
|
||||
activePaintMaterial?.material?.properties &&
|
||||
!activePaintMaterial.materialPreset
|
||||
) {
|
||||
return <PaintPanel />
|
||||
}
|
||||
|
||||
// Show appropriate panel based on selected node type
|
||||
if (selectedNodeType) {
|
||||
switch (selectedNodeType) {
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
getEffectiveRoofSurfaceMaterial,
|
||||
type MaterialSchema,
|
||||
type RoofNode,
|
||||
type RoofSurfaceMaterialRole,
|
||||
RoofNode as RoofNodeSchema,
|
||||
type RoofSegmentNode,
|
||||
RoofSegmentNode as RoofSegmentNodeSchema,
|
||||
useScene,
|
||||
@@ -19,44 +18,16 @@ import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import { duplicateRoofSubtree } from '../../../lib/roof-duplication'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||
import { MaterialPicker } from '../controls/material-picker'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { SliderControl } from '../controls/slider-control'
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
|
||||
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)
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
const createNode = useScene((s) => s.createNode)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
|
||||
|
||||
const node = useScene((s) =>
|
||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as RoofNode | undefined) : undefined,
|
||||
@@ -79,33 +50,6 @@ export function RoofPanel() {
|
||||
[selectedId, updateNode],
|
||||
)
|
||||
|
||||
const materialTargetRole =
|
||||
selectedMaterialTarget &&
|
||||
selectedMaterialTarget.nodeId === node?.id &&
|
||||
(selectedMaterialTarget.role === 'top' ||
|
||||
selectedMaterialTarget.role === 'edge' ||
|
||||
selectedMaterialTarget.role === 'wall')
|
||||
? selectedMaterialTarget.role
|
||||
: null
|
||||
const materialPickerValue =
|
||||
node && materialTargetRole ? getEffectiveRoofSurfaceMaterial(node, materialTargetRole) : {}
|
||||
|
||||
const handleTargetedMaterialChange = useCallback(
|
||||
(material: MaterialSchema) => {
|
||||
if (!node || !materialTargetRole) return
|
||||
handleUpdate(buildRoofSurfaceMaterialPatch(node, materialTargetRole, material, undefined))
|
||||
},
|
||||
[handleUpdate, materialTargetRole, node],
|
||||
)
|
||||
|
||||
const handleTargetedMaterialPresetChange = useCallback(
|
||||
(materialPreset: string) => {
|
||||
if (!node || !materialTargetRole) return
|
||||
handleUpdate(buildRoofSurfaceMaterialPatch(node, materialTargetRole, undefined, materialPreset))
|
||||
},
|
||||
[handleUpdate, materialTargetRole, node],
|
||||
)
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [setSelection])
|
||||
@@ -281,22 +225,6 @@ export function RoofPanel() {
|
||||
/>
|
||||
</ActionGroup>
|
||||
</PanelSection>
|
||||
<PanelSection title="Material">
|
||||
{!materialTargetRole ? (
|
||||
<div className="mb-3 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-[11px] text-muted-foreground">
|
||||
Click the roof surface you want to edit. Materials apply to one target at a time.
|
||||
</div>
|
||||
) : null}
|
||||
<MaterialPicker
|
||||
disabled={!materialTargetRole}
|
||||
hideSideControl
|
||||
nodeType="roof"
|
||||
onChange={handleTargetedMaterialChange}
|
||||
onSelectMaterialPreset={handleTargetedMaterialPresetChange}
|
||||
selectedMaterialPreset={materialPickerValue.materialPreset}
|
||||
value={materialPickerValue.material}
|
||||
/>
|
||||
</PanelSection>
|
||||
</PanelWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
type MaterialSchema,
|
||||
type RoofSegmentNode,
|
||||
RoofSegmentNode as RoofSegmentNodeSchema,
|
||||
type RoofType,
|
||||
@@ -15,7 +14,6 @@ import { useCallback } from 'react'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||
import { MaterialPicker } from '../controls/material-picker'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { SegmentedControl } from '../controls/segmented-control'
|
||||
import { SliderControl } from '../controls/slider-control'
|
||||
@@ -52,20 +50,6 @@ export function RoofSegmentPanel() {
|
||||
[selectedId, updateNode],
|
||||
)
|
||||
|
||||
const handleMaterialChange = useCallback(
|
||||
(material: MaterialSchema) => {
|
||||
handleUpdate({ material, materialPreset: undefined })
|
||||
},
|
||||
[handleUpdate],
|
||||
)
|
||||
|
||||
const handleMaterialPresetChange = useCallback(
|
||||
(materialPreset: string) => {
|
||||
handleUpdate({ materialPreset, material: undefined })
|
||||
},
|
||||
[handleUpdate],
|
||||
)
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [setSelection])
|
||||
@@ -322,15 +306,6 @@ export function RoofSegmentPanel() {
|
||||
/>
|
||||
</ActionGroup>
|
||||
</PanelSection>
|
||||
<PanelSection title="Material">
|
||||
<MaterialPicker
|
||||
nodeType="roof-segment"
|
||||
onChange={handleMaterialChange}
|
||||
onSelectMaterialPreset={handleMaterialPresetChange}
|
||||
selectedMaterialPreset={node.materialPreset}
|
||||
value={node.material}
|
||||
/>
|
||||
</PanelSection>
|
||||
</PanelWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { type AnyNode, type MaterialSchema, type SlabNode, useScene } from '@pascal-app/core'
|
||||
import { type AnyNode, type SlabNode, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Edit, Move, Plus, Trash2 } from 'lucide-react'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||
import { MaterialPicker } from '../controls/material-picker'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { SliderControl } from '../controls/slider-control'
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
@@ -32,20 +31,6 @@ export function SlabPanel() {
|
||||
[selectedId, updateNode],
|
||||
)
|
||||
|
||||
const handleMaterialPresetChange = useCallback(
|
||||
(materialPreset: string) => {
|
||||
handleUpdate({ materialPreset, material: undefined })
|
||||
},
|
||||
[handleUpdate],
|
||||
)
|
||||
|
||||
const handleCustomMaterialChange = useCallback(
|
||||
(material: MaterialSchema) => {
|
||||
handleUpdate({ material, materialPreset: undefined })
|
||||
},
|
||||
[handleUpdate],
|
||||
)
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSelection({ selectedIds: [] })
|
||||
setEditingHole(null)
|
||||
@@ -257,20 +242,9 @@ export function SlabPanel() {
|
||||
/>
|
||||
</div>
|
||||
</PanelSection>
|
||||
<PanelSection title="Material">
|
||||
<MaterialPicker
|
||||
nodeType="slab"
|
||||
onChange={handleCustomMaterialChange}
|
||||
onSelectMaterialPreset={handleMaterialPresetChange}
|
||||
selectedMaterialPreset={node.materialPreset}
|
||||
value={node.material}
|
||||
/>
|
||||
</PanelSection>
|
||||
<PanelSection title="Actions">
|
||||
<ActionGroup>
|
||||
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||
</ActionGroup>
|
||||
</PanelSection>
|
||||
<ActionGroup>
|
||||
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||
</ActionGroup>
|
||||
</PanelWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,12 +3,9 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
getEffectiveStairSurfaceMaterial,
|
||||
type LevelNode,
|
||||
type MaterialSchema,
|
||||
type StairNode,
|
||||
type StairRailingMode,
|
||||
type StairSurfaceMaterialRole,
|
||||
type StairSlabOpeningMode,
|
||||
type StairTopLandingMode,
|
||||
type StairType,
|
||||
@@ -25,7 +22,6 @@ import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE } from '../../tools/stair/stair-defaults'
|
||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||
import { MaterialPicker } from '../controls/material-picker'
|
||||
import { MetricControl } from '../controls/metric-control'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { SegmentedControl } from '../controls/segmented-control'
|
||||
@@ -33,32 +29,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' },
|
||||
@@ -89,7 +59,6 @@ export function StairPanel() {
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
const createNode = useScene((s) => s.createNode)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
|
||||
|
||||
const node = useScene((s) =>
|
||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairNode | undefined) : undefined,
|
||||
@@ -120,33 +89,6 @@ export function StairPanel() {
|
||||
[selectedId, updateNode],
|
||||
)
|
||||
|
||||
const materialTargetRole =
|
||||
selectedMaterialTarget &&
|
||||
selectedMaterialTarget.nodeId === node?.id &&
|
||||
(selectedMaterialTarget.role === 'railing' ||
|
||||
selectedMaterialTarget.role === 'tread' ||
|
||||
selectedMaterialTarget.role === 'side')
|
||||
? selectedMaterialTarget.role
|
||||
: null
|
||||
const materialPickerValue =
|
||||
node && materialTargetRole ? getEffectiveStairSurfaceMaterial(node, materialTargetRole) : {}
|
||||
|
||||
const handleTargetedMaterialChange = useCallback(
|
||||
(material: MaterialSchema) => {
|
||||
if (!node || !materialTargetRole) return
|
||||
handleUpdate(buildStairSurfaceMaterialPatch(node, materialTargetRole, material, undefined))
|
||||
},
|
||||
[handleUpdate, materialTargetRole, node],
|
||||
)
|
||||
|
||||
const handleTargetedMaterialPresetChange = useCallback(
|
||||
(materialPreset: string) => {
|
||||
if (!node || !materialTargetRole) return
|
||||
handleUpdate(buildStairSurfaceMaterialPatch(node, materialTargetRole, undefined, materialPreset))
|
||||
},
|
||||
[handleUpdate, materialTargetRole, node],
|
||||
)
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [setSelection])
|
||||
@@ -578,22 +520,6 @@ export function StairPanel() {
|
||||
/>
|
||||
</ActionGroup>
|
||||
</PanelSection>
|
||||
<PanelSection title="Material">
|
||||
{!materialTargetRole ? (
|
||||
<div className="mb-3 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-[11px] text-muted-foreground">
|
||||
Click the stair surface you want to edit. Materials apply to one target at a time.
|
||||
</div>
|
||||
) : null}
|
||||
<MaterialPicker
|
||||
disabled={!materialTargetRole}
|
||||
hideSideControl
|
||||
nodeType="stair"
|
||||
onChange={handleTargetedMaterialChange}
|
||||
onSelectMaterialPreset={handleTargetedMaterialPresetChange}
|
||||
selectedMaterialPreset={materialPickerValue.materialPreset}
|
||||
value={materialPickerValue.material}
|
||||
/>
|
||||
</PanelSection>
|
||||
</PanelWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
type AttachmentSide,
|
||||
type MaterialSchema,
|
||||
type StairSegmentNode,
|
||||
StairSegmentNode as StairSegmentNodeSchema,
|
||||
type StairSegmentType,
|
||||
@@ -16,7 +15,6 @@ import { useCallback } from 'react'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||
import { MaterialPicker } from '../controls/material-picker'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { SegmentedControl } from '../controls/segmented-control'
|
||||
import { SliderControl } from '../controls/slider-control'
|
||||
@@ -61,20 +59,6 @@ export function StairSegmentPanel() {
|
||||
[selectedId, updateNode],
|
||||
)
|
||||
|
||||
const handleMaterialChange = useCallback(
|
||||
(material: MaterialSchema) => {
|
||||
handleUpdate({ material, materialPreset: undefined })
|
||||
},
|
||||
[handleUpdate],
|
||||
)
|
||||
|
||||
const handleMaterialPresetChange = useCallback(
|
||||
(materialPreset: string) => {
|
||||
handleUpdate({ materialPreset, material: undefined })
|
||||
},
|
||||
[handleUpdate],
|
||||
)
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [setSelection])
|
||||
@@ -336,15 +320,6 @@ export function StairSegmentPanel() {
|
||||
/>
|
||||
</ActionGroup>
|
||||
</PanelSection>
|
||||
<PanelSection title="Material">
|
||||
<MaterialPicker
|
||||
nodeType="stair-segment"
|
||||
onChange={handleMaterialChange}
|
||||
onSelectMaterialPreset={handleMaterialPresetChange}
|
||||
selectedMaterialPreset={node.materialPreset}
|
||||
value={node.material}
|
||||
/>
|
||||
</PanelSection>
|
||||
</PanelWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,61 +3,29 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
getEffectiveWallSurfaceMaterial,
|
||||
getClampedWallCurveOffset,
|
||||
getMaxWallCurveOffset,
|
||||
getWallCurveLength,
|
||||
getWallSurfaceMaterialSignature,
|
||||
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 { useCallback } from 'react'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||
import { MaterialPicker } from '../controls/material-picker'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { 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)
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
const setCurvingWall = useEditor((s) => s.setCurvingWall)
|
||||
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
|
||||
|
||||
const node = useScene((s) =>
|
||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined,
|
||||
@@ -88,35 +56,6 @@ export function WallPanel() {
|
||||
[selectedId, updateNode],
|
||||
)
|
||||
|
||||
const effectiveInteriorMaterial = useMemo(
|
||||
() => (node ? getEffectiveWallSurfaceMaterial(node, 'interior') : {}),
|
||||
[node],
|
||||
)
|
||||
const effectiveExteriorMaterial = useMemo(
|
||||
() => (node ? getEffectiveWallSurfaceMaterial(node, 'exterior') : {}),
|
||||
[node],
|
||||
)
|
||||
const surfaceMaterialsMatch = useMemo(
|
||||
() =>
|
||||
getWallSurfaceMaterialSignature(effectiveInteriorMaterial) ===
|
||||
getWallSurfaceMaterialSignature(effectiveExteriorMaterial),
|
||||
[effectiveExteriorMaterial, effectiveInteriorMaterial],
|
||||
)
|
||||
const materialTargetSide =
|
||||
selectedMaterialTarget &&
|
||||
selectedMaterialTarget.nodeId === node?.id &&
|
||||
(selectedMaterialTarget.role === 'interior' || selectedMaterialTarget.role === 'exterior')
|
||||
? selectedMaterialTarget.role
|
||||
: null
|
||||
const materialPickerValue =
|
||||
materialTargetSide === 'interior'
|
||||
? effectiveInteriorMaterial
|
||||
: materialTargetSide === 'exterior'
|
||||
? effectiveExteriorMaterial
|
||||
: surfaceMaterialsMatch
|
||||
? effectiveInteriorMaterial
|
||||
: {}
|
||||
|
||||
const handleUpdateLength = useCallback(
|
||||
(newLength: number) => {
|
||||
if (!node || newLength <= 0) return
|
||||
@@ -140,22 +79,6 @@ export function WallPanel() {
|
||||
[node, handleUpdate],
|
||||
)
|
||||
|
||||
const handleMaterialPresetChange = useCallback(
|
||||
(materialPreset: string) => {
|
||||
if (!node || !materialTargetSide) return
|
||||
handleUpdate(buildWallSurfaceMaterialPatch(node, materialTargetSide, undefined, materialPreset))
|
||||
},
|
||||
[handleUpdate, materialTargetSide, node],
|
||||
)
|
||||
|
||||
const handleCustomMaterialChange = useCallback(
|
||||
(material: MaterialSchema) => {
|
||||
if (!node || !materialTargetSide) return
|
||||
handleUpdate(buildWallSurfaceMaterialPatch(node, materialTargetSide, material, undefined))
|
||||
},
|
||||
[handleUpdate, materialTargetSide, node],
|
||||
)
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [setSelection])
|
||||
@@ -237,23 +160,6 @@ export function WallPanel() {
|
||||
)}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Material">
|
||||
{!materialTargetSide ? (
|
||||
<div className="mb-3 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-[11px] text-muted-foreground">
|
||||
Click the wall face you want to edit. Materials now apply to one side at a time.
|
||||
</div>
|
||||
) : null}
|
||||
<MaterialPicker
|
||||
disabled={!materialTargetSide}
|
||||
hideSideControl
|
||||
nodeType="wall"
|
||||
onChange={handleCustomMaterialChange}
|
||||
onSelectMaterialPreset={handleMaterialPresetChange}
|
||||
selectedMaterialPreset={materialPickerValue.materialPreset}
|
||||
value={materialPickerValue.material}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Actions">
|
||||
<ActionGroup>
|
||||
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
emitter,
|
||||
type MaterialSchema,
|
||||
useScene,
|
||||
WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
@@ -15,7 +14,6 @@ import { usePresetsAdapter } from '../../../contexts/presets-context'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||
import { MaterialPicker } from '../controls/material-picker'
|
||||
import { MetricControl } from '../controls/metric-control'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { SliderControl } from '../controls/slider-control'
|
||||
@@ -45,13 +43,6 @@ export function WindowPanel() {
|
||||
[selectedId, updateNode],
|
||||
)
|
||||
|
||||
const handleMaterialChange = useCallback(
|
||||
(material: MaterialSchema) => {
|
||||
handleUpdate({ material })
|
||||
},
|
||||
[handleUpdate],
|
||||
)
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [setSelection])
|
||||
@@ -431,9 +422,6 @@ export function WindowPanel() {
|
||||
/>
|
||||
</ActionGroup>
|
||||
</PanelSection>
|
||||
<PanelSection title="Material">
|
||||
<MaterialPicker onChange={handleMaterialChange} value={node.material} />
|
||||
</PanelSection>
|
||||
</PanelWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -89,6 +89,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,279 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type CeilingNode,
|
||||
type FenceNode,
|
||||
getCatalogMaterialById,
|
||||
getEffectiveRoofSurfaceMaterial,
|
||||
getEffectiveStairSurfaceMaterial,
|
||||
getEffectiveWallSurfaceMaterial,
|
||||
getLibraryMaterialIdFromRef,
|
||||
type MaterialSchema,
|
||||
type MaterialTarget,
|
||||
type RoofNode,
|
||||
type RoofSurfaceMaterialRole,
|
||||
type SlabNode,
|
||||
type StairNode,
|
||||
type StairSurfaceMaterialRole,
|
||||
type WallNode,
|
||||
type WallSurfaceSide,
|
||||
} from '@pascal-app/core'
|
||||
|
||||
export type PaintableMaterialTarget = Extract<
|
||||
MaterialTarget,
|
||||
'wall' | 'roof' | 'stair' | 'fence' | 'slab' | 'ceiling'
|
||||
>
|
||||
|
||||
export type SingleSurfaceMaterialRole = 'surface'
|
||||
|
||||
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 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 buildSingleSurfaceMaterialPatch<TNode extends FenceNode | SlabNode | CeilingNode>(
|
||||
material: MaterialSchema | undefined,
|
||||
materialPreset: string | undefined,
|
||||
): Partial<TNode> {
|
||||
return {
|
||||
material,
|
||||
materialPreset,
|
||||
} as Partial<TNode>
|
||||
}
|
||||
|
||||
export function resolveActivePaintMaterialFromSelection(params: {
|
||||
nodes: Record<string, any>
|
||||
selectedId: string | null
|
||||
selectedMaterialTarget: {
|
||||
nodeId: string
|
||||
role:
|
||||
| WallSurfaceSide
|
||||
| StairSurfaceMaterialRole
|
||||
| RoofSurfaceMaterialRole
|
||||
| SingleSurfaceMaterialRole
|
||||
} | 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
|
||||
}
|
||||
|
||||
if (
|
||||
(selectedNode.type === 'fence' ||
|
||||
selectedNode.type === 'slab' ||
|
||||
selectedNode.type === 'ceiling') &&
|
||||
selectedMaterialTarget.role === 'surface'
|
||||
) {
|
||||
const target = selectedNode.type
|
||||
return hasActivePaintMaterial({
|
||||
material: selectedNode.material,
|
||||
materialPreset: selectedNode.materialPreset,
|
||||
sourceTarget: target,
|
||||
})
|
||||
? {
|
||||
material: selectedNode.material,
|
||||
materialPreset: selectedNode.materialPreset,
|
||||
sourceTarget: target,
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
if (selectedNode.type === 'fence') {
|
||||
return 'fence'
|
||||
}
|
||||
|
||||
if (selectedNode.type === 'slab') {
|
||||
return 'slab'
|
||||
}
|
||||
|
||||
if (selectedNode.type === 'ceiling') {
|
||||
return 'ceiling'
|
||||
}
|
||||
|
||||
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,13 @@ 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,
|
||||
type SingleSurfaceMaterialRole,
|
||||
} from '../lib/material-paint'
|
||||
|
||||
const DEFAULT_ACTIVE_SIDEBAR_PANEL = 'site'
|
||||
const DEFAULT_FLOORPLAN_PANE_RATIO = 0.5
|
||||
@@ -37,7 +44,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 =
|
||||
@@ -89,13 +96,19 @@ export type MovingFenceEndpoint = {
|
||||
endpoint: 'start' | 'end'
|
||||
}
|
||||
|
||||
export type MaterialTargetRole = WallSurfaceSide | StairSurfaceMaterialRole | RoofSurfaceMaterialRole
|
||||
export type MaterialTargetRole = WallSurfaceSide | StairSurfaceMaterialRole | RoofSurfaceMaterialRole | SingleSurfaceMaterialRole
|
||||
|
||||
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
|
||||
@@ -149,6 +162,15 @@ type EditorState = {
|
||||
setCurvingFence: (fence: FenceNode | 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
|
||||
hoveredPaintTarget: PaintableMaterialTarget | null
|
||||
setHoveredPaintTarget: (target: PaintableMaterialTarget | null) => void
|
||||
isPaintPanelOpen: boolean
|
||||
setPaintPanelOpen: (open: boolean) => void
|
||||
selectedReferenceId: string | null
|
||||
setSelectedReferenceId: (id: string | null) => void
|
||||
// Space detection for cutaway mode
|
||||
@@ -228,7 +250,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 {
|
||||
@@ -466,6 +488,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) {
|
||||
@@ -530,6 +554,47 @@ const useEditor = create<EditorState>()(
|
||||
setCurvingFence: (fence) => set({ curvingFence: fence }),
|
||||
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,
|
||||
}
|
||||
},
|
||||
hoveredPaintTarget: null,
|
||||
setHoveredPaintTarget: (target) =>
|
||||
set((state) =>
|
||||
state.hoveredPaintTarget === target ? state : { hoveredPaintTarget: target },
|
||||
),
|
||||
isPaintPanelOpen: false,
|
||||
setPaintPanelOpen: (open) => set({ isPaintPanelOpen: open }),
|
||||
selectedReferenceId: null,
|
||||
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
|
||||
spaces: {},
|
||||
|
||||
Reference in New Issue
Block a user