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 9550cb97..4abae9b6 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,11 +56,33 @@ 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 (
- // Fill the host's scroll slot and own the scroll internally: the eraser /
- // reset row stays pinned (shrink-0) while only the material list below
- // scrolls. The category tabs pin too (sticky, inside the scroll region).
{
- // 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 ? (
-
+
+
+ {/* Fixed footer: scene materials, always visible, with a `+` to add one. */}
+
+
+
+ Scene materials
+
+
+
+
+
+ Add material
+
+
+
+ {materialCount > 0 ? (
-
- ) : null}
+ ) : (
+
+ 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 6dd019e6..e0284a1a 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,145 +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 (
-