feat(materials): dynamic library registry, picker source tabs, Other category (#525)

* 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 <noreply@anthropic.com>

* fix(materials): underline source tabs in picker, matching catalog browse surfaces

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(materials): add 'other' category for uncategorized library materials

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(viewer): rebuild node material when a texture slot is cleared

Reused cached WebGPU materials keep a compiled TextureNode per slot; nulling
the slot for a cold texture load (or a preset without the map) without
needsUpdate leaves the node's per-frame material reference pulling null,
crashing the render pass in TextureNode.update. Cold loads are the norm for
freshly generated library materials, whose maps aren't in the texture cache.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(viewer): standard materials always carry a displacement texture

three's WebGPU shadow pass copies each object's displacementMap onto one
shared per-light shadow material while caching per-mesh shadow node graphs
against that shared override. A mesh whose shadow graph was built with a
displacement TextureNode (painted with a generated material — the only
presets carrying height maps) crashes the render pass with "null (reading
'matrix')" the moment a material without a displacement texture is swapped
onto it, which is exactly what the paint hover preview does. Give every
MeshStandardNodeMaterial a shared 1x1 black displacement texel (zero offset)
when it has no real height map, so the copied slot is never null in either
direction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-07-21 10:00:48 -04:00
committed by GitHub
co-authored by Claude Fable 5
parent bf89b5bcf2
commit 603f5d2242
6 changed files with 234 additions and 26 deletions
+6
View File
@@ -157,7 +157,9 @@ export {
} from './lib/zone-quantities' } from './lib/zone-quantities'
export { export {
getCatalogMaterialById, getCatalogMaterialById,
getDynamicLibraryMaterials,
getLibraryMaterialIdFromRef, getLibraryMaterialIdFromRef,
getLibraryMaterialsVersion,
getMaterialPresetByRef, getMaterialPresetByRef,
getMaterialsForCategory, getMaterialsForCategory,
getSceneMaterialIdFromRef, getSceneMaterialIdFromRef,
@@ -168,12 +170,16 @@ export {
type MaterialCatalogItem, type MaterialCatalogItem,
type MaterialCategory, type MaterialCategory,
type MaterialRef, type MaterialRef,
type MaterialSource,
type MaterialSurface, type MaterialSurface,
type ParsedMaterialRef, type ParsedMaterialRef,
parseMaterialRef, parseMaterialRef,
registerLibraryMaterials,
SCENE_MATERIAL_REF_PREFIX, SCENE_MATERIAL_REF_PREFIX,
subscribeLibraryMaterials,
toLibraryMaterialRef, toLibraryMaterialRef,
toSceneMaterialRef, toSceneMaterialRef,
unregisterLibraryMaterials,
} from './material-library' } from './material-library'
export type { export type {
FloorPlacedFootprint, FloorPlacedFootprint,
+58 -2
View File
@@ -4,10 +4,14 @@ import {
MaterialTarget as MaterialTargetSchema, MaterialTarget as MaterialTargetSchema,
} from './schema/material' } from './schema/material'
export type MaterialSource = 'pascal' | 'community' | 'mine' | 'workspace'
export type MaterialCatalogItem = { export type MaterialCatalogItem = {
id: string id: string
label: string label: string
category: MaterialCategory 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). * Where this finish is appropriate. Absent = universal (e.g. flat colors).
* The paint picker may filter by the slot being painted; v1 shows everything. * The paint picker may filter by the slot being painted; v1 shows everything.
@@ -69,6 +73,7 @@ export const MATERIAL_CATEGORIES = [
'roofing', 'roofing',
'ground', 'ground',
'glass', 'glass',
'other',
] as const ] as const
export type MaterialCategory = (typeof MATERIAL_CATEGORIES)[number] 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<string, MaterialCatalogItem>()
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[] { 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 { export function getCatalogMaterialById(id?: string): MaterialCatalogItem | undefined {
if (!id) return 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:' export const LIBRARY_MATERIAL_REF_PREFIX = 'library:'
@@ -27,7 +27,12 @@ import { SceneMaterialList } from './scene-material-list'
* fixed control/category header, a single scrolling catalog grid, and a fixed * fixed control/category header, a single scrolling catalog grid, and a fixed
* scene-material footer (always visible, with a `+` to add a custom material). * 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 activePaintMaterial = useEditor((state) => state.activePaintMaterial)
const activePaintTarget = useEditor((state) => state.activePaintTarget) const activePaintTarget = useEditor((state) => state.activePaintTarget)
const setActivePaintMaterial = useEditor((state) => state.setActivePaintMaterial) const setActivePaintMaterial = useEditor((state) => state.setActivePaintMaterial)
@@ -109,6 +114,7 @@ export function MaterialPaintPanel() {
{/* Scrolls: category tabs (fixed inside) + catalog grid (the scroll). */} {/* Scrolls: category tabs (fixed inside) + catalog grid (the scroll). */}
<div className="min-h-0 flex-1"> <div className="min-h-0 flex-1">
<MaterialPicker <MaterialPicker
onCreateMaterialRequest={onCreateMaterialRequest}
onSelectMaterialPreset={(materialPreset) => { onSelectMaterialPreset={(materialPreset) => {
setActivePaintMaterial({ materialPreset, sourceTarget: activePaintTarget }) setActivePaintMaterial({ materialPreset, sourceTarget: activePaintTarget })
}} }}
@@ -2,44 +2,82 @@
import { import {
getCatalogMaterialById, getCatalogMaterialById,
getDynamicLibraryMaterials,
getLibraryMaterialIdFromRef, getLibraryMaterialIdFromRef,
getLibraryMaterialsVersion,
getMaterialsForCategory, getMaterialsForCategory,
MATERIAL_CATEGORIES, MATERIAL_CATEGORIES,
type MaterialCatalogItem,
type MaterialSource,
type MaterialTarget, type MaterialTarget,
subscribeLibraryMaterials,
toLibraryMaterialRef, toLibraryMaterialRef,
} from '@pascal-app/core' } 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' import { triggerSFX } from '../../../lib/sfx-bus'
type MaterialPickerProps = { export type MaterialSourceFilter = 'all' | MaterialSource
export type MaterialPickerProps = {
selectedMaterialPreset?: string selectedMaterialPreset?: string
onSelectMaterialPreset?: (materialPreset: string) => void onSelectMaterialPreset?: (materialPreset: string) => void
disabled?: boolean disabled?: boolean
nodeType?: MaterialTarget nodeType?: MaterialTarget
hideSideControl?: boolean 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]) { function getCategoryLabel(category: (typeof MATERIAL_CATEGORIES)[number]) {
return category.charAt(0).toUpperCase() + category.slice(1) 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 * Catalog material picker: a fixed row of category tabs and a source filter row
* of swatches. Custom-material creation lives in the scene-material section * over a scrollable grid of swatches. Scene-material creation lives in the
* (the host's `+` action), not here, so it's available from any category. * scene-material section (the host's `+` action); `onCreateMaterialRequest` is
* the host's entry point for authoring a new *library* material.
*/ */
export function MaterialPicker({ export function MaterialPicker({
selectedMaterialPreset, selectedMaterialPreset,
onSelectMaterialPreset, onSelectMaterialPreset,
disabled = false, disabled = false,
onCreateMaterialRequest,
}: MaterialPickerProps) { }: MaterialPickerProps) {
const [selectedCategory, setSelectedCategory] = useState<(typeof MATERIAL_CATEGORIES)[number]>( const [selectedCategory, setSelectedCategory] = useState<(typeof MATERIAL_CATEGORIES)[number]>(
MATERIAL_CATEGORIES[0], MATERIAL_CATEGORIES[0],
) )
const [sourceFilter, setSourceFilter] = useState<MaterialSourceFilter>('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( const availableCategories = MATERIAL_CATEGORIES.filter(
(category) => getMaterialsForCategory(category).length > 0, (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 // Keep the visible category in sync with the externally-selected catalog
// material (a `scene:` ref matches no catalog entry, so the tab stays put). // material (a `scene:` ref matches no catalog entry, so the tab stays put).
@@ -72,7 +110,7 @@ export function MaterialPicker({
setSelectedCategory(category) setSelectedCategory(category)
// Auto-select the first material in the category so the brush is // Auto-select the first material in the category so the brush is
// immediately ready (and the swatch shows as selected). // 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) if (first) handleCatalogSelect(first.id)
}} }}
type="button" type="button"
@@ -81,11 +119,52 @@ export function MaterialPicker({
</button> </button>
))} ))}
</div> </div>
{/* Fixed source filter tabs — underline style, matching the catalog
browse surfaces (Items / Rooms / Build / Search) rather than the
pill-button category row above. */}
<div className="flex shrink-0 items-center gap-4 px-1">
{visibleSourceFilters.map((filter) => (
<button
className={`-mb-px border-b-2 px-0.5 py-1.5 font-medium text-xs transition-colors ${
sourceFilter === filter.id
? 'border-foreground text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
key={filter.id}
onClick={() => {
triggerSFX('sfx:menu-click')
setSourceFilter(filter.id)
}}
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
type="button"
>
{filter.label}
</button>
))}
</div>
{/* The only scrolling region. */} {/* The only scrolling region. */}
<div <div
className="subtle-scrollbar grid min-h-0 flex-1 auto-rows-min gap-2 overflow-y-auto pb-1" className="subtle-scrollbar grid min-h-0 flex-1 auto-rows-min gap-2 overflow-y-auto pb-1"
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(72px, 1fr))' }} style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(72px, 1fr))' }}
> >
{onCreateMaterialRequest ? (
<button
className="group relative flex flex-col gap-1.5 rounded-xl p-1.5 transition-colors hover:cursor-pointer hover:bg-sidebar-accent"
onClick={() => {
triggerSFX('sfx:menu-click')
onCreateMaterialRequest()
}}
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
type="button"
>
<div className="flex aspect-square w-full items-center justify-center rounded-lg border border-border/45 border-dashed">
<Plus className="size-5 text-muted-foreground group-hover:text-foreground" />
</div>
<span className="truncate px-0.5 text-left font-medium text-[11px] text-muted-foreground group-hover:text-foreground">
New material
</span>
</button>
) : null}
{catalogItems.map((item) => { {catalogItems.map((item) => {
const isSelected = selectedMaterialPreset === toLibraryMaterialRef(item.id) const isSelected = selectedMaterialPreset === toLibraryMaterialRef(item.id)
return ( return (
+9 -2
View File
@@ -207,8 +207,15 @@ export {
} from './components/ui/action-menu/view-toggles' } from './components/ui/action-menu/view-toggles'
export { useCommandPalette } from './components/ui/command-palette' export { useCommandPalette } from './components/ui/command-palette'
export { ActionButton, ActionGroup } from './components/ui/controls/action-button' export { ActionButton, ActionGroup } from './components/ui/controls/action-button'
export { MaterialPaintPanel } from './components/ui/controls/material-paint-panel' export {
export { MaterialPicker } from './components/ui/controls/material-picker' 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 { MetricControl } from './components/ui/controls/metric-control'
export { PanelSection } from './components/ui/controls/panel-section' export { PanelSection } from './components/ui/controls/panel-section'
export { SegmentedControl } from './components/ui/controls/segmented-control' export { SegmentedControl } from './components/ui/controls/segmented-control'
+68 -14
View File
@@ -286,6 +286,40 @@ function getPresetTexture(
return texture 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<T extends THREE.Material>(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( function createAssignedTexture(
source: THREE.Texture, source: THREE.Texture,
props: MaterialMapProperties, props: MaterialMapProperties,
@@ -360,7 +394,14 @@ function queueTextureAssignment(
const textureMaterial = material as TextureMaterial const textureMaterial = material as TextureMaterial
if (!path) { 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 return
} }
@@ -379,7 +420,15 @@ function queueTextureAssignment(
return 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) => { loadPresetTexture(path, props, slot).then((texture) => {
if (!texture) return if (!texture) return
@@ -496,8 +545,9 @@ export function createMaterialFromPreset(
return materialCache.get(cacheKey)! return materialCache.get(cacheKey)!
} }
const material = const material = ensureDisplacementFallback(
shading === 'solid' ? new MeshLambertNodeMaterial() : new MeshStandardNodeMaterial() shading === 'solid' ? new MeshLambertNodeMaterial() : new MeshStandardNodeMaterial(),
)
applyMaterialPresetToMaterials(material, preset) applyMaterialPresetToMaterials(material, preset)
maybeApplyGlassFresnel(material) maybeApplyGlassFresnel(material)
material.userData.__pascalCachedMaterial = true material.userData.__pascalCachedMaterial = true
@@ -541,14 +591,15 @@ export function createMaterial(
if (map) materialParams.map = map if (map) materialParams.map = map
const threeMaterial = const threeMaterial = ensureDisplacementFallback(
shading === 'solid' shading === 'solid'
? new MeshLambertNodeMaterial(materialParams) ? new MeshLambertNodeMaterial(materialParams)
: new MeshStandardNodeMaterial({ : new MeshStandardNodeMaterial({
...materialParams, ...materialParams,
roughness: props.roughness, roughness: props.roughness,
metalness: props.metalness, metalness: props.metalness,
}) }),
)
maybeApplyGlassFresnel(threeMaterial) maybeApplyGlassFresnel(threeMaterial)
threeMaterial.userData.__pascalCachedMaterial = true threeMaterial.userData.__pascalCachedMaterial = true
@@ -608,12 +659,14 @@ export function createDefaultMaterial(
}) })
} }
return new MeshStandardNodeMaterial({ return ensureDisplacementFallback(
color, new MeshStandardNodeMaterial({
roughness, color,
metalness: 0, roughness,
side: resolvedSide, metalness: 0,
}) side: resolvedSide,
}),
)
} }
function cachedDefaultMaterial( function cachedDefaultMaterial(
@@ -704,14 +757,15 @@ export function DEFAULT_WINDOW_MATERIAL(shading: RenderShading = 'rendered'): TH
transparent: true, transparent: true,
side: THREE.FrontSide, side: THREE.FrontSide,
} }
const material = const material = ensureDisplacementFallback(
shading === 'solid' shading === 'solid'
? new MeshLambertNodeMaterial(params) ? new MeshLambertNodeMaterial(params)
: new MeshStandardNodeMaterial({ : new MeshStandardNodeMaterial({
...params, ...params,
roughness: 0.1, roughness: 0.1,
metalness: 0.1, metalness: 0.1,
}) }),
)
maybeApplyGlassFresnel(material) maybeApplyGlassFresnel(material)
defaultMaterialCache.set(cacheKey, material) defaultMaterialCache.set(cacheKey, material)
return material return material