From 1dcaa7371762cf3d0fe8997c12afc056fe8fb5e3 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Thu, 18 Jun 2026 12:50:47 -0400 Subject: [PATCH] =?UTF-8?q?refactor(paint-slots):=20paint=20panel=20?= =?UTF-8?q?=E2=80=94=20three=20fixed=20bands,=20scroll=20only=20the=20grid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layout is now: fixed eraser/reset + category tabs (no longer sticky-inside- scroll), a single scrolling catalog grid, and an always-visible scene-material footer. The custom-material "+" moved out of the colors-only grid cell onto the scene-material section header, so a custom material can be added from any category (creates a blank scene material, selects it as the brush, opens its inline editor). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ui/controls/material-paint-panel.tsx | 91 +++++--- .../ui/controls/material-picker.tsx | 213 +++++++----------- 2 files changed, 141 insertions(+), 163 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 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).
+ {/* Fixed: eraser / reset. */}
-
+ + {/* 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 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 ( -
- {(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 ( + + ) + })} +
) }