feat(materials): dynamic library registry, picker source tabs, Other category#525
Conversation
…-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>
…wse surfaces Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ials Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| (category) => getMaterialsForCategory(category).length > 0, | ||
| ) | ||
| const catalogItems = getMaterialsForCategory(selectedCategory) | ||
| const catalogItems = filterBySource(getMaterialsForCategory(selectedCategory), sourceFilter) |
There was a problem hiding this comment.
Category tabs ignore source filter
Medium Severity
The material picker can show an empty grid and a broken UI state when the selected category or source filter no longer has any materials. This happens because availableCategories doesn't filter by the active sourceFilter, and selectedCategory or sourceFilter aren't reset when dynamic materials unregister, leaving the current selection invalid.
Reviewed by Cursor Bugbot for commit 2e419f6. Configure here.
| (category) => getMaterialsForCategory(category).length > 0, | ||
| ) | ||
| const catalogItems = getMaterialsForCategory(selectedCategory) | ||
| const catalogItems = filterBySource(getMaterialsForCategory(selectedCategory), sourceFilter) |
There was a problem hiding this comment.
Category sync ignores library updates
Medium Severity
The effect that syncs selectedCategory to the active catalog material only depends on selectedMaterialPreset, not libraryVersion. If that ref is already set before registerLibraryMaterials resolves the entry—or the entry reappears after unregister—getCatalogMaterialById runs too early, the effect never re-runs, and the picker stays on the wrong category so the selected swatch never comes into view.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 2e419f6. Configure here.
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>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 4 potential issues.
There are 6 total unresolved issues (including 2 from previous reviews).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 87d8d18. Configure here.
| (category) => getMaterialsForCategory(category).length > 0, | ||
| ) | ||
| const catalogItems = getMaterialsForCategory(selectedCategory) | ||
| const catalogItems = filterBySource(getMaterialsForCategory(selectedCategory), sourceFilter) |
There was a problem hiding this comment.
Brush survives material unregister
Medium Severity
Unregistering a library material leaves activePaintMaterial pointing at that library: ref. The picker re-renders from the registry subscription, but nothing clears the brush, so further painting can write dangling refs that resolveMaterialRef cannot resolve.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit 87d8d18. Configure here.
| for (const item of items) { | ||
| dynamicLibraryMaterials.set(item.id, item) | ||
| } | ||
| notifyDynamicLibraryChange() |
There was a problem hiding this comment.
Static id collision ghost tab
Low Severity
registerLibraryMaterials stores dynamic materials even when their IDs collide with static catalog items. While some getters, like getMaterialsForCategory, filter these collisions, getDynamicLibraryMaterials returns the unfiltered list. This can result in a source: 'workspace' material being registered but not displayed in the UI, potentially showing an empty Workspace tab.
Reviewed by Cursor Bugbot for commit 87d8d18. Configure here.
| for (const item of items) { | ||
| dynamicLibraryMaterials.set(item.id, item) | ||
| } | ||
| notifyDynamicLibraryChange() |
There was a problem hiding this comment.
Library refs cached as static
High Severity
registerLibraryMaterials can add or overwrite library: presets at runtime, but only the picker subscribes to version changes. Viewer wall/roof/fence caches still key library: slots by ref string alone, so late registration or same-id preset updates leave painted geometry on stale fallbacks or old textures.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 87d8d18. Configure here.
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>


Editor-side support for the hosted AI material generation pass (private-editor companion PR).
What's in here
@pascal-app/core): hosts canregisterLibraryMaterials/unregisterLibraryMaterialsat runtime so DB-backed generated materials resolve aslibrary:<id>refs in the viewer and paint tool, with a version counter + subscription for React integration.@pascal-app/editor): All / Pascal / Mine / Workspace / Community filter row in the catalog material picker, restyled to the underline tab convention used by the other browse surfaces (was pill buttons identical to the category row above it). Workspace tab only shows when workspace materials exist.onCreateMaterialRequesthook renders a "New material" tile so hosts can route into their authoring flow (the community studio Materials tab).othermaterial category: new fallback category so uncategorized generated materials get their own home instead of polluting a real category; category tabs only render when non-empty.Testing
library:refs.bun typecheckgreen in the consuming monorepo.🤖 Generated with Claude Code
Note
Medium Risk
Touches core material resolution, editor paint UX, and WebGPU material graph behavior—regressions could affect catalog display,
library:refs, or render stability when painting.Overview
Adds runtime host registration for catalog materials so generated or DB-backed finishes resolve as
library:<id>alongside the static catalog, with subscribe/version hooks for React and static IDs winning on collision. Catalog items gain optionalsource(pascal/ mine / workspace / community) and anothercategory for uncategorized generated materials.The material picker gains underline source filter tabs, optional New material via
onCreateMaterialRequest(wired throughMaterialPaintPanel), and re-renders when the dynamic library changes.The viewer fixes WebGPU crashes when swapping paint-preview materials by always giving
MeshStandardNodeMateriala neutral displacement placeholder and rebuilding the node graph when texture slots clear or cold-load instead of leaving nullTextureNodereferences.Reviewed by Cursor Bugbot for commit fd087fa. Bugbot is set up for automated code reviews on this repo. Configure here.