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:
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user