feat: Add catalog-based material presets across structural nodes (#231)

* Add catalog-based material presets across structural nodes

* Fix material picker fallback and wall visible highlights

* Preserve wall materials in selection highlights
This commit is contained in:
Sudhir Yadav
2026-04-15 12:16:35 -04:00
committed by GitHub
parent f25806ee8f
commit 57df224948
67 changed files with 1458 additions and 151 deletions
@@ -1,73 +1,68 @@
'use client'
import { DEFAULT_MATERIALS, type MaterialPreset, type MaterialSchema } from '@pascal-app/core'
import {
getMaterialsForTarget,
toLibraryMaterialRef,
type MaterialSchema,
type MaterialTarget,
} from '@pascal-app/core'
import { useState } from 'react'
const PRESET_COLORS: Record<MaterialPreset, string> = {
white: '#ffffff',
brick: '#8b4513',
concrete: '#808080',
wood: '#deb887',
glass: '#87ceeb',
metal: '#c0c0c0',
plaster: '#f5f5dc',
tile: '#d3d3d3',
marble: '#fafafa',
custom: '#ffffff',
}
const PRESET_LABELS: Record<MaterialPreset, string> = {
white: 'White',
brick: 'Brick',
concrete: 'Concrete',
wood: 'Wood',
glass: 'Glass',
metal: 'Metal',
plaster: 'Plaster',
tile: 'Tile',
marble: 'Marble',
custom: 'Custom',
}
type MaterialPickerProps = {
nodeType?: MaterialTarget
value?: MaterialSchema
onChange: (material: MaterialSchema) => void
selectedMaterialPreset?: string
onChange?: (material: MaterialSchema) => void
onSelectMaterialPreset?: (materialPreset: string) => void
}
export function MaterialPicker({ value, onChange }: MaterialPickerProps) {
const [showCustom, setShowCustom] = useState<boolean>(
value?.preset === 'custom' || !!value?.properties,
)
export function MaterialPicker({
nodeType,
value,
selectedMaterialPreset,
onChange,
onSelectMaterialPreset,
}: MaterialPickerProps) {
const [showCustom, setShowCustom] = useState<boolean>(!!value?.properties)
const catalogItems = nodeType ? getMaterialsForTarget(nodeType) : []
const currentPreset = value?.preset || 'white'
const currentProps = value?.properties || DEFAULT_MATERIALS[currentPreset]
const currentProps = value?.properties || {
color: '#ffffff',
roughness: 0.5,
metalness: 0,
opacity: 1,
transparent: false,
side: 'front' as const,
}
const selectedCatalogId =
selectedMaterialPreset ?? (value?.id ? toLibraryMaterialRef(value.id) : undefined)
const handlePresetChange = (preset: MaterialPreset) => {
if (preset === 'custom') {
setShowCustom(true)
onChange({
preset: 'custom',
properties: {
color: value?.properties?.color || '#ffffff',
roughness: value?.properties?.roughness ?? 0.5,
metalness: value?.properties?.metalness ?? 0,
opacity: value?.properties?.opacity ?? 1,
transparent: value?.properties?.transparent ?? false,
side: value?.properties?.side ?? 'front',
},
})
} else {
setShowCustom(false)
onChange({ preset })
}
const handleCatalogSelect = (materialId: string) => {
setShowCustom(false)
onSelectMaterialPreset?.(toLibraryMaterialRef(materialId))
}
const handleCustomOpen = () => {
setShowCustom(true)
onChange?.({
preset: 'custom',
properties: {
color: value?.properties?.color || '#ffffff',
roughness: value?.properties?.roughness ?? 0.5,
metalness: value?.properties?.metalness ?? 0,
opacity: value?.properties?.opacity ?? 1,
transparent: value?.properties?.transparent ?? false,
side: value?.properties?.side ?? 'front',
},
})
}
const handlePropertyChange = (
prop: keyof typeof currentProps,
val: (typeof currentProps)[keyof typeof currentProps],
) => {
onChange({
preset: showCustom ? 'custom' : currentPreset,
onChange?.({
preset: 'custom',
properties: {
...currentProps,
[prop]: val,
@@ -77,31 +72,56 @@ export function MaterialPicker({ value, onChange }: MaterialPickerProps) {
return (
<div className="space-y-3">
<div className="grid grid-cols-5 gap-1.5">
{(Object.keys(PRESET_COLORS) as MaterialPreset[]).map((preset) => (
<button
className={`h-8 w-8 rounded border-2 transition-all ${
currentPreset === preset
? 'border-blue-500 ring-2 ring-blue-500/30'
: 'border-gray-300 hover:border-gray-400'
}`}
key={preset}
onClick={() => handlePresetChange(preset)}
style={{
backgroundColor: PRESET_COLORS[preset],
backgroundImage:
preset === 'glass'
? 'linear-gradient(135deg, rgba(255,255,255,0.3) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.3) 50%, rgba(255,255,255,0.3) 75%, transparent 75%, transparent)'
: undefined,
backgroundSize: preset === 'glass' ? '8px 8px' : undefined,
}}
title={PRESET_LABELS[preset]}
type="button"
/>
))}
</div>
{(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 && (
{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>
@@ -34,7 +34,14 @@ export function CeilingPanel() {
const handleMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material })
handleUpdate({ material, materialPreset: undefined })
},
[handleUpdate],
)
const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined })
},
[handleUpdate],
)
@@ -223,7 +230,13 @@ export function CeilingPanel() {
</PanelSection>
<PanelSection title="Material">
<MaterialPicker onChange={handleMaterialChange} value={node.material} />
<MaterialPicker
nodeType="ceiling"
onChange={handleMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
/>
</PanelSection>
</PanelWrapper>
)
@@ -1,8 +1,17 @@
'use client'
import { type AnyNode, type AnyNodeId, type FenceBaseStyle, type FenceNode, type FenceStyle, useScene } from '@pascal-app/core'
import {
type AnyNode,
type AnyNodeId,
type FenceBaseStyle,
type FenceNode,
type FenceStyle,
type MaterialSchema,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback } from 'react'
import { MaterialPicker } from '../controls/material-picker'
import { PanelSection } from '../controls/panel-section'
import { SegmentedControl } from '../controls/segmented-control'
import { SliderControl } from '../controls/slider-control'
@@ -62,6 +71,20 @@ export function FencePanel() {
setSelection({ selectedIds: [] })
}, [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' || selectedIds.length !== 1) return null
const dx = node.end[0] - node.start[0]
@@ -179,6 +202,16 @@ export function FencePanel() {
value={node.edgeInset}
/>
</PanelSection>
<PanelSection title="Material">
<MaterialPicker
nodeType="fence"
onChange={handleCustomMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
/>
</PanelSection>
</PanelWrapper>
)
}
@@ -43,7 +43,14 @@ export function RoofPanel() {
const handleMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material })
handleUpdate({ material, materialPreset: undefined })
},
[handleUpdate],
)
const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined })
},
[handleUpdate],
)
@@ -255,7 +262,13 @@ export function RoofPanel() {
</ActionGroup>
</PanelSection>
<PanelSection title="Material">
<MaterialPicker onChange={handleMaterialChange} value={node.material} />
<MaterialPicker
nodeType="roof"
onChange={handleMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
/>
</PanelSection>
</PanelWrapper>
)
@@ -57,7 +57,14 @@ export function RoofSegmentPanel() {
const handleMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material })
handleUpdate({ material, materialPreset: undefined })
},
[handleUpdate],
)
const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined })
},
[handleUpdate],
)
@@ -319,7 +326,13 @@ export function RoofSegmentPanel() {
</ActionGroup>
</PanelSection>
<PanelSection title="Material">
<MaterialPicker onChange={handleMaterialChange} value={node.material} />
<MaterialPicker
nodeType="roof-segment"
onChange={handleMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
/>
</PanelSection>
</PanelWrapper>
)
@@ -30,9 +30,16 @@ export function SlabPanel() {
[selectedId, updateNode],
)
const handleMaterialChange = useCallback(
const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined })
},
[handleUpdate],
)
const handleCustomMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material })
handleUpdate({ material, materialPreset: undefined })
},
[handleUpdate],
)
@@ -221,7 +228,13 @@ export function SlabPanel() {
</div>
</PanelSection>
<PanelSection title="Material">
<MaterialPicker onChange={handleMaterialChange} value={node.material} />
<MaterialPicker
nodeType="slab"
onChange={handleCustomMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
/>
</PanelSection>
</PanelWrapper>
)
@@ -70,7 +70,14 @@ export function StairPanel() {
const handleMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material })
handleUpdate({ material, materialPreset: undefined })
},
[handleUpdate],
)
const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined })
},
[handleUpdate],
)
@@ -470,7 +477,13 @@ export function StairPanel() {
</ActionGroup>
</PanelSection>
<PanelSection title="Material">
<MaterialPicker onChange={handleMaterialChange} value={node.material} />
<MaterialPicker
nodeType="stair"
onChange={handleMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
/>
</PanelSection>
</PanelWrapper>
)
@@ -65,7 +65,14 @@ export function StairSegmentPanel() {
const handleMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material })
handleUpdate({ material, materialPreset: undefined })
},
[handleUpdate],
)
const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined })
},
[handleUpdate],
)
@@ -332,7 +339,13 @@ export function StairSegmentPanel() {
</ActionGroup>
</PanelSection>
<PanelSection title="Material">
<MaterialPicker onChange={handleMaterialChange} value={node.material} />
<MaterialPicker
nodeType="stair-segment"
onChange={handleMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
/>
</PanelSection>
</PanelWrapper>
)
@@ -55,9 +55,16 @@ export function WallPanel() {
[node, handleUpdate],
)
const handleMaterialChange = useCallback(
const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined })
},
[handleUpdate],
)
const handleCustomMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material })
handleUpdate({ material, materialPreset: undefined })
},
[handleUpdate],
)
@@ -116,7 +123,13 @@ export function WallPanel() {
</PanelSection>
<PanelSection title="Material">
<MaterialPicker onChange={handleMaterialChange} value={node.material} />
<MaterialPicker
nodeType="wall"
onChange={handleCustomMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
/>
</PanelSection>
</PanelWrapper>
)