feat(paint-slots): read pascal_material glTF extras as curated slot default

Insert the plan's middle resolution step for item slots: a curated
default baked into the GLB as a per-material 'pascal_material' extra
(three's GLTFLoader copies material extras to userData). Resolution per
slot is now: node.slots[slotId] override -> pascal_material curated
default -> authored GLB material. Bare ids resolve as library:<id>;
unresolved refs fall through. Runtime reads only the GLB (no DB).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-15 11:32:05 -04:00
co-authored by Claude Fable 5
parent e0179c480a
commit d52cbe6757
+28 -1
View File
@@ -8,6 +8,9 @@ import {
type Interactive, type Interactive,
type ItemNode, type ItemNode,
isSlotMaterialName, isSlotMaterialName,
LIBRARY_MATERIAL_REF_PREFIX,
SCENE_MATERIAL_REF_PREFIX,
toLibraryMaterialRef,
type LightEffect, type LightEffect,
useInteractive, useInteractive,
useLiveNodeOverrides, useLiveNodeOverrides,
@@ -50,12 +53,14 @@ type MutableMaterial = Material & {
type CapturedSingleItemMaterialData = { type CapturedSingleItemMaterialData = {
captured: true captured: true
authoredMaterials: Material authoredMaterials: Material
curatedRefs: string | undefined
slotIds: string | null slotIds: string | null
} }
type CapturedMultiItemMaterialData = { type CapturedMultiItemMaterialData = {
captured: true captured: true
authoredMaterials: Material[] authoredMaterials: Material[]
curatedRefs: (string | undefined)[]
slotIds: (string | null)[] slotIds: (string | null)[]
} }
@@ -71,6 +76,15 @@ type SceneMaterials = ReturnType<typeof useScene.getState>['materials']
const getAuthoredSlotId = (material: Material): string | null => const getAuthoredSlotId = (material: Material): string | null =>
isSlotMaterialName(material.name) ? deriveSlotId(material.name) : null isSlotMaterialName(material.name) ? deriveSlotId(material.name) : null
function curatedRefFromMaterial(material: Material): string | undefined {
const raw = (material.userData as { pascal_material?: unknown }).pascal_material
if (typeof raw !== 'string' || raw.length === 0) return undefined
if (raw.startsWith(LIBRARY_MATERIAL_REF_PREFIX) || raw.startsWith(SCENE_MATERIAL_REF_PREFIX)) {
return raw
}
return toLibraryMaterialRef(raw)
}
const captureItemMeshMaterials = (mesh: Mesh): CapturedItemMaterialData => { const captureItemMeshMaterials = (mesh: Mesh): CapturedItemMaterialData => {
const userData = mesh.userData as ItemMeshUserData const userData = mesh.userData as ItemMeshUserData
const captured = userData.pascalItemMaterialCapture const captured = userData.pascalItemMaterialCapture
@@ -82,9 +96,11 @@ const captureItemMeshMaterials = (mesh: Mesh): CapturedItemMaterialData => {
if (Array.isArray(mesh.material)) { if (Array.isArray(mesh.material)) {
const authoredMaterials = mesh.material.slice() const authoredMaterials = mesh.material.slice()
const slotIds = authoredMaterials.map(getAuthoredSlotId) const slotIds = authoredMaterials.map(getAuthoredSlotId)
const curatedRefs = authoredMaterials.map(curatedRefFromMaterial)
const next: CapturedItemMaterialData = { const next: CapturedItemMaterialData = {
captured: true, captured: true,
authoredMaterials, authoredMaterials,
curatedRefs,
slotIds, slotIds,
} }
userData.pascalItemMaterialCapture = next userData.pascalItemMaterialCapture = next
@@ -93,9 +109,11 @@ const captureItemMeshMaterials = (mesh: Mesh): CapturedItemMaterialData => {
} }
const slotId = getAuthoredSlotId(mesh.material) const slotId = getAuthoredSlotId(mesh.material)
const curatedRef = curatedRefFromMaterial(mesh.material)
const next: CapturedItemMaterialData = { const next: CapturedItemMaterialData = {
captured: true, captured: true,
authoredMaterials: mesh.material, authoredMaterials: mesh.material,
curatedRefs: curatedRef,
slotIds: slotId, slotIds: slotId,
} }
userData.pascalItemMaterialCapture = next userData.pascalItemMaterialCapture = next
@@ -134,6 +152,7 @@ const clampGeometryGroups = (mesh: Mesh, matCount: number): void => {
const resolveItemMaterial = ( const resolveItemMaterial = (
authoredMaterial: Material, authoredMaterial: Material,
slotId: string | null, slotId: string | null,
curatedRef: string | undefined,
{ {
colorPreset, colorPreset,
isAuthored, isAuthored,
@@ -155,6 +174,8 @@ const resolveItemMaterial = (
if (slotId != null) { if (slotId != null) {
const override = resolveMaterialRef(nodeSlots?.[slotId], sceneMaterials, shading) const override = resolveMaterialRef(nodeSlots?.[slotId], sceneMaterials, shading)
if (override) return override if (override) return override
const curated = resolveMaterialRef(curatedRef, sceneMaterials, shading)
if (curated) return curated
return authoredMaterial return authoredMaterial
} }
if (isAuthored) return authoredMaterial if (isAuthored) return authoredMaterial
@@ -342,7 +363,12 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
if (isCapturedMaterialArray(captured)) { if (isCapturedMaterialArray(captured)) {
const nextMaterials = captured.authoredMaterials.map((authoredMaterial, index) => const nextMaterials = captured.authoredMaterials.map((authoredMaterial, index) =>
resolveItemMaterial(authoredMaterial, captured.slotIds[index] ?? null, materialOptions), resolveItemMaterial(
authoredMaterial,
captured.slotIds[index] ?? null,
captured.curatedRefs[index],
materialOptions,
),
) )
mesh.material = nextMaterials mesh.material = nextMaterials
hasGlass = nextMaterials.some(isGlassMaterial) hasGlass = nextMaterials.some(isGlassMaterial)
@@ -351,6 +377,7 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
const nextMaterial = resolveItemMaterial( const nextMaterial = resolveItemMaterial(
captured.authoredMaterials, captured.authoredMaterials,
captured.slotIds, captured.slotIds,
captured.curatedRefs,
materialOptions, materialOptions,
) )
mesh.material = nextMaterial mesh.material = nextMaterial