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