From b3d840a39b9f43ee8a35b8db0a320dc4eeb5757d Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Mon, 15 Jun 2026 10:13:05 -0400 Subject: [PATCH] feat(paint-slots): scene-material library panel + Colors swatches Phase 3 paint UI. - Extract reusable MaterialPropertiesEditor (shared by the custom active- paint editor and the scene-material editor). - Curated Colors swatch row in the picker; catalog presets fork-on-tweak by seeding a custom material from the entry's previewColor. - Scene materials section in MaterialPaintPanel (shown once any exist): list with swatch, inline rename, 'used by N parts', paint-with, edit (live-propagates to every referencing part via the renderer's sceneMaterials dep), duplicate, delete. Co-Authored-By: Claude Fable 5 --- .../ui/controls/material-paint-panel.tsx | 8 + .../ui/controls/material-picker.tsx | 137 +++++++---- .../controls/material-properties-editor.tsx | 140 +++++++++++ .../ui/controls/scene-material-list.tsx | 218 ++++++++++++++++++ .../src/components/ui/panels/paint-panel.tsx | 149 +----------- packages/editor/src/lib/colors.ts | 16 ++ 6 files changed, 489 insertions(+), 179 deletions(-) create mode 100644 packages/editor/src/components/ui/controls/material-properties-editor.tsx create mode 100644 packages/editor/src/components/ui/controls/scene-material-list.tsx create mode 100644 packages/editor/src/lib/colors.ts 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 eead7524..ca3f3b65 100644 --- a/packages/editor/src/components/ui/controls/material-paint-panel.tsx +++ b/packages/editor/src/components/ui/controls/material-paint-panel.tsx @@ -11,6 +11,8 @@ import { import useEditor from './../../../store/use-editor' import { Button } from '../primitives/button' 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 @@ -27,6 +29,7 @@ export function MaterialPaintPanel() { const setPaintEraser = useEditor((state) => state.setPaintEraser) const selectedIds = useViewer((state) => state.selection.selectedIds) const nodes = useScene((state) => state.nodes) + const materialCount = useScene((state) => Object.keys(state.materials).length) const selectedId = selectedIds.length === 1 ? (selectedIds[0] ?? null) : null const selectedNode = selectedId ? nodes[selectedId as AnyNodeId] : null const canResetSelection = @@ -78,6 +81,11 @@ export function MaterialPaintPanel() { 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 da35979e..f3d4b129 100644 --- a/packages/editor/src/components/ui/controls/material-picker.tsx +++ b/packages/editor/src/components/ui/controls/material-picker.tsx @@ -10,6 +10,7 @@ import { toLibraryMaterialRef, } from '@pascal-app/core' import { useEffect, useRef, useState } from 'react' +import { CURATED_COLORS } from '../../../lib/colors' import useEditor from '../../../store/use-editor' type MaterialPickerProps = { @@ -55,7 +56,8 @@ export function MaterialPicker({ return } - const catalogId = getLibraryMaterialIdFromRef(selectedMaterialPreset) ?? value?.id ?? undefined + const catalogId = + getLibraryMaterialIdFromRef(selectedMaterialPreset) ?? value?.id ?? undefined const selectedCatalogEntry = getCatalogMaterialById(catalogId) if (selectedCatalogEntry?.category) { setSelectedCategory(selectedCatalogEntry.category) @@ -64,6 +66,9 @@ export function MaterialPicker({ const selectedCatalogId = selectedMaterialPreset ?? (value?.id ? toLibraryMaterialRef(value.id) : undefined) + const selectedCatalogMaterialId = getLibraryMaterialIdFromRef(selectedCatalogId) ?? undefined + const selectedCatalogEntry = getCatalogMaterialById(selectedCatalogMaterialId) + const selectedColor = value?.properties?.color.toLowerCase() const handleCatalogSelect = (materialId: string) => { if (disabled) return @@ -72,6 +77,23 @@ export function MaterialPicker({ onSelectMaterialPreset?.(toLibraryMaterialRef(materialId)) } + const handleColorSelect = (hex: string) => { + if (disabled) return + setShowCustom(false) + setPaintPanelOpen(false) + onChange?.({ + preset: 'custom', + properties: { + color: hex, + roughness: 0.6, + metalness: 0, + opacity: 1, + transparent: false, + side: 'front', + }, + }) + } + useEffect(() => { const container = categoryScrollRef.current if (!container) return @@ -97,10 +119,13 @@ export function MaterialPicker({ if (disabled) return setShowCustom(true) setPaintPanelOpen(true) + const forkColor = selectedMaterialPreset + ? (selectedCatalogEntry?.previewColor ?? '#ffffff') + : '#ffffff' onChange?.({ preset: 'custom', properties: { - color: value?.properties?.color || '#ffffff', + color: value?.properties?.color || forkColor, roughness: value?.properties?.roughness ?? 0.5, metalness: value?.properties?.metalness ?? 0, opacity: value?.properties?.opacity ?? 1, @@ -114,6 +139,33 @@ export function MaterialPicker({
{(catalogItems.length > 0 || onChange) && (
+ {onChange ? ( +
+
+ Colors +
+
+ {CURATED_COLORS.map((color) => { + const isSelected = selectedColor === color.hex.toLowerCase() + return ( +
+
+ ) : null}
{catalogItems.map((item) => ( - - ))} - {selectedCategory === 'other' && onChange ? ( - - ) : null} + + ))} + {selectedCategory === 'other' && onChange ? ( + + ) : null}
)} diff --git a/packages/editor/src/components/ui/controls/material-properties-editor.tsx b/packages/editor/src/components/ui/controls/material-properties-editor.tsx new file mode 100644 index 00000000..f4e54e76 --- /dev/null +++ b/packages/editor/src/components/ui/controls/material-properties-editor.tsx @@ -0,0 +1,140 @@ +'use client' + +import type { MaterialProperties, MaterialSchema } from '@pascal-app/core' +import { Input } from '../primitives/input' + +const DEFAULT_MATERIAL_PROPERTIES: MaterialProperties = { + color: '#ffffff', + roughness: 0.5, + metalness: 0, + opacity: 1, + transparent: false, + side: 'front', +} + +export function MaterialPropertiesEditor({ + value, + onChange, +}: { + value: MaterialSchema + onChange: (next: MaterialSchema) => void +}) { + const currentProps = value.properties ?? DEFAULT_MATERIAL_PROPERTIES + + const updateMaterial = ( + updates: Partial, + nextTransparent = currentProps.transparent, + ) => { + onChange({ + ...value, + preset: value.preset ?? 'custom', + properties: { + ...currentProps, + ...updates, + transparent: nextTransparent, + }, + }) + } + + return ( +
+
+ +
+ updateMaterial({ color: e.target.value })} + type="color" + value={currentProps.color} + /> + updateMaterial({ color: e.target.value })} + value={currentProps.color} + /> +
+
+ +
+
+ + + {currentProps.roughness.toFixed(2)} + +
+ updateMaterial({ roughness: Number.parseFloat(e.target.value) })} + step={0.01} + type="range" + value={currentProps.roughness} + /> +
+ +
+
+ + + {currentProps.metalness.toFixed(2)} + +
+ updateMaterial({ metalness: Number.parseFloat(e.target.value) })} + step={0.01} + type="range" + value={currentProps.metalness} + /> +
+ +
+
+ + + {currentProps.opacity.toFixed(2)} + +
+ { + const opacity = Number.parseFloat(e.target.value) + updateMaterial({ opacity }, opacity < 1 || currentProps.transparent) + }} + step={0.01} + type="range" + value={currentProps.opacity} + /> +
+ +
+ + +
+
+ ) +} diff --git a/packages/editor/src/components/ui/controls/scene-material-list.tsx b/packages/editor/src/components/ui/controls/scene-material-list.tsx new file mode 100644 index 00000000..e153c22b --- /dev/null +++ b/packages/editor/src/components/ui/controls/scene-material-list.tsx @@ -0,0 +1,218 @@ +'use client' + +import { + generateSceneMaterialId, + type MaterialSchema, + type SceneMaterial, + type SceneMaterialId, + toSceneMaterialRef, + useScene, +} from '@pascal-app/core' +import { Copy, Paintbrush, Pencil, Trash2 } from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' +import useEditor from '../../../store/use-editor' +import { Button } from '../primitives/button' +import { Input } from '../primitives/input' +import { MaterialPropertiesEditor } from './material-properties-editor' + +type SlotRecord = Record + +function getSlotRecord(node: unknown): SlotRecord | null { + if (!node || typeof node !== 'object' || !('slots' in node)) return null + const slots = (node as { slots?: unknown }).slots + if (!slots || typeof slots !== 'object' || Array.isArray(slots)) return null + return slots as SlotRecord +} + +export function SceneMaterialList() { + const materials = useScene((state) => state.materials) + const nodes = useScene((state) => state.nodes) + const addSceneMaterial = useScene((state) => state.addSceneMaterial) + const updateSceneMaterial = useScene((state) => state.updateSceneMaterial) + const removeSceneMaterial = useScene((state) => state.removeSceneMaterial) + const activePaintTarget = useEditor((state) => state.activePaintTarget) + const setActivePaintMaterial = useEditor((state) => state.setActivePaintMaterial) + + const materialEntries = useMemo( + () => Object.entries(materials) as [SceneMaterialId, SceneMaterial][], + [materials], + ) + + const usageCounts = useMemo(() => { + const counts = new Map() + const refToId = new Map() + + for (const [id] of materialEntries) { + counts.set(id, 0) + refToId.set(toSceneMaterialRef(id), id) + } + + for (const node of Object.values(nodes)) { + const slots = getSlotRecord(node) + if (!slots) continue + + for (const value of Object.values(slots)) { + if (typeof value !== 'string') continue + const materialId = refToId.get(value) + if (!materialId) continue + counts.set(materialId, (counts.get(materialId) ?? 0) + 1) + } + } + + return counts + }, [materialEntries, nodes]) + + return ( +
+ {materialEntries.map(([id, sceneMaterial]) => ( + + ))} +
+ ) +} + +function SceneMaterialRow({ + id, + sceneMaterial, + usageCount, + activePaintTarget, + addSceneMaterial, + updateSceneMaterial, + removeSceneMaterial, + setActivePaintMaterial, +}: { + id: SceneMaterialId + sceneMaterial: SceneMaterial + usageCount: number + activePaintTarget: ReturnType['activePaintTarget'] + addSceneMaterial: ReturnType['addSceneMaterial'] + updateSceneMaterial: ReturnType['updateSceneMaterial'] + removeSceneMaterial: ReturnType['removeSceneMaterial'] + setActivePaintMaterial: ReturnType['setActivePaintMaterial'] +}) { + const [isEditingMaterial, setIsEditingMaterial] = useState(false) + const [draftName, setDraftName] = useState(sceneMaterial.name) + const swatchColor = sceneMaterial.material.properties?.color ?? '#ffffff' + + useEffect(() => { + setDraftName(sceneMaterial.name) + }, [sceneMaterial.name]) + + const commitName = () => { + const nextName = draftName.trim() + if (!nextName) { + setDraftName(sceneMaterial.name) + return + } + if (nextName !== sceneMaterial.name) { + updateSceneMaterial(id, { name: nextName }) + } + } + + const duplicateMaterial = () => { + addSceneMaterial({ + id: generateSceneMaterialId(), + name: `${sceneMaterial.name} copy`, + material: structuredClone(sceneMaterial.material) as MaterialSchema, + }) + } + + return ( +
+
+ + setDraftName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.currentTarget.blur() + } + if (e.key === 'Escape') { + setDraftName(sceneMaterial.name) + e.currentTarget.blur() + } + }} + value={draftName} + /> +
+ +
+ + Used by {usageCount} {usageCount === 1 ? 'part' : 'parts'} + +
+ + + + +
+
+ + {isEditingMaterial ? ( +
+ updateSceneMaterial(id, { material })} + value={sceneMaterial.material} + /> +
+ ) : null} +
+ ) +} diff --git a/packages/editor/src/components/ui/panels/paint-panel.tsx b/packages/editor/src/components/ui/panels/paint-panel.tsx index c11a26a0..cc097e76 100644 --- a/packages/editor/src/components/ui/panels/paint-panel.tsx +++ b/packages/editor/src/components/ui/panels/paint-panel.tsx @@ -1,24 +1,10 @@ 'use client' import useEditor from '../../../store/use-editor' +import { MaterialPropertiesEditor } from '../controls/material-properties-editor' import { PanelSection } from '../controls/panel-section' -import { Input } from '../primitives/input' import { PanelWrapper } from './panel-wrapper' -function buildDefaultCustomMaterial() { - return { - preset: 'custom' as const, - properties: { - color: '#ffffff', - roughness: 0.5, - metalness: 0, - opacity: 1, - transparent: false, - side: 'front' as const, - }, - } -} - export function PaintPanel() { const activePaintMaterial = useEditor((state) => state.activePaintMaterial) const activePaintTarget = useEditor((state) => state.activePaintTarget) @@ -32,131 +18,18 @@ export function PaintPanel() { if (!customMaterial) return null - const currentProps = customMaterial.properties ?? buildDefaultCustomMaterial().properties - - const updateCustomMaterial = ( - updates: Partial, - nextTransparent = currentProps.transparent, - ) => { - setActivePaintMaterial({ - material: { - preset: 'custom', - properties: { - ...currentProps, - ...updates, - transparent: nextTransparent, - }, - }, - sourceTarget: activePaintMaterial?.sourceTarget ?? activePaintTarget, - }) - } - return ( setPaintPanelOpen(false)} title="Material" width={320}> - -
-
- -
- updateCustomMaterial({ color: e.target.value })} - type="color" - value={currentProps.color} - /> - updateCustomMaterial({ color: e.target.value })} - value={currentProps.color} - /> -
-
- -
-
- - - {currentProps.roughness.toFixed(2)} - -
- - updateCustomMaterial({ roughness: Number.parseFloat(e.target.value) }) - } - step={0.01} - type="range" - value={currentProps.roughness} - /> -
- -
-
- - - {currentProps.metalness.toFixed(2)} - -
- - updateCustomMaterial({ metalness: Number.parseFloat(e.target.value) }) - } - step={0.01} - type="range" - value={currentProps.metalness} - /> -
- -
-
- - - {currentProps.opacity.toFixed(2)} - -
- { - const opacity = Number.parseFloat(e.target.value) - updateCustomMaterial({ opacity }, opacity < 1 || currentProps.transparent) - }} - step={0.01} - type="range" - value={currentProps.opacity} - /> -
- -
- - -
-
+ + + setActivePaintMaterial({ + material, + sourceTarget: activePaintMaterial?.sourceTarget ?? activePaintTarget, + }) + } + value={customMaterial} + />
) diff --git a/packages/editor/src/lib/colors.ts b/packages/editor/src/lib/colors.ts new file mode 100644 index 00000000..9ab5bcce --- /dev/null +++ b/packages/editor/src/lib/colors.ts @@ -0,0 +1,16 @@ +export const CURATED_COLORS = [ + { name: 'Warm white', hex: '#f5f1e8' }, + { name: 'Soft linen', hex: '#e9ddcf' }, + { name: 'Stone', hex: '#c8c2b8' }, + { name: 'Clay beige', hex: '#b9a58f' }, + { name: 'Greige', hex: '#9d9488' }, + { name: 'Charcoal', hex: '#3c3c3a' }, + { name: 'Mushroom', hex: '#a38f7b' }, + { name: 'Terracotta', hex: '#b7654b' }, + { name: 'Muted ochre', hex: '#c29b52' }, + { name: 'Sage', hex: '#8d9b82' }, + { name: 'Olive gray', hex: '#68715f' }, + { name: 'Dusty blue', hex: '#7d91a3' }, + { name: 'Slate teal', hex: '#4f7372' }, + { name: 'Aubergine', hex: '#594354' }, +] as const