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:'
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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({
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user