Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 74 additions & 32 deletions packages/editor/src/components/ui/controls/material-paint-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,24 @@ import {
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Eraser, RotateCcw } from 'lucide-react'
import { Eraser, Plus, RotateCcw } from 'lucide-react'
import { useEffect, useState } from 'react'
import {
buildResetSurfaceMaterialUpdates,
resolvePaintTargetFromSelection,
} from './../../../lib/material-paint'
import useEditor from './../../../store/use-editor'
import { Button } from '../primitives/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '../primitives/tooltip'
import { MaterialPicker } from './material-picker'
import { PanelSection } from './panel-section'
import { SceneMaterialList } from './scene-material-list'

/**
* Material picker for paint mode. Embedders render this wherever paint controls
* belong (the community editor places it in the Build sidebar while paint mode
* is active). It owns the paint-target/material wiring so the host only needs
* to mount it; it fills its container's width.
* is active). It fills its container's height and lays out as three bands: a
* fixed control/category header, a single scrolling catalog grid, and a fixed
* scene-material footer (always visible, with a `+` to add a custom material).
*/
export function MaterialPaintPanel() {
const activePaintMaterial = useEditor((state) => state.activePaintMaterial)
Expand Down Expand Up @@ -55,9 +56,34 @@ export function MaterialPaintPanel() {
useScene.getState().updateNodes(buildResetSurfaceMaterialUpdates(nodes, selectedNode))
}

// Create a blank custom scene material, select it as the brush (`scene:` ref so
// edits propagate), and open its inline editor. Available from any category.
const createCustomMaterial = () => {
const id = generateSceneMaterialId()
const count = Object.keys(useScene.getState().materials).length
useScene.getState().addSceneMaterial({
id,
name: `Material ${count + 1}`,
material: {
preset: 'custom',
properties: {
color: '#ffffff',
roughness: 0.5,
metalness: 0,
opacity: 1,
transparent: false,
side: 'front',
},
},
})
setActivePaintMaterial({ materialPreset: toSceneMaterialRef(id), sourceTarget: activePaintTarget })
setAutoEditMaterialId(id)
}

return (
<div className="w-full space-y-2">
<div className="flex items-center gap-2">
<div className="flex h-full min-h-0 w-full flex-col">
{/* Fixed: eraser / reset. */}
<div className="flex shrink-0 items-center gap-2 pb-2">
<Button
aria-pressed={paintEraser}
className="flex-1"
Expand All @@ -79,32 +105,48 @@ export function MaterialPaintPanel() {
Reset all
</Button>
</div>
<MaterialPicker
onChange={(material) => {
// Custom-create: pre-create a scene material and select it as the
// brush via a `scene:` ref so painting stores the ref and edits to
// it propagate everywhere. The user edits it inline in the scene-
// material list below (auto-opened) — no separate right-side pane.
const id = generateSceneMaterialId()
const count = Object.keys(useScene.getState().materials).length
useScene.getState().addSceneMaterial({ id, name: `Material ${count + 1}`, material })
setActivePaintMaterial({
materialPreset: toSceneMaterialRef(id),
sourceTarget: activePaintTarget,
})
setAutoEditMaterialId(id)
}}
onSelectMaterialPreset={(materialPreset) => {
setActivePaintMaterial({ materialPreset, sourceTarget: activePaintTarget })
}}
selectedMaterialPreset={activePaintMaterial?.materialPreset}
value={activePaintMaterial?.material}
/>
{materialCount > 0 ? (
<PanelSection title="Scene materials">
<SceneMaterialList autoEditId={autoEditMaterialId} />
</PanelSection>
) : null}

{/* Scrolls: category tabs (fixed inside) + catalog grid (the scroll). */}
<div className="min-h-0 flex-1">
<MaterialPicker
onSelectMaterialPreset={(materialPreset) => {
setActivePaintMaterial({ materialPreset, sourceTarget: activePaintTarget })
}}
selectedMaterialPreset={activePaintMaterial?.materialPreset}
/>
</div>

{/* Fixed footer: scene materials, always visible, with a `+` to add one. */}
<div className="mt-2 shrink-0 space-y-1.5 border-border/60 border-t pt-2">
<div className="flex items-center justify-between">
<span className="font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
Scene materials
</span>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label="Add material"
onClick={createCustomMaterial}
size="icon-sm"
type="button"
variant="outline"
>
<Plus />
</Button>
</TooltipTrigger>
<TooltipContent>Add material</TooltipContent>
</Tooltip>
</div>
<div className="subtle-scrollbar max-h-56 overflow-y-auto">
{materialCount > 0 ? (
<SceneMaterialList autoEditId={autoEditMaterialId} />
) : (
<p className="px-0.5 py-1 text-muted-foreground text-xs">
No custom materials yet — add one with +.
</p>
)}
</div>
</div>
</div>
)
}
211 changes: 77 additions & 134 deletions packages/editor/src/components/ui/controls/material-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,14 @@ import {
getLibraryMaterialIdFromRef,
getMaterialsForCategory,
MATERIAL_CATEGORIES,
type MaterialSchema,
type MaterialTarget,
toLibraryMaterialRef,
} from '@pascal-app/core'
import { useEffect, useState } from 'react'
import { triggerSFX } from '../../../lib/sfx-bus'

type MaterialPickerProps = {
value?: MaterialSchema
selectedMaterialPreset?: string
onChange?: (material: MaterialSchema) => void
onSelectMaterialPreset?: (materialPreset: string) => void
disabled?: boolean
nodeType?: MaterialTarget
Expand All @@ -26,14 +23,16 @@ function getCategoryLabel(category: (typeof MATERIAL_CATEGORIES)[number]) {
return category.charAt(0).toUpperCase() + category.slice(1)
}

/**
* Catalog material picker: a fixed row of category tabs over a scrollable grid
* of swatches. Custom-material creation lives in the scene-material section
* (the host's `+` action), not here, so it's available from any category.
*/
export function MaterialPicker({
value,
selectedMaterialPreset,
onChange,
onSelectMaterialPreset,
disabled = false,
}: MaterialPickerProps) {
const [showCustom, setShowCustom] = useState<boolean>(!!value?.properties)
const [selectedCategory, setSelectedCategory] = useState<(typeof MATERIAL_CATEGORIES)[number]>(
MATERIAL_CATEGORIES[0],
)
Expand All @@ -42,143 +41,87 @@ export function MaterialPicker({
)
const catalogItems = getMaterialsForCategory(selectedCategory)

// Keep the visible category in sync with the externally-selected catalog
// material (a `scene:` ref matches no catalog entry, so the tab stays put).
useEffect(() => {
setShowCustom(!!value?.properties && !selectedMaterialPreset)
}, [selectedMaterialPreset, value?.properties])

useEffect(() => {
if (!selectedMaterialPreset && value?.properties) {
setSelectedCategory('colors')
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 selectedCatalogMaterialId = getLibraryMaterialIdFromRef(selectedCatalogId) ?? undefined
const selectedCatalogEntry = getCatalogMaterialById(selectedCatalogMaterialId)
const catalogId = getLibraryMaterialIdFromRef(selectedMaterialPreset) ?? undefined
const entry = getCatalogMaterialById(catalogId)
if (entry?.category) setSelectedCategory(entry.category)
}, [selectedMaterialPreset])

const handleCatalogSelect = (materialId: string) => {
if (disabled) return
setShowCustom(false)
onSelectMaterialPreset?.(toLibraryMaterialRef(materialId))
}

// Seed a new custom material from the current/forked colour and hand it to
// the host (MaterialPaintPanel), which pre-creates a scene material the user
// edits inline in the build pane — no separate right-side editor pane.
const handleCustomOpen = () => {
if (disabled) return
const forkColor = selectedMaterialPreset
? (selectedCatalogEntry?.previewColor ?? '#ffffff')
: '#ffffff'
onChange?.({
preset: 'custom',
properties: {
color: value?.properties?.color || forkColor,
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',
},
})
}

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-1">
<div className="flex flex-wrap gap-1 pb-1">
{availableCategories.map((category) => (
<button
className={`rounded-full px-3 py-1 font-medium text-xs transition-colors ${
selectedCategory === category
? 'bg-foreground text-background'
: 'text-muted-foreground hover:text-foreground'
}`}
key={category}
onClick={() => {
setSelectedCategory(category)
if (showCustom) {
setShowCustom(false)
}
}}
type="button"
>
{getCategoryLabel(category)}
</button>
))}
</div>
<div
className="grid gap-2 pb-1"
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(72px, 1fr))' }}
<div
className={`flex h-full min-h-0 flex-col gap-2 ${disabled ? 'pointer-events-none opacity-50' : ''}`}
>
{/* Fixed category tabs — outside the scroll region. */}
<div className="flex shrink-0 flex-wrap gap-1">
{availableCategories.map((category) => (
<button
className={`rounded-full px-3 py-1 font-medium text-xs transition-colors ${
selectedCategory === category
? 'bg-foreground text-background'
: 'text-muted-foreground hover:text-foreground'
}`}
key={category}
onClick={() => {
setSelectedCategory(category)
// Auto-select the first material in the category so the brush is
// immediately ready (and the swatch shows as selected).
const first = getMaterialsForCategory(category)[0]
if (first) handleCatalogSelect(first.id)
}}
type="button"
>
{catalogItems.map((item) => {
const isSelected = selectedCatalogId === toLibraryMaterialRef(item.id)
return (
<button
className={`group relative flex flex-col gap-1.5 rounded-xl p-1.5 transition-colors hover:cursor-pointer hover:bg-sidebar-accent ${
isSelected ? 'bg-sidebar-accent ring-2 ring-primary-foreground' : ''
}`}
key={item.id}
onClick={() => {
triggerSFX('sfx:menu-click')
handleCatalogSelect(item.id)
}}
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
type="button"
>
<div className="relative aspect-square w-full overflow-hidden rounded-lg">
{item.previewThumbnailUrl ? (
<img
alt={item.label}
className="h-full w-full object-cover"
src={item.previewThumbnailUrl}
/>
) : (
<div
className="h-full w-full"
style={{ backgroundColor: item.previewColor ?? '#f3f4f6' }}
/>
)}
</div>
<span className="truncate px-0.5 text-left font-medium text-[11px] text-muted-foreground group-hover:text-foreground">
{item.label}
</span>
</button>
)
})}
{selectedCategory === 'colors' && onChange ? (
<button
className={`group relative flex flex-col gap-1.5 rounded-xl p-1.5 transition-colors hover:cursor-pointer hover:bg-sidebar-accent ${
showCustom ? 'bg-sidebar-accent ring-2 ring-primary-foreground' : ''
}`}
onClick={() => {
triggerSFX('sfx:menu-click')
handleCustomOpen()
}}
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
type="button"
>
<div className="flex aspect-square w-full items-center justify-center rounded-lg bg-muted text-lg text-muted-foreground group-hover:text-foreground">
+
</div>
<span className="truncate px-0.5 text-left font-medium text-[11px] text-muted-foreground group-hover:text-foreground">
Custom
</span>
</button>
) : null}
</div>
</div>
)}
{getCategoryLabel(category)}
</button>
))}
</div>
{/* The only scrolling region. */}
<div
className="subtle-scrollbar grid min-h-0 flex-1 auto-rows-min gap-2 overflow-y-auto pb-1"
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(72px, 1fr))' }}
>
{catalogItems.map((item) => {
const isSelected = selectedMaterialPreset === toLibraryMaterialRef(item.id)
return (
<button
className={`group relative flex flex-col gap-1.5 rounded-xl p-1.5 transition-colors hover:cursor-pointer hover:bg-sidebar-accent ${
isSelected ? 'bg-sidebar-accent ring-1 ring-primary ring-inset' : ''
}`}
key={item.id}
onClick={() => {
triggerSFX('sfx:menu-click')
handleCatalogSelect(item.id)
}}
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
type="button"
>
<div className="relative aspect-square w-full overflow-hidden rounded-lg">
{item.previewThumbnailUrl ? (
<img
alt={item.label}
className="h-full w-full object-cover"
src={item.previewThumbnailUrl}
/>
) : (
<div
className="h-full w-full"
style={{ backgroundColor: item.previewColor ?? '#f3f4f6' }}
/>
)}
</div>
<span className="truncate px-0.5 text-left font-medium text-[11px] text-muted-foreground group-hover:text-foreground">
{item.label}
</span>
</button>
)
})}
</div>
</div>
)
}
Loading
Loading