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..7bf2b7866 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. @@ -69,6 +73,7 @@ export const MATERIAL_CATEGORIES = [ 'roofing', 'ground', 'glass', + 'other', ] as const export type MaterialCategory = (typeof MATERIAL_CATEGORIES)[number] @@ -4149,13 +4154,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..328a63d21 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: 'mine', label: 'Mine' }, + { id: 'workspace', label: 'Workspace' }, + { id: 'community', label: 'Community' }, +] + 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,52 @@ export function MaterialPicker({ ))}
+ {/* 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) => ( + + ))} +
{/* 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' diff --git a/packages/viewer/src/lib/materials.ts b/packages/viewer/src/lib/materials.ts index 504e30344..aa1096496 100644 --- a/packages/viewer/src/lib/materials.ts +++ b/packages/viewer/src/lib/materials.ts @@ -286,6 +286,40 @@ function getPresetTexture( return texture } +// three's WebGPU shadow pass copies each object's base-material +// `displacementMap` onto one shared per-light shadow material, while the +// per-mesh shadow node graph is cached against that shared override. A mesh +// whose graph was built with a displacement TextureNode crashes the render +// pass ("Cannot read properties of null (reading 'matrix')") as soon as a +// material *without* a displacement texture is swapped onto it — the paint +// hover preview does exactly that. Standard node materials therefore always +// carry a displacement texture: a shared 1×1 black texel (zero offset, so +// shading is unchanged) whenever there is no real height map. +let neutralDisplacement: THREE.Texture | null = null + +function getNeutralDisplacementTexture(): THREE.Texture { + if (!neutralDisplacement) { + neutralDisplacement = new THREE.DataTexture(new Uint8Array([0, 0, 0, 255]), 1, 1) + neutralDisplacement.needsUpdate = true + } + return neutralDisplacement +} + +function ensureDisplacementFallback(material: T): T { + const textureMaterial = material as THREE.Material as TextureMaterial + if (material instanceof MeshStandardNodeMaterial && !textureMaterial.displacementMap) { + textureMaterial.displacementMap = getNeutralDisplacementTexture() + } + return material +} + +/** The value a cleared slot falls back to (never null for displacement). */ +function clearedSlotValue(material: CommonMaterial, slot: TextureSlot): THREE.Texture | null { + return slot === 'displacementMap' && material instanceof MeshStandardNodeMaterial + ? getNeutralDisplacementTexture() + : null +} + function createAssignedTexture( source: THREE.Texture, props: MaterialMapProperties, @@ -360,7 +394,14 @@ function queueTextureAssignment( const textureMaterial = material as TextureMaterial if (!path) { - textureMaterial[slot] = null + const cleared = clearedSlotValue(material, slot) + if (textureMaterial[slot] !== cleared) { + // Rebuild the node graph: a cached WebGPU material keeps a TextureNode + // for the slot, whose per-frame material reference would pull the null + // and crash in TextureNode.update ("null (reading 'matrix')"). + textureMaterial[slot] = cleared + material.needsUpdate = true + } return } @@ -379,7 +420,15 @@ function queueTextureAssignment( return } - textureMaterial[slot] = null + // Cold load: clear the slot for the fetch window, and rebuild the node + // graph if it previously held a texture — reused cached materials otherwise + // keep a TextureNode whose reference pulls the null and crashes the render + // pass. Cold loads are the norm for freshly generated library materials. + const placeholder = clearedSlotValue(material, slot) + if (textureMaterial[slot] !== placeholder) { + textureMaterial[slot] = placeholder + material.needsUpdate = true + } loadPresetTexture(path, props, slot).then((texture) => { if (!texture) return @@ -496,8 +545,9 @@ export function createMaterialFromPreset( return materialCache.get(cacheKey)! } - const material = - shading === 'solid' ? new MeshLambertNodeMaterial() : new MeshStandardNodeMaterial() + const material = ensureDisplacementFallback( + shading === 'solid' ? new MeshLambertNodeMaterial() : new MeshStandardNodeMaterial(), + ) applyMaterialPresetToMaterials(material, preset) maybeApplyGlassFresnel(material) material.userData.__pascalCachedMaterial = true @@ -541,14 +591,15 @@ export function createMaterial( if (map) materialParams.map = map - const threeMaterial = + const threeMaterial = ensureDisplacementFallback( shading === 'solid' ? new MeshLambertNodeMaterial(materialParams) : new MeshStandardNodeMaterial({ ...materialParams, roughness: props.roughness, metalness: props.metalness, - }) + }), + ) maybeApplyGlassFresnel(threeMaterial) threeMaterial.userData.__pascalCachedMaterial = true @@ -608,12 +659,14 @@ export function createDefaultMaterial( }) } - return new MeshStandardNodeMaterial({ - color, - roughness, - metalness: 0, - side: resolvedSide, - }) + return ensureDisplacementFallback( + new MeshStandardNodeMaterial({ + color, + roughness, + metalness: 0, + side: resolvedSide, + }), + ) } function cachedDefaultMaterial( @@ -704,14 +757,15 @@ export function DEFAULT_WINDOW_MATERIAL(shading: RenderShading = 'rendered'): TH transparent: true, side: THREE.FrontSide, } - const material = + const material = ensureDisplacementFallback( shading === 'solid' ? new MeshLambertNodeMaterial(params) : new MeshStandardNodeMaterial({ ...params, roughness: 0.1, metalness: 0.1, - }) + }), + ) maybeApplyGlassFresnel(material) defaultMaterialCache.set(cacheKey, material) return material