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) => ( + - ) - })} - {selectedCategory === 'colors' && onChange ? ( - - ) : null} -
-
- )} + {getCategoryLabel(category)} + + ))} +
+ {/* The only scrolling region. */} +
+ {catalogItems.map((item) => { + const isSelected = selectedMaterialPreset === toLibraryMaterialRef(item.id) + return ( + + ) + })} +
) } 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 ( -
+