diff --git a/packages/editor/src/components/ui/controls/material-paint-panel.tsx b/packages/editor/src/components/ui/controls/material-paint-panel.tsx
index d2fd2d244..4abae9b62 100644
--- a/packages/editor/src/components/ui/controls/material-paint-panel.tsx
+++ b/packages/editor/src/components/ui/controls/material-paint-panel.tsx
@@ -8,7 +8,7 @@ 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,
@@ -16,15 +16,16 @@ import {
} 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)
@@ -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 (
-
-
+
+ {/* Fixed: eraser / reset. */}
+
-
{
- // 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 ? (
-
-
-
- ) : null}
+
+ {/* Scrolls: category tabs (fixed inside) + catalog grid (the scroll). */}
+
+ {
+ setActivePaintMaterial({ materialPreset, sourceTarget: activePaintTarget })
+ }}
+ selectedMaterialPreset={activePaintMaterial?.materialPreset}
+ />
+
+
+ {/* Fixed footer: scene materials, always visible, with a `+` to add one. */}
+
+
+
+ Scene materials
+
+
+
+
+
+ Add material
+
+
+
+ {materialCount > 0 ? (
+
+ ) : (
+
+ No custom materials yet — add one with +.
+
+ )}
+
+
)
}
diff --git a/packages/editor/src/components/ui/controls/material-picker.tsx b/packages/editor/src/components/ui/controls/material-picker.tsx
index e0b5469f9..e0284a1a4 100644
--- a/packages/editor/src/components/ui/controls/material-picker.tsx
+++ b/packages/editor/src/components/ui/controls/material-picker.tsx
@@ -5,7 +5,6 @@ import {
getLibraryMaterialIdFromRef,
getMaterialsForCategory,
MATERIAL_CATEGORIES,
- type MaterialSchema,
type MaterialTarget,
toLibraryMaterialRef,
} from '@pascal-app/core'
@@ -13,9 +12,7 @@ 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
@@ -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
(!!value?.properties)
const [selectedCategory, setSelectedCategory] = useState<(typeof MATERIAL_CATEGORIES)[number]>(
MATERIAL_CATEGORIES[0],
)
@@ -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 (
-
- {(catalogItems.length > 0 || onChange) && (
-
-
- {availableCategories.map((category) => (
-
- ))}
-
-
+ {/* Fixed category tabs — outside the scroll region. */}
+
+ {availableCategories.map((category) => (
+
-
- )}
+ {getCategoryLabel(category)}
+
+ ))}
+
+ {/* The only scrolling region. */}
+
+ {catalogItems.map((item) => {
+ const isSelected = selectedMaterialPreset === toLibraryMaterialRef(item.id)
+ return (
+
{
+ triggerSFX('sfx:menu-click')
+ handleCatalogSelect(item.id)
+ }}
+ onMouseEnter={() => triggerSFX('sfx:menu-hover')}
+ type="button"
+ >
+
+ {item.previewThumbnailUrl ? (
+

+ ) : (
+
+ )}
+
+
+ {item.label}
+
+
+ )
+ })}
+
)
}
diff --git a/packages/editor/src/components/ui/controls/scene-material-list.tsx b/packages/editor/src/components/ui/controls/scene-material-list.tsx
index a300ffc33..a95ccb4a3 100644
--- a/packages/editor/src/components/ui/controls/scene-material-list.tsx
+++ b/packages/editor/src/components/ui/controls/scene-material-list.tsx
@@ -32,6 +32,7 @@ export function SceneMaterialList({ autoEditId }: { autoEditId?: SceneMaterialId
const updateSceneMaterial = useScene((state) => state.updateSceneMaterial)
const removeSceneMaterial = useScene((state) => state.removeSceneMaterial)
const activePaintTarget = useEditor((state) => state.activePaintTarget)
+ const activePaintRef = useEditor((state) => state.activePaintMaterial?.materialPreset)
const setActivePaintMaterial = useEditor((state) => state.setActivePaintMaterial)
const materialEntries = useMemo(
@@ -71,6 +72,7 @@ export function SceneMaterialList({ autoEditId }: { autoEditId?: SceneMaterialId
activePaintTarget={activePaintTarget}
autoEdit={autoEditId === id}
id={id}
+ isActive={activePaintRef === toSceneMaterialRef(id)}
key={id}
removeSceneMaterial={removeSceneMaterial}
sceneMaterial={sceneMaterial}
@@ -89,6 +91,7 @@ function SceneMaterialRow({
usageCount,
activePaintTarget,
autoEdit,
+ isActive,
addSceneMaterial,
updateSceneMaterial,
removeSceneMaterial,
@@ -99,6 +102,7 @@ function SceneMaterialRow({
usageCount: number
activePaintTarget: ReturnType['activePaintTarget']
autoEdit: boolean
+ isActive: boolean
addSceneMaterial: ReturnType['addSceneMaterial']
updateSceneMaterial: ReturnType['updateSceneMaterial']
removeSceneMaterial: ReturnType['removeSceneMaterial']
@@ -133,7 +137,11 @@ function SceneMaterialRow({
}
return (
-