From 8badd415b93929be49500bab01f32fa12ec4f111 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Thu, 18 Jun 2026 12:45:45 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(paint-slots):=20paint=20panel=20polish?= =?UTF-8?q?=20=E2=80=94=20sticky=20controls,=20selection=20outlines,=20aut?= =?UTF-8?q?o-select?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MaterialPaintPanel owns its scroll: the eraser/reset row stays pinned and the category tabs stick to the top, so only the material list scrolls. - Selected catalog swatch + active scene-material card use the same `ring-1 ring-primary ring-inset` outline as item/preset tiles. - Choosing a material category auto-selects its first material. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ui/controls/material-paint-panel.tsx | 61 ++++++++++--------- .../ui/controls/material-picker.tsx | 14 +++-- .../ui/controls/scene-material-list.tsx | 10 ++- 3 files changed, 50 insertions(+), 35 deletions(-) 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..9550cb976 100644 --- a/packages/editor/src/components/ui/controls/material-paint-panel.tsx +++ b/packages/editor/src/components/ui/controls/material-paint-panel.tsx @@ -56,8 +56,11 @@ export function MaterialPaintPanel() { } 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 ? ( - - - - ) : null} +
+ { + // 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} +
) } diff --git a/packages/editor/src/components/ui/controls/material-picker.tsx b/packages/editor/src/components/ui/controls/material-picker.tsx index e0b5469f9..6dd019e6d 100644 --- a/packages/editor/src/components/ui/controls/material-picker.tsx +++ b/packages/editor/src/components/ui/controls/material-picker.tsx @@ -96,7 +96,7 @@ export function MaterialPicker({
{(catalogItems.length > 0 || onChange) && (
-
+
{availableCategories.map((category) => (
-
+ + {/* Scrolls: category tabs (fixed inside) + catalog grid (the scroll). */} +
{ - // 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 6dd019e6d..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,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 ( -
- {(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 ( + + ) + })} +
) }