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'
export {
getCatalogMaterialById,
getDynamicLibraryMaterials,
getLibraryMaterialIdFromRef,
getLibraryMaterialsVersion,
getMaterialPresetByRef,
getMaterialsForCategory,
getSceneMaterialIdFromRef,
@@ -168,12 +170,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,
+58 -2
View File
@@ -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<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[] {
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:'
@@ -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). */}
<div className="min-h-0 flex-1">
<MaterialPicker
onCreateMaterialRequest={onCreateMaterialRequest}
onSelectMaterialPreset={(materialPreset) => {
setActivePaintMaterial({ materialPreset, sourceTarget: activePaintTarget })
}}
@@ -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<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(
(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({
</button>
))}
</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. */}
<div
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))' }}
>
{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) => {
const isSelected = selectedMaterialPreset === toLibraryMaterialRef(item.id)
return (
+9 -2
View File
@@ -207,8 +207,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'
+68 -14
View File
@@ -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<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(
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