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
+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