From 1007b4eb1c0aa95e4dff4a75bed51e394b060b4a Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Mon, 20 Jul 2026 14:20:13 -0400 Subject: [PATCH 1/5] feat(materials): dynamic library registry, picker source tabs, create-material entry point Core gains a runtime material registry (registerLibraryMaterials/ unregisterLibraryMaterials/subscribeLibraryMaterials) so embedders can feed user/community materials; library: refs to registered materials resolve in the viewer unchanged. MaterialCatalogItem carries an optional source (pascal|community|mine|workspace). MaterialPicker gets a source filter row and an optional onCreateMaterialRequest '+ New material' tile, threaded through MaterialPaintPanel. Co-Authored-By: Claude Fable 5 --- packages/core/src/index.ts | 6 ++ packages/core/src/material-library.ts | 59 ++++++++++++- .../ui/controls/material-paint-panel.tsx | 8 +- .../ui/controls/material-picker.tsx | 87 +++++++++++++++++-- packages/editor/src/index.tsx | 11 ++- 5 files changed, 159 insertions(+), 12 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1147e04c1..618047068 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -146,7 +146,9 @@ export { } from './lib/zone-quantities' export { getCatalogMaterialById, + getDynamicLibraryMaterials, getLibraryMaterialIdFromRef, + getLibraryMaterialsVersion, getMaterialPresetByRef, getMaterialsForCategory, getSceneMaterialIdFromRef, @@ -157,12 +159,16 @@ export { type MaterialCatalogItem, type MaterialCategory, type MaterialRef, + type MaterialSource, type MaterialSurface, type ParsedMaterialRef, parseMaterialRef, + registerLibraryMaterials, SCENE_MATERIAL_REF_PREFIX, + subscribeLibraryMaterials, toLibraryMaterialRef, toSceneMaterialRef, + unregisterLibraryMaterials, } from './material-library' export type { FloorPlacedFootprint, diff --git a/packages/core/src/material-library.ts b/packages/core/src/material-library.ts index e45a48875..c2902d14e 100644 --- a/packages/core/src/material-library.ts +++ b/packages/core/src/material-library.ts @@ -4,10 +4,14 @@ import { MaterialTarget as MaterialTargetSchema, } from './schema/material' +export type MaterialSource = 'pascal' | 'community' | 'mine' | 'workspace' + export type MaterialCatalogItem = { id: string label: string category: MaterialCategory + /** Origin of the entry. Absent = 'pascal' (all static catalog entries). */ + source?: MaterialSource /** * Where this finish is appropriate. Absent = universal (e.g. flat colors). * The paint picker may filter by the slot being painted; v1 shows everything. @@ -4149,13 +4153,64 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [ }, ] +const STATIC_CATALOG_IDS = new Set(MATERIAL_CATALOG.map((item) => item.id)) + +// Embedder-registered library materials (user/community/workspace). Core stays +// passive: hosts push entries in; nothing here fetches. Static catalog entries +// win on id collision so a registration can never shadow a built-in. +const dynamicLibraryMaterials = new Map() +const dynamicLibraryListeners = new Set<() => void>() +let dynamicLibraryVersion = 0 + +function notifyDynamicLibraryChange(): void { + dynamicLibraryVersion += 1 + for (const listener of [...dynamicLibraryListeners]) { + listener() + } +} + +export function registerLibraryMaterials(items: MaterialCatalogItem[]): void { + if (items.length === 0) return + for (const item of items) { + dynamicLibraryMaterials.set(item.id, item) + } + notifyDynamicLibraryChange() +} + +export function unregisterLibraryMaterials(ids: string[]): void { + let changed = false + for (const id of ids) { + changed = dynamicLibraryMaterials.delete(id) || changed + } + if (changed) notifyDynamicLibraryChange() +} + +export function getDynamicLibraryMaterials(): MaterialCatalogItem[] { + return [...dynamicLibraryMaterials.values()] +} + +export function subscribeLibraryMaterials(listener: () => void): () => void { + dynamicLibraryListeners.add(listener) + return () => { + dynamicLibraryListeners.delete(listener) + } +} + +export function getLibraryMaterialsVersion(): number { + return dynamicLibraryVersion +} + export function getMaterialsForCategory(category: MaterialCategory): MaterialCatalogItem[] { - return MATERIAL_CATALOG.filter((item) => item.category === category) + const items = MATERIAL_CATALOG.filter((item) => item.category === category) + for (const item of dynamicLibraryMaterials.values()) { + if (item.category === category && !STATIC_CATALOG_IDS.has(item.id)) items.push(item) + } + return items } export function getCatalogMaterialById(id?: string): MaterialCatalogItem | undefined { if (!id) return undefined - return MATERIAL_CATALOG.find((item) => item.id === id) + return MATERIAL_CATALOG.find((item) => item.id === id) ?? dynamicLibraryMaterials.get(id) } export const LIBRARY_MATERIAL_REF_PREFIX = 'library:' 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 4abae9b62..922a69a84 100644 --- a/packages/editor/src/components/ui/controls/material-paint-panel.tsx +++ b/packages/editor/src/components/ui/controls/material-paint-panel.tsx @@ -27,7 +27,12 @@ import { SceneMaterialList } from './scene-material-list' * 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() { +export type MaterialPaintPanelProps = { + /** When provided, the catalog grid leads with a "New material" tile that invokes it. */ + onCreateMaterialRequest?: () => void +} + +export function MaterialPaintPanel({ onCreateMaterialRequest }: MaterialPaintPanelProps) { const activePaintMaterial = useEditor((state) => state.activePaintMaterial) const activePaintTarget = useEditor((state) => state.activePaintTarget) const setActivePaintMaterial = useEditor((state) => state.setActivePaintMaterial) @@ -109,6 +114,7 @@ export function MaterialPaintPanel() { {/* Scrolls: category tabs (fixed inside) + catalog grid (the scroll). */}
{ setActivePaintMaterial({ materialPreset, sourceTarget: activePaintTarget }) }} diff --git a/packages/editor/src/components/ui/controls/material-picker.tsx b/packages/editor/src/components/ui/controls/material-picker.tsx index e0284a1a4..b40b9233c 100644 --- a/packages/editor/src/components/ui/controls/material-picker.tsx +++ b/packages/editor/src/components/ui/controls/material-picker.tsx @@ -2,44 +2,82 @@ import { getCatalogMaterialById, + getDynamicLibraryMaterials, getLibraryMaterialIdFromRef, + getLibraryMaterialsVersion, getMaterialsForCategory, MATERIAL_CATEGORIES, + type MaterialCatalogItem, + type MaterialSource, type MaterialTarget, + subscribeLibraryMaterials, toLibraryMaterialRef, } from '@pascal-app/core' -import { useEffect, useState } from 'react' +import { Plus } from 'lucide-react' +import { useEffect, useMemo, useState, useSyncExternalStore } from 'react' import { triggerSFX } from '../../../lib/sfx-bus' -type MaterialPickerProps = { +export type MaterialSourceFilter = 'all' | MaterialSource + +export type MaterialPickerProps = { selectedMaterialPreset?: string onSelectMaterialPreset?: (materialPreset: string) => void disabled?: boolean nodeType?: MaterialTarget hideSideControl?: boolean + onCreateMaterialRequest?: () => void } +const SOURCE_FILTERS: { id: MaterialSourceFilter; label: string }[] = [ + { id: 'all', label: 'All' }, + { id: 'pascal', label: 'Pascal' }, + { id: 'community', label: 'Community' }, + { id: 'mine', label: 'Mine' }, + { id: 'workspace', label: 'Workspace' }, +] + function getCategoryLabel(category: (typeof MATERIAL_CATEGORIES)[number]) { return category.charAt(0).toUpperCase() + category.slice(1) } +function filterBySource(items: MaterialCatalogItem[], filter: MaterialSourceFilter) { + if (filter === 'all') return items + return items.filter((item) => (item.source ?? 'pascal') === filter) +} + /** - * 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. + * Catalog material picker: a fixed row of category tabs and a source filter row + * over a scrollable grid of swatches. Scene-material creation lives in the + * scene-material section (the host's `+` action); `onCreateMaterialRequest` is + * the host's entry point for authoring a new *library* material. */ export function MaterialPicker({ selectedMaterialPreset, onSelectMaterialPreset, disabled = false, + onCreateMaterialRequest, }: MaterialPickerProps) { const [selectedCategory, setSelectedCategory] = useState<(typeof MATERIAL_CATEGORIES)[number]>( MATERIAL_CATEGORIES[0], ) + const [sourceFilter, setSourceFilter] = useState('all') + // Version counter so host registrations/unregistrations re-render the picker. + const libraryVersion = useSyncExternalStore( + subscribeLibraryMaterials, + getLibraryMaterialsVersion, + getLibraryMaterialsVersion, + ) + const hasWorkspaceMaterials = useMemo( + () => getDynamicLibraryMaterials().some((item) => item.source === 'workspace'), + [libraryVersion], + ) + const visibleSourceFilters = SOURCE_FILTERS.filter( + (filter) => filter.id !== 'workspace' || hasWorkspaceMaterials, + ) const availableCategories = MATERIAL_CATEGORIES.filter( (category) => getMaterialsForCategory(category).length > 0, ) - const catalogItems = getMaterialsForCategory(selectedCategory) + const catalogItems = filterBySource(getMaterialsForCategory(selectedCategory), sourceFilter) // Keep the visible category in sync with the externally-selected catalog // material (a `scene:` ref matches no catalog entry, so the tab stays put). @@ -72,7 +110,7 @@ export function MaterialPicker({ setSelectedCategory(category) // Auto-select the first material in the category so the brush is // immediately ready (and the swatch shows as selected). - const first = getMaterialsForCategory(category)[0] + const first = filterBySource(getMaterialsForCategory(category), sourceFilter)[0] if (first) handleCatalogSelect(first.id) }} type="button" @@ -81,11 +119,46 @@ export function MaterialPicker({ ))}
+ {/* Fixed source filter tabs. */} +
+ {visibleSourceFilters.map((filter) => ( + + ))} +
{/* The only scrolling region. */}
+ {onCreateMaterialRequest ? ( + + ) : null} {catalogItems.map((item) => { const isSelected = selectedMaterialPreset === toLibraryMaterialRef(item.id) return ( diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index cc4eaaf2d..cd318fe01 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -200,8 +200,15 @@ export { } from './components/ui/action-menu/view-toggles' export { useCommandPalette } from './components/ui/command-palette' export { ActionButton, ActionGroup } from './components/ui/controls/action-button' -export { MaterialPaintPanel } from './components/ui/controls/material-paint-panel' -export { MaterialPicker } from './components/ui/controls/material-picker' +export { + MaterialPaintPanel, + type MaterialPaintPanelProps, +} from './components/ui/controls/material-paint-panel' +export { + MaterialPicker, + type MaterialPickerProps, + type MaterialSourceFilter, +} from './components/ui/controls/material-picker' export { MetricControl } from './components/ui/controls/metric-control' export { PanelSection } from './components/ui/controls/panel-section' export { SegmentedControl } from './components/ui/controls/segmented-control' From 5e8b63a16ea34e49c1d7fec7d174b2d70ac6f4de Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Mon, 20 Jul 2026 14:44:46 -0400 Subject: [PATCH 2/5] fix(materials): underline source tabs in picker, matching catalog browse surfaces Co-Authored-By: Claude Fable 5 --- .../ui/controls/material-picker.tsx | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/editor/src/components/ui/controls/material-picker.tsx b/packages/editor/src/components/ui/controls/material-picker.tsx index b40b9233c..328a63d21 100644 --- a/packages/editor/src/components/ui/controls/material-picker.tsx +++ b/packages/editor/src/components/ui/controls/material-picker.tsx @@ -31,9 +31,9 @@ export type MaterialPickerProps = { const SOURCE_FILTERS: { id: MaterialSourceFilter; label: string }[] = [ { id: 'all', label: 'All' }, { id: 'pascal', label: 'Pascal' }, - { id: 'community', label: 'Community' }, { id: 'mine', label: 'Mine' }, { id: 'workspace', label: 'Workspace' }, + { id: 'community', label: 'Community' }, ] function getCategoryLabel(category: (typeof MATERIAL_CATEGORIES)[number]) { @@ -119,17 +119,23 @@ export function MaterialPicker({ ))}
- {/* Fixed source filter tabs. */} -
+ {/* Fixed source filter tabs — underline style, matching the catalog + browse surfaces (Items / Rooms / Build / Search) rather than the + pill-button category row above. */} +
{visibleSourceFilters.map((filter) => (