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:
co-authored by
Claude Fable 5
parent
bf89b5bcf2
commit
603f5d2242
@@ -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,
|
||||
|
||||
@@ -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:'
|
||||
|
||||
Reference in New Issue
Block a user