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 {
getCatalogMaterialById,
getMaterialCategoryTargets,
getLibraryMaterialIdFromRef,
getMaterialPresetByRef,
getMaterialsForTarget,
getMaterialsForCategory,
LIBRARY_MATERIAL_REF_PREFIX,
MATERIAL_CATALOG,
MATERIAL_CATEGORIES,
type MaterialCategory,
type MaterialCatalogItem,
toLibraryMaterialRef,
} from './material-library'
+57 -47
View File
@@ -7,51 +7,63 @@ import {
export type MaterialCatalogItem = {
id: string
label: string
category: MaterialCategory
description?: string
targets: MaterialTarget[]
previewThumbnailUrl?: string
previewColor?: string
preset: MaterialPresetPayload
}
const WALL_TARGETS: MaterialTarget[] = [
MaterialTargetSchema.enum.wall,
]
export const MATERIAL_CATEGORIES = [
'wood',
'wallpaper',
'parquet',
'granite',
'marble',
'other',
] as const
export type MaterialCategory = (typeof MATERIAL_CATEGORIES)[number]
const SLAB_TARGETS: MaterialTarget[] = [
MaterialTargetSchema.enum.slab,
]
const WALL_AND_SLAB_TARGETS: MaterialTarget[] = [
export const MATERIAL_CATEGORY_TARGETS: Record<MaterialCategory, MaterialTarget[]> = {
wood: [
MaterialTargetSchema.enum.wall,
MaterialTargetSchema.enum.slab,
]
const STAIR_TARGETS: MaterialTarget[] = [
MaterialTargetSchema.enum.stair,
MaterialTargetSchema.enum['stair-segment'],
]
const STAIR_AND_FENCE_TARGETS: MaterialTarget[] = [
...STAIR_TARGETS,
MaterialTargetSchema.enum.fence,
]
const ROOF_TARGETS: MaterialTarget[] = [
MaterialTargetSchema.enum.roof,
MaterialTargetSchema.enum['roof-segment'],
]
const CEILING_TARGETS: MaterialTarget[] = [
],
wallpaper: [MaterialTargetSchema.enum.wall],
parquet: [
MaterialTargetSchema.enum.slab,
MaterialTargetSchema.enum.stair,
MaterialTargetSchema.enum['stair-segment'],
],
granite: [MaterialTargetSchema.enum.slab],
marble: [
MaterialTargetSchema.enum.slab,
MaterialTargetSchema.enum.stair,
MaterialTargetSchema.enum['stair-segment'],
],
other: [
MaterialTargetSchema.enum.wall,
MaterialTargetSchema.enum.roof,
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[] = [
{
id: 'wall-wood1',
label: 'Wood',
category: 'wood',
description: 'Warm wood finish',
targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS, ...ROOF_TARGETS],
previewThumbnailUrl: '/material/wood1/wood1_thumbnail.webp',
preset: {
maps: {
@@ -85,8 +97,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
{
id: 'wall-wood2',
label: 'Wood',
category: 'wood',
description: 'Textured wood finish',
targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS, ...ROOF_TARGETS],
previewThumbnailUrl: '/material/wood2/wood2_thumbnail.webp',
preset: {
maps: {
@@ -121,8 +133,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
{
id: 'wall-wood3',
label: 'Wood',
category: 'wood',
description: 'Knotted timber finish',
targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS, ...ROOF_TARGETS],
previewThumbnailUrl: '/material/wood3/wood3_thumbnail.webp',
preset: {
maps: {
@@ -155,8 +167,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
{
id: 'wall-wood4',
label: 'Wood',
category: 'wood',
description: 'Oak stretcher finish',
targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS],
previewThumbnailUrl: '/material/wood4/wood4_thumbnail.webp',
preset: {
maps: {
@@ -189,8 +201,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
{
id: 'wall-wood5',
label: 'Wood',
category: 'wood',
description: 'Rich grain wood finish',
targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS],
previewThumbnailUrl: '/material/wood5/wood5_thumnail.webp',
preset: {
maps: {
@@ -225,8 +237,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
{
id: 'wall-granite1',
label: 'Granite',
category: 'granite',
description: 'Polished granite finish',
targets: SLAB_TARGETS,
previewThumbnailUrl: '/material/granite1/granite_thumbnail.webp',
preset: {
maps: {
@@ -259,8 +271,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
{
id: 'wall-marble1',
label: 'Marble',
category: 'marble',
description: 'Smooth marble finish',
targets: [...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS],
previewThumbnailUrl: '/material/marble1/marble1_thumbnail.webp',
preset: {
maps: {
@@ -293,8 +305,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
{
id: 'wall-marble2',
label: 'Marble',
category: 'marble',
description: 'Soft marble finish',
targets: [...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS],
previewThumbnailUrl: '/material/marble2/marble2_thumbnail.webp',
preset: {
maps: {
@@ -327,8 +339,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
{
id: 'wall-parquet1',
label: 'Parquet',
category: 'parquet',
description: 'Parquet wood finish',
targets: SLAB_TARGETS,
previewThumbnailUrl: '/material/parquet1/parquet_thumnail.webp',
preset: {
maps: {
@@ -361,8 +373,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
{
id: 'wall-parquet2',
label: 'Parquet',
category: 'parquet',
description: 'Soft parquet finish',
targets: SLAB_TARGETS,
previewThumbnailUrl: '/material/parquet2/parquet2_thumbnail.webp',
preset: {
maps: {
@@ -395,8 +407,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
{
id: 'wall-wallpaper1',
label: 'Wallpaper',
category: 'wallpaper',
description: 'Soft wallpaper finish',
targets: WALL_TARGETS,
previewThumbnailUrl: '/material/wallpaper1/wallpaper1_thumbnail.webp',
preset: {
maps: {
@@ -430,8 +442,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
{
id: 'wall-wallpaper2',
label: 'Wallpaper',
category: 'wallpaper',
description: 'Decorative wallpaper finish',
targets: WALL_TARGETS,
previewThumbnailUrl: '/material/wallpaper2/wallpaper2_thumnail.webp',
preset: {
maps: {
@@ -464,8 +476,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
{
id: 'wall-wallpaper3',
label: 'Wallpaper',
category: 'wallpaper',
description: 'Patterned wallpaper finish',
targets: WALL_TARGETS,
previewThumbnailUrl: '/material/wallpaper3/wallpaper3_thumbnail.webp',
preset: {
maps: {
@@ -498,14 +510,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
{
id: 'preset-white',
label: 'White',
category: 'other',
description: 'Clean painted finish',
targets: [
...WALL_TARGETS,
...SLAB_TARGETS,
...ROOF_TARGETS,
...STAIR_AND_FENCE_TARGETS,
...CEILING_TARGETS,
],
previewColor: '#ffffff',
preset: {
maps: {},
@@ -536,8 +542,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
{
id: 'preset-metal',
label: 'Metal',
category: 'other',
description: 'Brushed metal finish',
targets: [...WALL_TARGETS, ...SLAB_TARGETS],
previewColor: '#c0c0c0',
preset: {
maps: {},
@@ -568,8 +574,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
{
id: 'preset-glass',
label: 'Glass',
category: 'other',
description: 'Light glass finish',
targets: [...WALL_TARGETS, ...SLAB_TARGETS],
previewColor: '#87ceeb',
preset: {
maps: {},
@@ -599,8 +605,12 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
},
]
export function getMaterialsForTarget(target: MaterialTarget): MaterialCatalogItem[] {
return MATERIAL_CATALOG.filter((item) => item.targets.includes(target))
export function getMaterialsForCategory(category: MaterialCategory): MaterialCatalogItem[] {
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 {
@@ -1,16 +1,13 @@
'use client'
import { getMaterialsForTarget, toLibraryMaterialRef, useScene } from '@pascal-app/core'
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 {
isActivePaintMaterialCompatible,
resolvePaintTargetFromSelection,
} from './../../../lib/material-paint'
import { resolvePaintTargetFromSelection } from './../../../lib/material-paint'
import { cn } from './../../../lib/utils'
import useEditor from './../../../store/use-editor'
import { ItemCatalog } from '../item-catalog/item-catalog'
@@ -40,45 +37,17 @@ function PaintMaterialTray() {
}
}, [nodes, selectedId, setActivePaintTarget])
const catalogItems = useMemo(() => getMaterialsForTarget(activePaintTarget), [activePaintTarget])
useEffect(() => {
const firstMaterial = catalogItems[0]
if (!firstMaterial) return
const hasCompatibleMaterial = isActivePaintMaterialCompatible(
activePaintMaterial,
activePaintTarget,
)
if (hasCompatibleMaterial) return
setActivePaintMaterial({
materialPreset: toLibraryMaterialRef(firstMaterial.id),
sourceTarget: activePaintTarget,
})
}, [activePaintMaterial, activePaintTarget, catalogItems, setActivePaintMaterial])
return (
<div className="w-[42rem] max-w-[calc(100vw-2rem)]">
<MaterialPicker
hideSideControl
nodeType={activePaintTarget}
onChange={(material) => {
setActivePaintMaterial({ material, sourceTarget: activePaintTarget })
onChange={(material, category) => {
setActivePaintMaterial({ material, category, sourceTarget: activePaintTarget })
}}
onSelectMaterialPreset={(materialPreset) => {
setActivePaintMaterial({ materialPreset, sourceTarget: activePaintTarget })
onSelectMaterialPreset={(materialPreset, category) => {
setActivePaintMaterial({ materialPreset, category, sourceTarget: activePaintTarget })
}}
selectedMaterialPreset={
activePaintMaterial?.sourceTarget === activePaintTarget
? activePaintMaterial.materialPreset
: undefined
}
value={
activePaintMaterial?.sourceTarget === activePaintTarget
? activePaintMaterial.material
: undefined
}
selectedMaterialPreset={activePaintMaterial?.materialPreset}
value={activePaintMaterial?.material}
/>
</div>
)
@@ -1,55 +1,69 @@
'use client'
import {
getMaterialsForTarget,
getCatalogMaterialById,
getLibraryMaterialIdFromRef,
getMaterialsForCategory,
MATERIAL_CATEGORIES,
toLibraryMaterialRef,
type MaterialCategory,
type MaterialSchema,
type MaterialTarget,
} from '@pascal-app/core'
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
onChange?: (material: MaterialSchema, category: MaterialCategory) => void
onSelectMaterialPreset?: (materialPreset: string, category: MaterialCategory) => void
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 [selectedCategory, setSelectedCategory] = useState<MaterialCategory>(MATERIAL_CATEGORIES[0])
const catalogScrollRef = useRef<HTMLDivElement>(null)
const catalogItems = nodeType ? getMaterialsForTarget(nodeType) : []
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)
onSelectMaterialPreset?.(toLibraryMaterialRef(materialId))
setPaintPanelOpen(false)
const category = getCatalogMaterialById(materialId)?.category
if (!category) return
onSelectMaterialPreset?.(toLibraryMaterialRef(materialId), category)
}
useEffect(() => {
@@ -76,7 +90,9 @@ export function MaterialPicker({
const handleCustomOpen = () => {
if (disabled) return
setShowCustom(true)
onChange?.({
setPaintPanelOpen(true)
onChange?.(
{
preset: 'custom',
properties: {
color: value?.properties?.color || '#ffffff',
@@ -86,30 +102,39 @@ export function MaterialPicker({
transparent: value?.properties?.transparent ?? false,
side: value?.properties?.side ?? 'front',
},
})
}
const handlePropertyChange = (
prop: keyof typeof currentProps,
val: (typeof currentProps)[keyof typeof currentProps],
) => {
if (disabled) return
onChange?.({
preset: 'custom',
properties: {
...currentProps,
[prop]: val,
},
})
'other',
)
}
return (
<div className={`min-w-0 space-y-3 ${disabled ? 'pointer-events-none opacity-50' : ''}`}>
{(catalogItems.length > 0 || onChange) && (
<div className="min-w-0 space-y-2">
{catalogItems.length > 0 ? (
<div className="text-gray-500 text-xs uppercase tracking-[0.16em]">Library</div>
) : null}
<div className="flex flex-wrap gap-1">
{MATERIAL_CATEGORIES.map((category) => (
<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
className="w-full max-w-full overflow-x-auto overflow-y-hidden"
ref={catalogScrollRef}
@@ -142,12 +167,12 @@ export function MaterialPicker({
)}
</button>
))}
{onChange ? (
{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 bg-blue-50 text-blue-700 ring-2 ring-blue-500/30'
: 'border-gray-300 bg-white text-gray-500 hover:border-gray-400'
? 'border-blue-500 ring-2 ring-blue-500/30'
: 'border-gray-300 hover:border-gray-400'
}`}
onClick={handleCustomOpen}
title="Custom"
@@ -160,97 +185,6 @@ export function MaterialPicker({
</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>
)
}
@@ -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 { 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) {
+24 -2
View File
@@ -8,6 +8,8 @@ import {
getEffectiveStairSurfaceMaterial,
getEffectiveWallSurfaceMaterial,
getLibraryMaterialIdFromRef,
getMaterialCategoryTargets,
type MaterialCategory,
type MaterialSchema,
type MaterialTarget,
type RoofNode,
@@ -29,6 +31,7 @@ export type SingleSurfaceMaterialRole = 'surface'
export type ActivePaintMaterial = {
material?: MaterialSchema
materialPreset?: string
category?: MaterialCategory
sourceTarget: PaintableMaterialTarget
}
@@ -47,6 +50,17 @@ function getCatalogEntryForActivePaintMaterial(material: ActivePaintMaterial | n
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) {
return getCatalogEntryForActivePaintMaterial(material)?.label ?? 'Custom'
}
@@ -58,9 +72,10 @@ export function isActivePaintMaterialCompatible(
if (!hasActivePaintMaterial(material)) return false
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(
@@ -185,6 +200,7 @@ export function resolveActivePaintMaterialFromSelection(params: {
? {
material: surface.material,
materialPreset: surface.materialPreset,
category: resolvePaintMaterialCategory(surface.material, surface.materialPreset),
sourceTarget: 'wall',
}
: null
@@ -205,6 +221,7 @@ export function resolveActivePaintMaterialFromSelection(params: {
? {
material: surface.material,
materialPreset: surface.materialPreset,
category: resolvePaintMaterialCategory(surface.material, surface.materialPreset),
sourceTarget: 'roof',
}
: null
@@ -225,6 +242,7 @@ export function resolveActivePaintMaterialFromSelection(params: {
? {
material: surface.material,
materialPreset: surface.materialPreset,
category: resolvePaintMaterialCategory(surface.material, surface.materialPreset),
sourceTarget: 'stair',
}
: null
@@ -245,6 +263,10 @@ export function resolveActivePaintMaterialFromSelection(params: {
? {
material: selectedNode.material,
materialPreset: selectedNode.materialPreset,
category: resolvePaintMaterialCategory(
selectedNode.material,
selectedNode.materialPreset,
),
sourceTarget: target,
}
: null
+4
View File
@@ -169,6 +169,8 @@ type EditorState = {
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
@@ -591,6 +593,8 @@ const useEditor = create<EditorState>()(
set((state) =>
state.hoveredPaintTarget === target ? state : { hoveredPaintTarget: target },
),
isPaintPanelOpen: false,
setPaintPanelOpen: (open) => set({ isPaintPanelOpen: open }),
selectedReferenceId: null,
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
spaces: {},