Refactor paint materials to use category-based compatibility

This commit is contained in:
sudhir
2026-04-23 12:48:50 +05:30
parent 88b2f095f1
commit 2876f39876
8 changed files with 358 additions and 239 deletions
+4 -1
View File
@@ -40,11 +40,14 @@ export {
export { baseMaterial, glassMaterial } from './materials' export { baseMaterial, glassMaterial } from './materials'
export { export {
getCatalogMaterialById, getCatalogMaterialById,
getMaterialCategoryTargets,
getLibraryMaterialIdFromRef, getLibraryMaterialIdFromRef,
getMaterialPresetByRef, getMaterialPresetByRef,
getMaterialsForTarget, getMaterialsForCategory,
LIBRARY_MATERIAL_REF_PREFIX, LIBRARY_MATERIAL_REF_PREFIX,
MATERIAL_CATALOG, MATERIAL_CATALOG,
MATERIAL_CATEGORIES,
type MaterialCategory,
type MaterialCatalogItem, type MaterialCatalogItem,
toLibraryMaterialRef, toLibraryMaterialRef,
} from './material-library' } from './material-library'
+65 -55
View File
@@ -7,51 +7,63 @@ import {
export type MaterialCatalogItem = { export type MaterialCatalogItem = {
id: string id: string
label: string label: string
category: MaterialCategory
description?: string description?: string
targets: MaterialTarget[]
previewThumbnailUrl?: string previewThumbnailUrl?: string
previewColor?: string previewColor?: string
preset: MaterialPresetPayload preset: MaterialPresetPayload
} }
const WALL_TARGETS: MaterialTarget[] = [ export const MATERIAL_CATEGORIES = [
MaterialTargetSchema.enum.wall, 'wood',
] 'wallpaper',
'parquet',
'granite',
'marble',
'other',
] as const
export type MaterialCategory = (typeof MATERIAL_CATEGORIES)[number]
const SLAB_TARGETS: MaterialTarget[] = [ export const MATERIAL_CATEGORY_TARGETS: Record<MaterialCategory, MaterialTarget[]> = {
MaterialTargetSchema.enum.slab, wood: [
] MaterialTargetSchema.enum.wall,
MaterialTargetSchema.enum.slab,
const WALL_AND_SLAB_TARGETS: MaterialTarget[] = [ MaterialTargetSchema.enum.stair,
MaterialTargetSchema.enum.wall, MaterialTargetSchema.enum['stair-segment'],
MaterialTargetSchema.enum.slab, MaterialTargetSchema.enum.fence,
] MaterialTargetSchema.enum.roof,
MaterialTargetSchema.enum['roof-segment'],
const STAIR_TARGETS: MaterialTarget[] = [ ],
MaterialTargetSchema.enum.stair, wallpaper: [MaterialTargetSchema.enum.wall],
MaterialTargetSchema.enum['stair-segment'], parquet: [
] MaterialTargetSchema.enum.slab,
MaterialTargetSchema.enum.stair,
const STAIR_AND_FENCE_TARGETS: MaterialTarget[] = [ MaterialTargetSchema.enum['stair-segment'],
...STAIR_TARGETS, ],
MaterialTargetSchema.enum.fence, granite: [MaterialTargetSchema.enum.slab],
] marble: [
MaterialTargetSchema.enum.slab,
const ROOF_TARGETS: MaterialTarget[] = [ MaterialTargetSchema.enum.stair,
MaterialTargetSchema.enum.roof, MaterialTargetSchema.enum['stair-segment'],
MaterialTargetSchema.enum['roof-segment'], ],
] other: [
MaterialTargetSchema.enum.wall,
const CEILING_TARGETS: MaterialTarget[] = [ MaterialTargetSchema.enum.roof,
MaterialTargetSchema.enum.ceiling, MaterialTargetSchema.enum['roof-segment'],
] MaterialTargetSchema.enum.stair,
MaterialTargetSchema.enum['stair-segment'],
MaterialTargetSchema.enum.fence,
MaterialTargetSchema.enum.slab,
MaterialTargetSchema.enum.ceiling,
],
}
export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
{ {
id: 'wall-wood1', id: 'wall-wood1',
label: 'Wood', label: 'Wood',
category: 'wood',
description: 'Warm wood finish', description: 'Warm wood finish',
targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS, ...ROOF_TARGETS],
previewThumbnailUrl: '/material/wood1/wood1_thumbnail.webp', previewThumbnailUrl: '/material/wood1/wood1_thumbnail.webp',
preset: { preset: {
maps: { maps: {
@@ -85,8 +97,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
{ {
id: 'wall-wood2', id: 'wall-wood2',
label: 'Wood', label: 'Wood',
category: 'wood',
description: 'Textured wood finish', description: 'Textured wood finish',
targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS, ...ROOF_TARGETS],
previewThumbnailUrl: '/material/wood2/wood2_thumbnail.webp', previewThumbnailUrl: '/material/wood2/wood2_thumbnail.webp',
preset: { preset: {
maps: { maps: {
@@ -121,8 +133,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
{ {
id: 'wall-wood3', id: 'wall-wood3',
label: 'Wood', label: 'Wood',
category: 'wood',
description: 'Knotted timber finish', description: 'Knotted timber finish',
targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS, ...ROOF_TARGETS],
previewThumbnailUrl: '/material/wood3/wood3_thumbnail.webp', previewThumbnailUrl: '/material/wood3/wood3_thumbnail.webp',
preset: { preset: {
maps: { maps: {
@@ -155,8 +167,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
{ {
id: 'wall-wood4', id: 'wall-wood4',
label: 'Wood', label: 'Wood',
category: 'wood',
description: 'Oak stretcher finish', description: 'Oak stretcher finish',
targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS],
previewThumbnailUrl: '/material/wood4/wood4_thumbnail.webp', previewThumbnailUrl: '/material/wood4/wood4_thumbnail.webp',
preset: { preset: {
maps: { maps: {
@@ -189,8 +201,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
{ {
id: 'wall-wood5', id: 'wall-wood5',
label: 'Wood', label: 'Wood',
category: 'wood',
description: 'Rich grain wood finish', description: 'Rich grain wood finish',
targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS],
previewThumbnailUrl: '/material/wood5/wood5_thumnail.webp', previewThumbnailUrl: '/material/wood5/wood5_thumnail.webp',
preset: { preset: {
maps: { maps: {
@@ -225,8 +237,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
{ {
id: 'wall-granite1', id: 'wall-granite1',
label: 'Granite', label: 'Granite',
category: 'granite',
description: 'Polished granite finish', description: 'Polished granite finish',
targets: SLAB_TARGETS,
previewThumbnailUrl: '/material/granite1/granite_thumbnail.webp', previewThumbnailUrl: '/material/granite1/granite_thumbnail.webp',
preset: { preset: {
maps: { maps: {
@@ -259,8 +271,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
{ {
id: 'wall-marble1', id: 'wall-marble1',
label: 'Marble', label: 'Marble',
category: 'marble',
description: 'Smooth marble finish', description: 'Smooth marble finish',
targets: [...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS],
previewThumbnailUrl: '/material/marble1/marble1_thumbnail.webp', previewThumbnailUrl: '/material/marble1/marble1_thumbnail.webp',
preset: { preset: {
maps: { maps: {
@@ -293,8 +305,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
{ {
id: 'wall-marble2', id: 'wall-marble2',
label: 'Marble', label: 'Marble',
category: 'marble',
description: 'Soft marble finish', description: 'Soft marble finish',
targets: [...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS],
previewThumbnailUrl: '/material/marble2/marble2_thumbnail.webp', previewThumbnailUrl: '/material/marble2/marble2_thumbnail.webp',
preset: { preset: {
maps: { maps: {
@@ -327,8 +339,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
{ {
id: 'wall-parquet1', id: 'wall-parquet1',
label: 'Parquet', label: 'Parquet',
category: 'parquet',
description: 'Parquet wood finish', description: 'Parquet wood finish',
targets: SLAB_TARGETS,
previewThumbnailUrl: '/material/parquet1/parquet_thumnail.webp', previewThumbnailUrl: '/material/parquet1/parquet_thumnail.webp',
preset: { preset: {
maps: { maps: {
@@ -361,8 +373,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
{ {
id: 'wall-parquet2', id: 'wall-parquet2',
label: 'Parquet', label: 'Parquet',
category: 'parquet',
description: 'Soft parquet finish', description: 'Soft parquet finish',
targets: SLAB_TARGETS,
previewThumbnailUrl: '/material/parquet2/parquet2_thumbnail.webp', previewThumbnailUrl: '/material/parquet2/parquet2_thumbnail.webp',
preset: { preset: {
maps: { maps: {
@@ -395,8 +407,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
{ {
id: 'wall-wallpaper1', id: 'wall-wallpaper1',
label: 'Wallpaper', label: 'Wallpaper',
category: 'wallpaper',
description: 'Soft wallpaper finish', description: 'Soft wallpaper finish',
targets: WALL_TARGETS,
previewThumbnailUrl: '/material/wallpaper1/wallpaper1_thumbnail.webp', previewThumbnailUrl: '/material/wallpaper1/wallpaper1_thumbnail.webp',
preset: { preset: {
maps: { maps: {
@@ -430,8 +442,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
{ {
id: 'wall-wallpaper2', id: 'wall-wallpaper2',
label: 'Wallpaper', label: 'Wallpaper',
category: 'wallpaper',
description: 'Decorative wallpaper finish', description: 'Decorative wallpaper finish',
targets: WALL_TARGETS,
previewThumbnailUrl: '/material/wallpaper2/wallpaper2_thumnail.webp', previewThumbnailUrl: '/material/wallpaper2/wallpaper2_thumnail.webp',
preset: { preset: {
maps: { maps: {
@@ -464,8 +476,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
{ {
id: 'wall-wallpaper3', id: 'wall-wallpaper3',
label: 'Wallpaper', label: 'Wallpaper',
category: 'wallpaper',
description: 'Patterned wallpaper finish', description: 'Patterned wallpaper finish',
targets: WALL_TARGETS,
previewThumbnailUrl: '/material/wallpaper3/wallpaper3_thumbnail.webp', previewThumbnailUrl: '/material/wallpaper3/wallpaper3_thumbnail.webp',
preset: { preset: {
maps: { maps: {
@@ -498,14 +510,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
{ {
id: 'preset-white', id: 'preset-white',
label: 'White', label: 'White',
category: 'other',
description: 'Clean painted finish', description: 'Clean painted finish',
targets: [
...WALL_TARGETS,
...SLAB_TARGETS,
...ROOF_TARGETS,
...STAIR_AND_FENCE_TARGETS,
...CEILING_TARGETS,
],
previewColor: '#ffffff', previewColor: '#ffffff',
preset: { preset: {
maps: {}, maps: {},
@@ -536,8 +542,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
{ {
id: 'preset-metal', id: 'preset-metal',
label: 'Metal', label: 'Metal',
category: 'other',
description: 'Brushed metal finish', description: 'Brushed metal finish',
targets: [...WALL_TARGETS, ...SLAB_TARGETS],
previewColor: '#c0c0c0', previewColor: '#c0c0c0',
preset: { preset: {
maps: {}, maps: {},
@@ -568,8 +574,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
{ {
id: 'preset-glass', id: 'preset-glass',
label: 'Glass', label: 'Glass',
category: 'other',
description: 'Light glass finish', description: 'Light glass finish',
targets: [...WALL_TARGETS, ...SLAB_TARGETS],
previewColor: '#87ceeb', previewColor: '#87ceeb',
preset: { preset: {
maps: {}, maps: {},
@@ -599,8 +605,12 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
}, },
] ]
export function getMaterialsForTarget(target: MaterialTarget): MaterialCatalogItem[] { export function getMaterialsForCategory(category: MaterialCategory): MaterialCatalogItem[] {
return MATERIAL_CATALOG.filter((item) => item.targets.includes(target)) return MATERIAL_CATALOG.filter((item) => item.category === category)
}
export function getMaterialCategoryTargets(category: MaterialCategory): MaterialTarget[] {
return MATERIAL_CATEGORY_TARGETS[category]
} }
export function getCatalogMaterialById(id?: string): MaterialCatalogItem | undefined { export function getCatalogMaterialById(id?: string): MaterialCatalogItem | undefined {
@@ -1,16 +1,13 @@
'use client' 'use client'
import { getMaterialsForTarget, toLibraryMaterialRef, useScene } from '@pascal-app/core' import { useScene } from '@pascal-app/core'
import { AnimatePresence, motion } from 'motion/react' import { AnimatePresence, motion } from 'motion/react'
import { useEffect, useMemo } from 'react' import { useEffect, useMemo } from 'react'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { TooltipProvider } from './../../../components/ui/primitives/tooltip' import { TooltipProvider } from './../../../components/ui/primitives/tooltip'
import { MaterialPicker } from './../../../components/ui/controls/material-picker' import { MaterialPicker } from './../../../components/ui/controls/material-picker'
import { useReducedMotion } from './../../../hooks/use-reduced-motion' import { useReducedMotion } from './../../../hooks/use-reduced-motion'
import { import { resolvePaintTargetFromSelection } from './../../../lib/material-paint'
isActivePaintMaterialCompatible,
resolvePaintTargetFromSelection,
} from './../../../lib/material-paint'
import { cn } from './../../../lib/utils' import { cn } from './../../../lib/utils'
import useEditor from './../../../store/use-editor' import useEditor from './../../../store/use-editor'
import { ItemCatalog } from '../item-catalog/item-catalog' import { ItemCatalog } from '../item-catalog/item-catalog'
@@ -40,45 +37,17 @@ function PaintMaterialTray() {
} }
}, [nodes, selectedId, setActivePaintTarget]) }, [nodes, selectedId, setActivePaintTarget])
const catalogItems = useMemo(() => getMaterialsForTarget(activePaintTarget), [activePaintTarget])
useEffect(() => {
const firstMaterial = catalogItems[0]
if (!firstMaterial) return
const hasCompatibleMaterial = isActivePaintMaterialCompatible(
activePaintMaterial,
activePaintTarget,
)
if (hasCompatibleMaterial) return
setActivePaintMaterial({
materialPreset: toLibraryMaterialRef(firstMaterial.id),
sourceTarget: activePaintTarget,
})
}, [activePaintMaterial, activePaintTarget, catalogItems, setActivePaintMaterial])
return ( return (
<div className="w-[42rem] max-w-[calc(100vw-2rem)]"> <div className="w-[42rem] max-w-[calc(100vw-2rem)]">
<MaterialPicker <MaterialPicker
hideSideControl onChange={(material, category) => {
nodeType={activePaintTarget} setActivePaintMaterial({ material, category, sourceTarget: activePaintTarget })
onChange={(material) => {
setActivePaintMaterial({ material, sourceTarget: activePaintTarget })
}} }}
onSelectMaterialPreset={(materialPreset) => { onSelectMaterialPreset={(materialPreset, category) => {
setActivePaintMaterial({ materialPreset, sourceTarget: activePaintTarget }) setActivePaintMaterial({ materialPreset, category, sourceTarget: activePaintTarget })
}} }}
selectedMaterialPreset={ selectedMaterialPreset={activePaintMaterial?.materialPreset}
activePaintMaterial?.sourceTarget === activePaintTarget value={activePaintMaterial?.material}
? activePaintMaterial.materialPreset
: undefined
}
value={
activePaintMaterial?.sourceTarget === activePaintTarget
? activePaintMaterial.material
: undefined
}
/> />
</div> </div>
) )
@@ -1,55 +1,69 @@
'use client' 'use client'
import { import {
getMaterialsForTarget, getCatalogMaterialById,
getLibraryMaterialIdFromRef,
getMaterialsForCategory,
MATERIAL_CATEGORIES,
toLibraryMaterialRef, toLibraryMaterialRef,
type MaterialCategory,
type MaterialSchema, type MaterialSchema,
type MaterialTarget,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import useEditor from '../../../store/use-editor'
type MaterialPickerProps = { type MaterialPickerProps = {
nodeType?: MaterialTarget
value?: MaterialSchema value?: MaterialSchema
selectedMaterialPreset?: string selectedMaterialPreset?: string
onChange?: (material: MaterialSchema) => void onChange?: (material: MaterialSchema, category: MaterialCategory) => void
onSelectMaterialPreset?: (materialPreset: string) => void onSelectMaterialPreset?: (materialPreset: string, category: MaterialCategory) => void
hideSideControl?: boolean
disabled?: boolean disabled?: boolean
} }
export function MaterialPicker({ export function MaterialPicker({
nodeType,
value, value,
selectedMaterialPreset, selectedMaterialPreset,
onChange, onChange,
onSelectMaterialPreset, onSelectMaterialPreset,
hideSideControl = false,
disabled = false, disabled = false,
}: MaterialPickerProps) { }: MaterialPickerProps) {
const setPaintPanelOpen = useEditor((state) => state.setPaintPanelOpen)
const [showCustom, setShowCustom] = useState<boolean>(!!value?.properties) const [showCustom, setShowCustom] = useState<boolean>(!!value?.properties)
const [selectedCategory, setSelectedCategory] = useState<MaterialCategory>(MATERIAL_CATEGORIES[0])
const catalogScrollRef = useRef<HTMLDivElement>(null) const catalogScrollRef = useRef<HTMLDivElement>(null)
const catalogItems = nodeType ? getMaterialsForTarget(nodeType) : [] const catalogItems =
selectedCategory === 'other'
? getMaterialsForCategory('other')
: getMaterialsForCategory(selectedCategory)
useEffect(() => { useEffect(() => {
setShowCustom(!!value?.properties && !selectedMaterialPreset) setShowCustom(!!value?.properties && !selectedMaterialPreset)
}, [selectedMaterialPreset, value?.properties]) }, [selectedMaterialPreset, value?.properties])
const currentProps = value?.properties || { useEffect(() => {
color: '#ffffff', if (!selectedMaterialPreset && value?.properties) {
roughness: 0.5, setSelectedCategory('other')
metalness: 0, return
opacity: 1, }
transparent: false,
side: 'front' as const, const catalogId =
} getLibraryMaterialIdFromRef(selectedMaterialPreset) ?? value?.id ?? undefined
const selectedCatalogEntry = getCatalogMaterialById(catalogId)
if (selectedCatalogEntry?.category) {
setSelectedCategory(selectedCatalogEntry.category)
}
}, [selectedMaterialPreset, value?.id])
const selectedCatalogId = const selectedCatalogId =
selectedMaterialPreset ?? (value?.id ? toLibraryMaterialRef(value.id) : undefined) selectedMaterialPreset ?? (value?.id ? toLibraryMaterialRef(value.id) : undefined)
const handleCatalogSelect = (materialId: string) => { const handleCatalogSelect = (materialId: string) => {
if (disabled) return if (disabled) return
setShowCustom(false) setShowCustom(false)
onSelectMaterialPreset?.(toLibraryMaterialRef(materialId)) setPaintPanelOpen(false)
const category = getCatalogMaterialById(materialId)?.category
if (!category) return
onSelectMaterialPreset?.(toLibraryMaterialRef(materialId), category)
} }
useEffect(() => { useEffect(() => {
@@ -76,40 +90,51 @@ export function MaterialPicker({
const handleCustomOpen = () => { const handleCustomOpen = () => {
if (disabled) return if (disabled) return
setShowCustom(true) setShowCustom(true)
onChange?.({ setPaintPanelOpen(true)
preset: 'custom', onChange?.(
properties: { {
color: value?.properties?.color || '#ffffff', preset: 'custom',
roughness: value?.properties?.roughness ?? 0.5, properties: {
metalness: value?.properties?.metalness ?? 0, color: value?.properties?.color || '#ffffff',
opacity: value?.properties?.opacity ?? 1, roughness: value?.properties?.roughness ?? 0.5,
transparent: value?.properties?.transparent ?? false, metalness: value?.properties?.metalness ?? 0,
side: value?.properties?.side ?? 'front', opacity: value?.properties?.opacity ?? 1,
transparent: value?.properties?.transparent ?? false,
side: value?.properties?.side ?? 'front',
},
}, },
}) 'other',
} )
const handlePropertyChange = (
prop: keyof typeof currentProps,
val: (typeof currentProps)[keyof typeof currentProps],
) => {
if (disabled) return
onChange?.({
preset: 'custom',
properties: {
...currentProps,
[prop]: val,
},
})
} }
return ( return (
<div className={`min-w-0 space-y-3 ${disabled ? 'pointer-events-none opacity-50' : ''}`}> <div className={`min-w-0 space-y-3 ${disabled ? 'pointer-events-none opacity-50' : ''}`}>
{(catalogItems.length > 0 || onChange) && ( {(catalogItems.length > 0 || onChange) && (
<div className="min-w-0 space-y-2"> <div className="min-w-0 space-y-2">
{catalogItems.length > 0 ? ( <div className="flex flex-wrap gap-1">
<div className="text-gray-500 text-xs uppercase tracking-[0.16em]">Library</div> {MATERIAL_CATEGORIES.map((category) => (
) : null} <button
className={`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" className="w-full max-w-full overflow-x-auto overflow-y-hidden"
ref={catalogScrollRef} ref={catalogScrollRef}
@@ -142,12 +167,12 @@ export function MaterialPicker({
)} )}
</button> </button>
))} ))}
{onChange ? ( {selectedCategory === 'other' && onChange ? (
<button <button
className={`flex h-14 w-14 shrink-0 items-center justify-center rounded-lg border text-[10px] font-medium transition-all ${ className={`flex h-14 w-14 shrink-0 items-center justify-center rounded-lg border text-[10px] font-medium transition-all ${
showCustom showCustom
? 'border-blue-500 bg-blue-50 text-blue-700 ring-2 ring-blue-500/30' ? 'border-blue-500 ring-2 ring-blue-500/30'
: 'border-gray-300 bg-white text-gray-500 hover:border-gray-400' : 'border-gray-300 hover:border-gray-400'
}`} }`}
onClick={handleCustomOpen} onClick={handleCustomOpen}
title="Custom" title="Custom"
@@ -160,97 +185,6 @@ export function MaterialPicker({
</div> </div>
</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>
)}
</div>
)}
</div> </div>
) )
} }
@@ -0,0 +1,164 @@
'use client'
import useEditor from '../../../store/use-editor'
import { Input } from '../primitives/input'
import { PanelSection } from '../controls/panel-section'
import { PanelWrapper } from './panel-wrapper'
function buildDefaultCustomMaterial() {
return {
preset: 'custom' as const,
properties: {
color: '#ffffff',
roughness: 0.5,
metalness: 0,
opacity: 1,
transparent: false,
side: 'front' as const,
},
}
}
export function PaintPanel() {
const activePaintMaterial = useEditor((state) => state.activePaintMaterial)
const activePaintTarget = useEditor((state) => state.activePaintTarget)
const setActivePaintMaterial = useEditor((state) => state.setActivePaintMaterial)
const setPaintPanelOpen = useEditor((state) => state.setPaintPanelOpen)
const customMaterial =
activePaintMaterial?.material?.properties && !activePaintMaterial.materialPreset
? activePaintMaterial.material
: null
if (!customMaterial) return null
const currentProps = customMaterial.properties ?? buildDefaultCustomMaterial().properties
const updateCustomMaterial = (
updates: Partial<typeof currentProps>,
nextTransparent = currentProps.transparent,
) => {
setActivePaintMaterial({
material: {
preset: 'custom',
properties: {
...currentProps,
...updates,
transparent: nextTransparent,
},
},
category: 'other',
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 { DoorPanel } from './door-panel'
import { FencePanel } from './fence-panel' import { FencePanel } from './fence-panel'
import { ItemPanel } from './item-panel' import { ItemPanel } from './item-panel'
import { PaintPanel } from './paint-panel'
import { ReferencePanel } from './reference-panel' import { ReferencePanel } from './reference-panel'
import { RoofPanel } from './roof-panel' import { RoofPanel } from './roof-panel'
import { RoofSegmentPanel } from './roof-segment-panel' import { RoofSegmentPanel } from './roof-segment-panel'
@@ -19,6 +20,9 @@ import { WindowPanel } from './window-panel'
export function PanelManager() { export function PanelManager() {
const selectedIds = useViewer((s) => s.selection.selectedIds) const selectedIds = useViewer((s) => s.selection.selectedIds)
const selectedReferenceId = useEditor((s) => s.selectedReferenceId) 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 // Only subscribe to the *type* of the single-selected node — string primitive
// so we don't re-render on unrelated scene mutations. // so we don't re-render on unrelated scene mutations.
const selectedNodeType = useScene((s) => { const selectedNodeType = useScene((s) => {
@@ -32,6 +36,15 @@ export function PanelManager() {
return <ReferencePanel /> return <ReferencePanel />
} }
if (
isPaintPanelOpen &&
mode === 'material-paint' &&
activePaintMaterial?.material?.properties &&
!activePaintMaterial.materialPreset
) {
return <PaintPanel />
}
// Show appropriate panel based on selected node type // Show appropriate panel based on selected node type
if (selectedNodeType) { if (selectedNodeType) {
switch (selectedNodeType) { switch (selectedNodeType) {
+27 -5
View File
@@ -8,6 +8,8 @@ import {
getEffectiveStairSurfaceMaterial, getEffectiveStairSurfaceMaterial,
getEffectiveWallSurfaceMaterial, getEffectiveWallSurfaceMaterial,
getLibraryMaterialIdFromRef, getLibraryMaterialIdFromRef,
getMaterialCategoryTargets,
type MaterialCategory,
type MaterialSchema, type MaterialSchema,
type MaterialTarget, type MaterialTarget,
type RoofNode, type RoofNode,
@@ -29,6 +31,7 @@ export type SingleSurfaceMaterialRole = 'surface'
export type ActivePaintMaterial = { export type ActivePaintMaterial = {
material?: MaterialSchema material?: MaterialSchema
materialPreset?: string materialPreset?: string
category?: MaterialCategory
sourceTarget: PaintableMaterialTarget sourceTarget: PaintableMaterialTarget
} }
@@ -47,6 +50,17 @@ function getCatalogEntryForActivePaintMaterial(material: ActivePaintMaterial | n
return getCatalogMaterialById(catalogId) return getCatalogMaterialById(catalogId)
} }
function resolvePaintMaterialCategory(
material: MaterialSchema | undefined,
materialPreset: string | undefined,
): MaterialCategory | undefined {
const catalogId = getLibraryMaterialIdFromRef(materialPreset) ?? material?.id ?? undefined
const catalogEntry = getCatalogMaterialById(catalogId)
if (catalogEntry?.category) return catalogEntry.category
if (material || materialPreset === undefined) return 'other'
return undefined
}
export function getActivePaintMaterialLabel(material: ActivePaintMaterial | null | undefined) { export function getActivePaintMaterialLabel(material: ActivePaintMaterial | null | undefined) {
return getCatalogEntryForActivePaintMaterial(material)?.label ?? 'Custom' return getCatalogEntryForActivePaintMaterial(material)?.label ?? 'Custom'
} }
@@ -58,9 +72,10 @@ export function isActivePaintMaterialCompatible(
if (!hasActivePaintMaterial(material)) return false if (!hasActivePaintMaterial(material)) return false
const catalogEntry = getCatalogEntryForActivePaintMaterial(material) const catalogEntry = getCatalogEntryForActivePaintMaterial(material)
if (!catalogEntry) return true const category = material?.category ?? catalogEntry?.category
if (!category) return false
return catalogEntry.targets.includes(target) return getMaterialCategoryTargets(category).includes(target)
} }
export function buildWallSurfaceMaterialPatch( export function buildWallSurfaceMaterialPatch(
@@ -182,9 +197,10 @@ export function resolveActivePaintMaterialFromSelection(params: {
materialPreset: surface.materialPreset, materialPreset: surface.materialPreset,
sourceTarget: 'wall', sourceTarget: 'wall',
}) })
? { ? {
material: surface.material, material: surface.material,
materialPreset: surface.materialPreset, materialPreset: surface.materialPreset,
category: resolvePaintMaterialCategory(surface.material, surface.materialPreset),
sourceTarget: 'wall', sourceTarget: 'wall',
} }
: null : null
@@ -202,9 +218,10 @@ export function resolveActivePaintMaterialFromSelection(params: {
materialPreset: surface.materialPreset, materialPreset: surface.materialPreset,
sourceTarget: 'roof', sourceTarget: 'roof',
}) })
? { ? {
material: surface.material, material: surface.material,
materialPreset: surface.materialPreset, materialPreset: surface.materialPreset,
category: resolvePaintMaterialCategory(surface.material, surface.materialPreset),
sourceTarget: 'roof', sourceTarget: 'roof',
} }
: null : null
@@ -222,9 +239,10 @@ export function resolveActivePaintMaterialFromSelection(params: {
materialPreset: surface.materialPreset, materialPreset: surface.materialPreset,
sourceTarget: 'stair', sourceTarget: 'stair',
}) })
? { ? {
material: surface.material, material: surface.material,
materialPreset: surface.materialPreset, materialPreset: surface.materialPreset,
category: resolvePaintMaterialCategory(surface.material, surface.materialPreset),
sourceTarget: 'stair', sourceTarget: 'stair',
} }
: null : null
@@ -245,6 +263,10 @@ export function resolveActivePaintMaterialFromSelection(params: {
? { ? {
material: selectedNode.material, material: selectedNode.material,
materialPreset: selectedNode.materialPreset, materialPreset: selectedNode.materialPreset,
category: resolvePaintMaterialCategory(
selectedNode.material,
selectedNode.materialPreset,
),
sourceTarget: target, sourceTarget: target,
} }
: null : null
+4
View File
@@ -169,6 +169,8 @@ type EditorState = {
primeMaterialPaintFromSelection: () => MaterialPaintSelectionSnapshot primeMaterialPaintFromSelection: () => MaterialPaintSelectionSnapshot
hoveredPaintTarget: PaintableMaterialTarget | null hoveredPaintTarget: PaintableMaterialTarget | null
setHoveredPaintTarget: (target: PaintableMaterialTarget | null) => void setHoveredPaintTarget: (target: PaintableMaterialTarget | null) => void
isPaintPanelOpen: boolean
setPaintPanelOpen: (open: boolean) => void
selectedReferenceId: string | null selectedReferenceId: string | null
setSelectedReferenceId: (id: string | null) => void setSelectedReferenceId: (id: string | null) => void
// Space detection for cutaway mode // Space detection for cutaway mode
@@ -591,6 +593,8 @@ const useEditor = create<EditorState>()(
set((state) => set((state) =>
state.hoveredPaintTarget === target ? state : { hoveredPaintTarget: target }, state.hoveredPaintTarget === target ? state : { hoveredPaintTarget: target },
), ),
isPaintPanelOpen: false,
setPaintPanelOpen: (open) => set({ isPaintPanelOpen: open }),
selectedReferenceId: null, selectedReferenceId: null,
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }), setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
spaces: {}, spaces: {},