From 81a95812fb6379000ca997460fc823f788b67ab1 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Wed, 16 Sep 2026 15:21:07 -0700 Subject: [PATCH] feat(editor): one Resources tab, and reference rows under each mesh The left panel keeps Objects first. Materials, Textures, and Geometries become one Resources tab with three collapsible sections and count badges, composed from the three existing trees. Files and Library stay in the bottom panel. Under a mesh, line, or points row the Objects tree now shows what the object references, built on expand: its geometry, its material or one row per material in an array, one row per assigned texture slot under each material, and one row per entity component. A reference row is icon only, italic, marked with a link glyph, not draggable, and has no context actions. Click selects the resource so the Inspector shows it. Double-click switches to Resources with that row highlighted and in view. Double-click on an asset root opens that asset's document through store.open, the same call as the Inspector's Edit Asset button. Preview-rig lights in asset documents render dimmed. Verification: npm run typecheck, npm run lint, and npm run build:editor exit 0. Headless walkthrough against a clone of the terminator project: tabs Objects and Resources; badges Materials 160, Textures 447, Geometries 402, 1009 rows; an Endo mesh lists its geometry, material, and the map, aoMap, normalMap, roughnessMap, and metalnessMap slots; a click puts the material in the Inspector; a double-click opens Resources with the row selected and in view. Screenshots under ~/games/terminator-evidence/docs/evidence/kite3d-resources-tab/. Co-Authored-By: Claude Fable 5.1 --- .../components/BPGeometriesTreeComponent.tsx | 26 +-- .../src/components/BPHierarchyComponent.tsx | 142 ++++++++++++++-- .../components/BPMaterialsTreeComponent.tsx | 67 ++------ .../components/BPResourcesTreeComponent.tsx | 156 ++++++++++++++++++ .../components/BPTexturesTreeComponent.tsx | 46 ++---- .../editor/src/components/BPTreeComponent.tsx | 13 ++ .../components/ObjectHierarchyComponent.tsx | 20 +++ .../src/components/ThreeEditorComponent.tsx | 23 ++- .../src/components/WindowPanesLayout.tsx | 17 +- packages/editor/src/components/tree2.scss | 18 ++ packages/editor/src/renderer.scss | 35 ++++ packages/editor/src/utils/icons.tsx | 18 +- packages/editor/src/utils/resourceReveal.ts | 33 ++++ .../src/utils/three/objectReferences.ts | 110 ++++++++++++ .../editor/src/utils/three/sceneResources.ts | 18 ++ .../editor/src/utils/three/selectResource.ts | 23 +++ 16 files changed, 648 insertions(+), 117 deletions(-) create mode 100644 packages/editor/src/components/BPResourcesTreeComponent.tsx create mode 100644 packages/editor/src/utils/resourceReveal.ts create mode 100644 packages/editor/src/utils/three/objectReferences.ts create mode 100644 packages/editor/src/utils/three/sceneResources.ts create mode 100644 packages/editor/src/utils/three/selectResource.ts diff --git a/packages/editor/src/components/BPGeometriesTreeComponent.tsx b/packages/editor/src/components/BPGeometriesTreeComponent.tsx index 714882c5..75a0d22b 100644 --- a/packages/editor/src/components/BPGeometriesTreeComponent.tsx +++ b/packages/editor/src/components/BPGeometriesTreeComponent.tsx @@ -1,6 +1,8 @@ import {Event2, IGeometry, IObject3D, ISceneEventMap, PickingPlugin, ThreeViewer, UiObjectConfig} from "threepipe"; import {UiConfigRendererContextType} from 'uiconfig-blueprint/lib/esm/lib' -import {filterObjectsInSceneRoot} from "../utils/three/filterObjectsInSceneRoot.ts"; +import {sceneGeometries} from "../utils/three/sceneResources.ts"; +import {selectResource} from "../utils/three/selectResource.ts"; +import {geometryIcon} from "../utils/icons.tsx"; import React, {useMemo} from "react"; import {useObjContextMenu} from "./UseObjContextMenu.tsx"; import {HandleContextMenuCallback, MenuItem2} from "../utils/ContextMenuUtils.ts"; @@ -40,25 +42,19 @@ export class BPGeometriesTreeComponent extends // node.icon = 'full-circle' // } // todo set icon based on if its generated or not and its type? - node.icon = 'grid-view' + node.icon = geometryIcon return node; } protected _getRootNodes(): T[] { - const showAll = false // todo param in ui - const geoms = this.context.viewer.object3dManager.getGeometries() - if(showAll) - return Array.from(geoms) as T[] - const inRoot = filterObjectsInSceneRoot(geoms); - return Array.from(inRoot) as T[] // only return geometries that are in the root scene model root + return sceneGeometries(this.context.viewer) as T[] } protected async _onNodeClick(_id: string) { const node = this._infoMap.get(_id) if(!node) return const value = node.isSelected ? null : node.nodeData! // unselect if already selected - // node.nodeData!.dispatchEvent({type: 'select', value: value ?? null, material: node.nodeData!, ui: true, bubbleToObject: true, bubbleToParent: true}) - this.context.viewer.getPlugin(PickingPlugin)?.setSelectedObject(value) + selectResource(this.context.viewer, node.nodeData!, value) } protected async _onNodeDoubleClick(_id: string) { @@ -128,6 +124,13 @@ export class BPGeometriesTreeComponent extends console.error('BPGeometriesTreeComponent: viewer not found in context', this.context) return } + // A closed Resources section is not mounted, so it missed every selection event while it was + // shut. The tree reads what is picked now, or its row opens unhighlighted. + const picked = viewer.getPlugin(PickingPlugin)?.getSelectedObject() + if(picked?.isBufferGeometry) { + this._selectedIds = [picked.uuid] + this.setSelected(this._selectedIds, false) + } viewer.getPlugin(PickingPlugin)?.addEventListener('selectedObjectChanged', this.selectedObjectChanged) viewer.scene.addEventListener('sceneUpdate', this.sceneUpdate) // todo: subscribe only to the geometry in the config instead of the whole scene viewer.scene.addEventListener('geometryUpdate', this.geometryUpdate) // todo: subscribe only to the geometry in the config instead of the whole scene @@ -147,7 +150,7 @@ export class BPGeometriesTreeComponent extends } -export function GeometryHierarchyComponent({className}: {className: string}){ +export function GeometryHierarchyComponent({className, treeRef}: {className: string, treeRef?: React.Ref}){ const manager = useManager() const viewer = manager.get() @@ -160,5 +163,6 @@ export function GeometryHierarchyComponent({className}: {className: string}){ return } diff --git a/packages/editor/src/components/BPHierarchyComponent.tsx b/packages/editor/src/components/BPHierarchyComponent.tsx index e201d954..b3583ca4 100644 --- a/packages/editor/src/components/BPHierarchyComponent.tsx +++ b/packages/editor/src/components/BPHierarchyComponent.tsx @@ -1,5 +1,6 @@ import { Event2, + IMaterial, IObject3D, ISceneEventMap, ObjectPickerEventMap, @@ -12,14 +13,43 @@ import {VisibilityIcon} from "./VisibilityIcon"; import React from "react"; import {canMakeAsset, isExternalObject} from "../utils/projectUtils.ts"; import {HandleContextMenuCallback, MenuItem2} from "../utils/ContextMenuUtils.ts"; -import {Intent} from "@blueprintjs/core"; -import {BPTreeComponent} from "./BPTreeComponent.tsx"; +import {Icon, IconName, Intent} from "@blueprintjs/core"; +import {hasObjectReferences, ObjectReference, objectReferences} from "../utils/three/objectReferences.ts"; +import {SceneResource, selectResource} from "../utils/three/selectResource.ts"; +import {resourceReveal} from "../utils/resourceReveal.ts"; +import {componentIcon, geometryIcon, iconForMaterial, textureIcon} from "../utils/icons.tsx"; +import {BPTreeComponent, BPTreeComponentState} from "./BPTreeComponent.tsx"; import {TreeNodeInfo} from "./treeTypes.ts"; import {canDropNode, CanvasFileDropHandler, isDraggableDroppableNode} from "../utils/CanvasFileDropHandler.tsx"; import {uiConfigToMenuItem} from "../utils/ContextMenuUtils.ts"; import {EditModePlugin} from "../utils/EditModePlugin.ts"; interface BPHierarchyComponentPropsExtras extends HandleContextMenuCallback{ + /** + * A double click on an asset row opens that asset's own document. The wrapper resolves the path + * and answers true when it opened one, because then focusing the camera would be wasted work. + */ + onOpenAsset?: (object: IObject3D) => boolean +} + +const referenceKindIcons: Record, IconName> = { + geometry: geometryIcon, + texture: textureIcon, + component: componentIcon, +} + +/** + * A reference row wears the icon of what it points at, with a link glyph in front of it. The kind + * is never spelled out: the icon says it, and the tooltip on the label says the slot. + */ +function referenceIcon(reference: ObjectReference) { + const kindIcon = reference.kind === 'material' + ? iconForMaterial(reference.resource as IMaterial) ?? 'style' + : referenceKindIcons[reference.kind] + return + + {typeof kindIcon === 'string' ? : kindIcon} + } export class BPHierarchyComponent extends BPTreeComponent { declare context: UiConfigRendererContextType&{viewer: ThreeViewer} @@ -38,10 +68,17 @@ export class BPHierarchyComponent extends BPTre protected _updateNodeInfo(node: TreeNodeInfo, obj: T) { node.label = obj.name ? obj.name : obj.type ? `(${obj.type})` : 'unnamed'; + const children: TreeNodeInfo[] = [] if(!obj.isMesh && !obj.isLine && !obj.isPoints && !obj.isScene && !obj.isCamera && !obj.isLight) // todo _sChildren - node.childNodes = ((obj.children as T[]) || []).reduce((...args) => this.buildData(...args), []) + children.push(...((obj.children as T[]) || []).reduce((...args) => this.buildData(...args), [])) + // What the object points at goes under its real children, and only while the row is open: + // a closed row builds nothing. Every tree refresh rebuilds these rows with the rest. + if(node.isExpanded) children.push(...objectReferences(obj).map(r => this._referenceNode(r))) + node.childNodes = children node.isSelected = this._selectedId === node.id + // A preview light belongs to the document view, not to the file, so its row is dimmed. + node.className = obj.userData?.excludeFromExport ? 'tree-node-excluded' : undefined // node.hasCaret = (node.childNodes?.length||0) > 0 node.icon = undefined @@ -93,7 +130,7 @@ export class BPHierarchyComponent extends BPTre node.droppable = droppable node.draggable = draggable node.intent = isComponent ? Intent.WARNING : isExternal ? Intent.PRIMARY : Intent.NONE - node.hasCaret = !node.icon + node.hasCaret = !node.icon || hasObjectReferences(obj) } return node; @@ -108,7 +145,78 @@ export class BPHierarchyComponent extends BPTre // return (this.props.config.children || []).map(c => getOrCall(c) || {}).flat(2) } + /** + * The reference rows on the tree right now, by row id. A row that is not built is not here. + * The base class builds the first state from its constructor, before a field of this class is + * set, so every reader creates it if it has to, the way the base class does with its own map. + */ + private _refRows!: Map + + /** + * Turns one reference into a row. The row keeps its own open state across refreshes, the way an + * object row does, because the same node object is updated in place. + */ + private _referenceNode(reference: ObjectReference): TreeNodeInfo { + if (!this._refRows) this._refRows = new Map() + const node: TreeNodeInfo = this._infoMap.get(reference.id) ?? { + id: reference.id, + label: '', + childNodes: [], + isExpanded: false, + isSelected: false, + } + node.label = {reference.name} + node.icon = referenceIcon(reference) + node.className = 'tree-node-reference' + node.intent = Intent.NONE + node.isSelected = false // the row is a pointer; the resource highlights in the Resources tab + node.draggable = false // a reference cannot be moved, only followed + node.droppable = false + node.secondaryLabel = undefined + node.hasCaret = !!reference.children?.length + node.childNodes = node.isExpanded ? (reference.children ?? []).map(c => this._referenceNode(c)) : [] + this._infoMap.set(reference.id, node) + this._refRows.set(reference.id, reference) + this.nSet?.add(reference.id) + return node + } + + /** A reference row puts its resource in the Inspector, the same as the Resources tab does. */ + private _selectReference(reference: ObjectReference) { + if(reference.kind === 'component') { + // A component has no row of its own anywhere else: the Inspector shows it under its object. + const owner = reference.owner + owner.dispatchEvent({type: 'select', value: owner, object: owner, ui: true, bubbleToParent: true}) + return + } + const resource = reference.resource as SceneResource + selectResource(this.context.viewer, resource, resource) + } + + protected async _onNodeExpandCollapse(_id: string | number, expanded?: boolean) { + await super._onNodeExpandCollapse(_id, expanded) + const node = this._infoMap.get(_id) + if(!node?.isExpanded) return + // Opening a row is what builds the reference rows under it. + const reference = this._refRows?.get(_id) + const references = reference ? reference.children : node.nodeData ? objectReferences(node.nodeData) : undefined + if(!references?.length) return + const realChildren = (node.childNodes ?? []).filter(c => !this._refRows?.has(c.id)) + node.childNodes = [...realChildren, ...references.map(r => this._referenceNode(r))] + await this.setStatePromise({...this.state, nodes: this._cloneNodes()}) + } + + getUpdatedState(_state: BPTreeComponentState): BPTreeComponentState { + if (!this._refRows) this._refRows = new Map() + const state = super.getUpdatedState(_state) + // A row the rebuild did not keep is off the tree, so its reference goes with it. + for (const id of [...this._refRows.keys()]) if (!this._infoMap.has(id)) this._refRows.delete(id) + return state + } + protected async _onNodeClick(_id: string) { + const reference = this._refRows?.get(_id) + if(reference) return this._selectReference(reference) const node = this._infoMap.get(_id) if(!node) return const value = node.isSelected ? null : node.nodeData! // unselect if already selected @@ -116,16 +224,27 @@ export class BPHierarchyComponent extends BPTre } protected async _onNodeDoubleClick(_id: string) { + const reference = this._refRows?.get(_id) + if(reference) { + this._selectReference(reference) + // The second click follows the pointer: the Resources tab opens on that row. + if(reference.kind !== 'component') + resourceReveal.reveal({kind: reference.kind, uuid: reference.resource.uuid}) + return + } const node = this._infoMap.get(_id) if(!node) return - let isScene = node.nodeData! === this.context.viewer.scene as any - let obj = node.nodeData! - node.nodeData!.dispatchEvent({ + const obj = node.nodeData! + const isScene = obj === this.context.viewer.scene as any + // An asset row opens its own document, the way the Inspector's Edit Asset button does. + // Focusing the camera on a row whose document replaces the viewport would be wasted. + const opened = !isScene && (this.props.onOpenAsset?.(obj) ?? false) + obj.dispatchEvent({ type: 'select', - value: node.nodeData!, - object: node.nodeData!, + value: obj, + object: obj, ui: true, - focusCamera: !isScene, + focusCamera: !isScene && !opened, bubbleToParent: true, }) if(isScene){ @@ -138,6 +257,9 @@ export class BPHierarchyComponent extends BPTre _e.preventDefault() _e.stopPropagation() + // A reference row has no menu: nothing under it can be renamed, reordered or deleted. + if(this._refRows?.has(_id)) return this._onNodeClick(_id as string) + const items: MenuItem2[] = [] const node = this._infoMap.get(_id) if(!node) return diff --git a/packages/editor/src/components/BPMaterialsTreeComponent.tsx b/packages/editor/src/components/BPMaterialsTreeComponent.tsx index e08ea38a..fbf3960e 100644 --- a/packages/editor/src/components/BPMaterialsTreeComponent.tsx +++ b/packages/editor/src/components/BPMaterialsTreeComponent.tsx @@ -8,11 +8,10 @@ import { ThreeViewer, UiObjectConfig } from "threepipe"; -import { - bpUiConfigIcons, - UiConfigRendererContextType -} from 'uiconfig-blueprint/lib/esm/lib' -import {filterObjectsInSceneRoot} from "../utils/three/filterObjectsInSceneRoot.ts"; +import {UiConfigRendererContextType} from 'uiconfig-blueprint/lib/esm/lib' +import {sceneMaterials} from "../utils/three/sceneResources.ts"; +import {selectResource} from "../utils/three/selectResource.ts"; +import {iconForMaterial} from "../utils/icons.tsx"; import React, {useMemo} from "react"; import {useObjContextMenu} from "./UseObjContextMenu.tsx"; import {HandleContextMenuCallback, MenuItem2} from "../utils/ContextMenuUtils.ts"; @@ -46,61 +45,19 @@ export class BPMaterialsTreeComponent extends B // if(!obj.isMesh && !obj.isLine && !obj.isPoints && !obj.isScene && !obj.isCamera && !obj.isLight) // node.childNodes = ((obj.children as T[]) || []).reduce((...args) => this.buildData(...args), []) node.isSelected = this._selectedIds?.includes(node.id as string) ?? false - if(obj.isPhysicalMaterial){ - node.icon = bpUiConfigIcons['shape-sphere-filled-1']({style: {color: 'transparent'}, className: 'bp5-tree-node-icon-svg'}) - } - if(obj.isUnlitMaterial){ - node.icon = 'full-circle' - } + node.icon = iconForMaterial(obj) return node; } protected _getRootNodes(): T[] { - // const v = this.context.methods.getRawValue(this.props.config) - // const materials = new Set() - // v?.traverse((obj) => { - // if (obj.material) { - // const mats = Array.isArray(obj.material) ? obj.material : [obj.material] - // for (const mat of mats) { - // if (mat && mat.isMaterial) { - // materials.add(mat) - // } - // } - // } - // }) - // const uuidSet = new Set() - // // log materials with duplicate uuid - // materials.forEach((m) => { - // if(!m.uuid || uuidSet.has(m.uuid)) { - // console.warn('Materials: Material with duplicate or missing uuid, ignoring', m) - // materials.delete(m) - // }else if(m.uuid) - // uuidSet.add(m.uuid) - // }) - const showAll = false - const mats = - showAll ? - this.context.viewer.materialManager.getAllMaterials() || [] : - // only materials in scene - this.context.viewer.object3dManager.getMaterials() - - if(showAll) - return Array.from(mats) as T[] // todo as any - - const inRoot = filterObjectsInSceneRoot(mats); - return Array.from(inRoot) as T[] - - // return v?.children as any || [] // todo as any - // return this.context.viewer.materialManager.getAllMaterials() as any || [] // todo as any - // return getValue(this.props.config) - // return (this.props.config.children || []).map(c => getOrCall(c) || {}).flat(2) + return sceneMaterials(this.context.viewer) as T[] } protected async _onNodeClick(_id: string) { const node = this._infoMap.get(_id) if(!node) return const value = node.isSelected ? null : node.nodeData! // unselect if already selected - node.nodeData!.dispatchEvent({type: 'select', value: value ?? null, material: node.nodeData!, ui: true, bubbleToObject: true, bubbleToParent: true}) + selectResource(this.context.viewer, node.nodeData!, value) } protected async _onNodeDoubleClick(_id: string) { @@ -181,6 +138,13 @@ export class BPMaterialsTreeComponent extends B console.error('BPMaterialsTreeComponent: viewer not found in context', this.context) return } + // A closed Resources section is not mounted, so it missed every selection event while it was + // shut. The tree reads what is picked now, or its row opens unhighlighted. + const picked = viewer.getPlugin(PickingPlugin)?.getSelectedObject() + if(picked?.isMaterial) { + this._selectedIds = [picked.uuid] + this.setSelected(this._selectedIds, false) + } viewer.getPlugin(PickingPlugin)?.addEventListener('selectedObjectChanged', this.selectedObjectChanged) viewer.scene.addEventListener('sceneUpdate', this.sceneUpdate) // todo: subscribe only to the material in the config instead of the whole scene viewer.scene.addEventListener('materialUpdate', this.materialUpdate) // todo: subscribe only to the material in the config instead of the whole scene @@ -200,7 +164,7 @@ export class BPMaterialsTreeComponent extends B } -export function MaterialHierarchyComponent({className}: {className: string}){ +export function MaterialHierarchyComponent({className, treeRef}: {className: string, treeRef?: React.Ref}){ const {makeAsset} = useMakeAsset() const actions = {makeAsset: makeAsset} const {handleContextMenu} = useObjContextMenu(actions) @@ -214,5 +178,6 @@ export function MaterialHierarchyComponent({className}: {className: string}){ return } diff --git a/packages/editor/src/components/BPResourcesTreeComponent.tsx b/packages/editor/src/components/BPResourcesTreeComponent.tsx new file mode 100644 index 00000000..4e58fce2 --- /dev/null +++ b/packages/editor/src/components/BPResourcesTreeComponent.tsx @@ -0,0 +1,156 @@ +import React, {useCallback, useEffect, useRef, useState} from "react"; +import {Icon, Tag} from "@blueprintjs/core"; +import {ThreeViewer} from "threepipe"; +import {BPMaterialsTreeComponent, MaterialHierarchyComponent} from "./BPMaterialsTreeComponent.tsx"; +import {BPTexturesTreeComponent, TextureHierarchyComponent} from "./BPTexturesTreeComponent.tsx"; +import {BPGeometriesTreeComponent, GeometryHierarchyComponent} from "./BPGeometriesTreeComponent.tsx"; +import {sceneGeometries, sceneMaterials, sceneTextures} from "../utils/three/sceneResources.ts"; +import {ResourceKind, resourceReveal, ResourceRevealRequest} from "../utils/resourceReveal.ts"; +import {useManager} from "../utils/UseManager.ts"; + +export type ResourceSectionCounts = Record + +/** The sections, in the order they are drawn. The title and the kind are the whole model. */ +export const resourceSections: {kind: ResourceKind, title: string}[] = [ + {kind: 'material', title: 'Materials'}, + {kind: 'texture', title: 'Textures'}, + {kind: 'geometry', title: 'Geometries'}, +] + +/** What each badge says. The lists under the badges come from the same three calls. */ +export function resourceSectionCounts(viewer: ThreeViewer): ResourceSectionCounts { + return { + material: sceneMaterials(viewer).length, + texture: sceneTextures(viewer).length, + geometry: sceneGeometries(viewer).length, + } +} + +export type ResourceSectionsOpen = Record + +const openSectionsKey = 'kite3dResourcesSectionsOpen' +const allOpen: ResourceSectionsOpen = {material: true, texture: true, geometry: true} + +export function rememberedSections(): ResourceSectionsOpen { + try { + const read = JSON.parse(localStorage.getItem(openSectionsKey) || 'null') + if (read && typeof read === 'object') return {...allOpen, ...read} + } catch { + // localStorage outlives the build that wrote it, and a key this build cannot read must not + // stop the panel from drawing. The sections open instead. + } + return allOpen +} + +function rememberSections(open: ResourceSectionsOpen) { + try { + localStorage.setItem(openSectionsKey, JSON.stringify(open)) + } catch { + // A browser with storage turned off still gets a working panel, it just forgets. + } +} + +/** + * The Resources tab: one list of what the current document holds, in three sections that each hold + * the tree that tab used to be. Selection, the Inspector, drag and the context menus are the trees' + * own, unchanged. A double click on a reference row in the Objects tree lands here, on that row. + */ +export function ResourcesHierarchyComponent({className}: {className: string}) { + const manager = useManager() + const viewer = manager.get() + + const [open, setOpen] = useState(rememberedSections) + const [counts, setCounts] = useState(() => resourceSectionCounts(viewer)) + + const trees = { + material: useRef(null), + texture: useRef(null), + geometry: useRef(null), + } + + const toggle = useCallback((kind: ResourceKind) => { + setOpen(prev => { + const next = {...prev, [kind]: !prev[kind]} + rememberSections(next) + return next + }) + }, []) + + // The badges follow the same events the three trees refresh on, so a count cannot disagree with + // the list under it. Several events land on one frame, so the recount waits for the frame. + useEffect(() => { + let queued = 0 + const recount = () => { + if (queued) return + queued = requestAnimationFrame(() => { + queued = 0 + setCounts(resourceSectionCounts(viewer)) + }) + } + const scene = viewer.scene + scene.addEventListener('sceneUpdate', recount) + scene.addEventListener('materialUpdate', recount) + scene.addEventListener('geometryUpdate', recount) + scene.addEventListener('textureUpdate', recount) + scene.addEventListener('texturesChanged', recount) + recount() + return () => { + if (queued) cancelAnimationFrame(queued) + scene.removeEventListener('sceneUpdate', recount) + scene.removeEventListener('materialUpdate', recount) + scene.removeEventListener('geometryUpdate', recount) + scene.removeEventListener('textureUpdate', recount) + scene.removeEventListener('texturesChanged', recount) + } + }, [viewer]) + + // A reveal arrives from a reference row in the Objects tree. This tab is not mounted while that + // tab is showing, so the request is also waiting when it mounts. + useEffect(() => { + const scrollWhenReady = (request: ResourceRevealRequest, tries: number) => { + if (trees[request.kind].current?.scrollToNode(request.uuid)) return + if (tries > 0) requestAnimationFrame(() => scrollWhenReady(request, tries - 1)) + } + const apply = (request: ResourceRevealRequest) => { + setOpen(prev => { + if (prev[request.kind]) return prev + const next = {...prev, [request.kind]: true} + rememberSections(next) + return next + }) + // The section may be opening this frame, so the scroll waits for the row to exist. + requestAnimationFrame(() => scrollWhenReady(request, 30)) + } + const pending = resourceReveal.take() + if (pending) apply(pending) + const listener = (e: ResourceRevealRequest) => apply({kind: e.kind, uuid: e.uuid}) + resourceReveal.addEventListener('reveal', listener) + return () => resourceReveal.removeEventListener('reveal', listener) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const body = (kind: ResourceKind) => { + if (kind === 'material') return + if (kind === 'texture') return + return + } + + return
+ {resourceSections.map(section =>
+
toggle(section.kind)} + onKeyDown={e => { + if (e.key === 'Enter' || e.key === ' ') toggle(section.kind) + }} + > + + {section.title} + {counts[section.kind]} +
+ {open[section.kind] &&
{body(section.kind)}
} +
)} +
+} diff --git a/packages/editor/src/components/BPTexturesTreeComponent.tsx b/packages/editor/src/components/BPTexturesTreeComponent.tsx index d705651a..02c3923b 100644 --- a/packages/editor/src/components/BPTexturesTreeComponent.tsx +++ b/packages/editor/src/components/BPTexturesTreeComponent.tsx @@ -10,7 +10,9 @@ import { UiObjectConfig } from "threepipe"; import { UiConfigRendererContextType} from 'uiconfig-blueprint/lib/esm/lib' -import {filterObjectsInSceneRoot} from "../utils/three/filterObjectsInSceneRoot.ts"; +import {sceneTextures} from "../utils/three/sceneResources.ts"; +import {selectResource} from "../utils/three/selectResource.ts"; +import {textureIcon} from "../utils/icons.tsx"; import React, {useMemo} from "react"; import {useObjContextMenu} from "./UseObjContextMenu.tsx"; import {HandleContextMenuCallback, MenuItem2} from "../utils/ContextMenuUtils.ts"; @@ -49,7 +51,7 @@ export class BPTexturesTreeComponent extends BPTr // if(obj.isUnlitTexture){ // node.icon = 'full-circle' // } - node.icon = 'media' + node.icon = textureIcon return node; } // @@ -71,40 +73,14 @@ export class BPTexturesTreeComponent extends BPTr // } protected _getRootNodes(): T[] { - // const v = this.context.methods.getRawValue(this.props.config) - // return v?.children as any || [] // todo as any - const showAll = false - if(showAll) { - let mats = - showAll ? - this.context.viewer.materialManager.getAllMaterials() || [] : - this.context.viewer.object3dManager.getMaterials() // only materials in scene - if(!showAll) - mats = Array.from(filterObjectsInSceneRoot(mats)) - - const textures = new Set() - mats.forEach((m: IMaterial) => { - const maps = m._mapRefs || [] - maps.forEach((t: ITexture) => { - if (t && t.isTexture) { - textures.add(t) - } - }) - }) - return Array.from(textures) as T[] - }else { - return this.context.viewer.object3dManager.getTextures() as T[] - } - // return getValue(this.props.config) - // return (this.props.config.children || []).map(c => getOrCall(c) || {}).flat(2) + return sceneTextures(this.context.viewer) as T[] } protected async _onNodeClick(_id: string) { const node = this._infoMap.get(_id) if(!node) return const value = node.isSelected ? null : node.nodeData! // unselect if already selected - // node.nodeData!.dispatchEvent({type: 'select', value: value ?? null, texture: node.nodeData!, ui: true, bubbleToMaterial: true, bubbleToObject: true, bubbleToParent: true}) - this.context.viewer.getPlugin(PickingPlugin)?.setSelectedObject(value) + selectResource(this.context.viewer, node.nodeData!, value) } protected async _onNodeDoubleClick(_id: string) { @@ -179,6 +155,13 @@ export class BPTexturesTreeComponent extends BPTr console.error('BPTexturesTreeComponent: viewer not found in context', this.context) return } + // A closed Resources section is not mounted, so it missed every selection event while it was + // shut. The tree reads what is picked now, or its row opens unhighlighted. + const picked = viewer.getPlugin(PickingPlugin)?.getSelectedObject() + if(picked?.isTexture) { + this._selectedIds = [picked.uuid] + this.setSelected(this._selectedIds, false) + } viewer.getPlugin(PickingPlugin)?.addEventListener('selectedObjectChanged', this.selectedObjectChanged) viewer.scene.addEventListener('sceneUpdate', this.sceneUpdate) // todo: subscribe only to the texture in the config instead of the whole scene viewer.scene.addEventListener('textureUpdate', this.textureUpdate) // todo: subscribe only to the texture in the config instead of the whole scene @@ -200,7 +183,7 @@ export class BPTexturesTreeComponent extends BPTr } -export function TextureHierarchyComponent({className}: {className: string}){ +export function TextureHierarchyComponent({className, treeRef}: {className: string, treeRef?: React.Ref}){ const manager = useManager() const viewer = manager.get() const {handleContextMenu} = useObjContextMenu() @@ -212,5 +195,6 @@ export function TextureHierarchyComponent({className}: {className: string}){ return } diff --git a/packages/editor/src/components/BPTreeComponent.tsx b/packages/editor/src/components/BPTreeComponent.tsx index 4a6e0ff1..54fb9022 100644 --- a/packages/editor/src/components/BPTreeComponent.tsx +++ b/packages/editor/src/components/BPTreeComponent.tsx @@ -18,6 +18,18 @@ export abstract class BPTreeComponent>() + private _treeRef = React.createRef>() + + /** + * Brings a row into view, and answers whether the row was there. A caller that has just opened + * the tree can try again on the next frame while this is false. + */ + scrollToNode(id: string | number) { + const el = this._treeRef.current?.getNodeContentElement(id) + el?.scrollIntoView({block: 'nearest'}) + return !!el + } + protected _createNodeInfo(id: string, obj: T) { return { id, @@ -249,6 +261,7 @@ export abstract class BPTreeComponent { diff --git a/packages/editor/src/components/ObjectHierarchyComponent.tsx b/packages/editor/src/components/ObjectHierarchyComponent.tsx index 35c7925c..85adbeef 100644 --- a/packages/editor/src/components/ObjectHierarchyComponent.tsx +++ b/packages/editor/src/components/ObjectHierarchyComponent.tsx @@ -7,6 +7,10 @@ import {useOnObjectCreate} from "./UseOnObjectCreate.tsx"; import {Object3DGenerationMenu} from "./Object3DGenerationMenu.tsx"; import {useManager} from "../utils/UseManager.ts"; import {useMakeAsset} from "../utils/UseMakeAsset.ts"; +import {useDocuments} from "../documents/UseDocuments.ts"; +import {documentKind} from "../documents/DocumentStore.ts"; +import {assetUrlPrefix} from "../utils/project.ts"; +import {showErrorToast} from "../utils/Toaster.tsx"; export function ExtraMenuItems(props: { event: React.MouseEvent, @@ -54,6 +58,21 @@ export function ObjectHierarchyComponent({className}: { className: string }) { // }) const contextMenu = useContextMenu() + const {store} = useDocuments() + + /** + * A placed asset names the file it came from in userData.rootPath. A double click on that row + * opens the file as a document, the same call the Inspector's Edit Asset button makes. A clone + * of an asset child carries _tpRootPath instead, and that one stays a plain row. + */ + const onOpenAsset = (obj: IObject3D) => { + const rootPath = (obj as {_tpRootPath?: string})._tpRootPath ? null : obj.userData?.rootPath + if (!rootPath || !rootPath.startsWith(assetUrlPrefix)) return false + const path = manager.resolveAssetIdPath(rootPath) + if (!path || path.startsWith(assetUrlPrefix) || !documentKind(path)) return false + store.open(path).catch(e => showErrorToast(`Unable to open ${path}`, e)) + return true + } const config: UiObjectConfig = useMemo(() => ({ type: 'hierarchy', @@ -71,6 +90,7 @@ export function ObjectHierarchyComponent({className}: { className: string }) { { contextMenu.handleContextMenu({ event: e, diff --git a/packages/editor/src/components/ThreeEditorComponent.tsx b/packages/editor/src/components/ThreeEditorComponent.tsx index deee6d6c..20de1550 100644 --- a/packages/editor/src/components/ThreeEditorComponent.tsx +++ b/packages/editor/src/components/ThreeEditorComponent.tsx @@ -15,9 +15,9 @@ import {Alignment, Button, Card, Divider, IconName, Navbar, Panel, PanelStack2, import {BPHierarchyComponent} from './BPHierarchyComponent.tsx' import {SaveProjectButton} from './SaveProjectButton.tsx' import {BPTextureFileComponent} from './BPTextureFileComponent.tsx' -import {BPMaterialsTreeComponent, MaterialHierarchyComponent} from "./BPMaterialsTreeComponent.tsx"; -import {BPTexturesTreeComponent, TextureHierarchyComponent} from "./BPTexturesTreeComponent.tsx"; -import {GeometryHierarchyComponent} from "./BPGeometriesTreeComponent.tsx"; +import {BPMaterialsTreeComponent} from "./BPMaterialsTreeComponent.tsx"; +import {BPTexturesTreeComponent} from "./BPTexturesTreeComponent.tsx"; +import {ResourcesHierarchyComponent} from "./BPResourcesTreeComponent.tsx"; import {FilesPanel} from "./FilesPanel.tsx"; import { InspectorPanelComponent, @@ -41,6 +41,7 @@ import {useCloseDocument} from '../documents/UseCloseDocument.tsx'; import {DocumentTab} from '../documents/DocumentTab.tsx'; import {useTabKeys} from '../documents/UseTabKeys.ts'; import {showErrorToast} from '../utils/Toaster.tsx'; +import {resourceReveal} from '../utils/resourceReveal.ts'; export function RefUiConfigComponent(props: BPComponentProps){ @@ -88,11 +89,10 @@ export function RefUiConfigComponent(props: BPComponentProps){ ConfigObjectGenerators.reference = RefUiConfigComponent ConfigObjectGenerators.image = BPTextureFileComponent +// Objects first, then one Resources tab holding the materials, textures and geometries lists. const editorLeftTabs = { objects: ObjectHierarchyComponent, - materials: MaterialHierarchyComponent, - textures: TextureHierarchyComponent, - geometries: GeometryHierarchyComponent, + resources: ResourcesHierarchyComponent, } ConfigObjectGenerators.hierarchy = BPHierarchyComponent ConfigObjectGenerators.materials = BPMaterialsTreeComponent @@ -108,6 +108,15 @@ export function ThreeEditorComponent() { const {documents, activeId, store} = useDocuments() const {closeDocument} = useCloseDocument() useTabKeys() + const [leftTabId, setLeftTabId] = useState('objects') + + // A double click on a reference row in the Objects tree asks for the Resources tab. The tab + // itself opens the section and scrolls to the row. + useEffect(() => { + const listener = () => setLeftTabId('resources') + resourceReveal.addEventListener('reveal', listener) + return () => resourceReveal.removeEventListener('reveal', listener) + }, []) const isRunning = manager.playMode.isRunningMode // const [splitSizes, setSplitSizes] = useState([0, 100, 0]) @@ -252,6 +261,8 @@ export function ThreeEditorComponent() { void store.activate(id).catch(e=>showErrorToast(`Unable to open ${id}`, e))} diff --git a/packages/editor/src/components/WindowPanesLayout.tsx b/packages/editor/src/components/WindowPanesLayout.tsx index 0e95cc27..1fd5626c 100644 --- a/packages/editor/src/components/WindowPanesLayout.tsx +++ b/packages/editor/src/components/WindowPanesLayout.tsx @@ -23,12 +23,15 @@ export interface WindowPanesLayoutProps{ bottom: null | (WindowPanel|null)[] center: (WindowPanel|null)[] } - /** The centre tab to show. The other three slots keep their own selection. */ + /** The centre tab to show. The bottom and right slots keep their own selection. */ selectedCenterTabId?: string onCenterTabChange?: (id: string)=>void + /** The left tab to show. A reference row in the Objects tree switches it to Resources. */ + selectedLeftTabId?: string + onLeftTabChange?: (id: string)=>void } -export function WindowPanesLayout({ panels, selectedCenterTabId, onCenterTabChange }: WindowPanesLayoutProps){ +export function WindowPanesLayout({ panels, selectedCenterTabId, onCenterTabChange, selectedLeftTabId, onLeftTabChange }: WindowPanesLayoutProps){ const panelRefs = { left: useRef(null), right: useRef(null), @@ -88,7 +91,7 @@ export function WindowPanesLayout({ panels, selectedCenterTabId, onCenterTabChan } - const renderPanels = (p0: (WindowPanel|null)[], vertical = false, controlled = false)=> { + const renderPanels = (p0: (WindowPanel|null)[], vertical = false, controlled?: {selectedTabId?: string, onChange?: (id: string)=>void})=> { const p = p0.filter(p=>!!p) if(!p.length) return // One panel gets a strip too: the center slot is the document tabs, and one open document @@ -99,8 +102,8 @@ export function WindowPanesLayout({ panels, selectedCenterTabId, onCenterTabChan renderActiveTabPanelOnly={true} size={"medium"} className={"window-panels-tabs"} - selectedTabId={controlled ? selectedCenterTabId : undefined} - onChange={controlled && onCenterTabChange ? (id)=>onCenterTabChange(String(id)) : undefined} + selectedTabId={controlled?.selectedTabId} + onChange={controlled?.onChange ? (id)=>controlled.onChange!(String(id)) : undefined} > {p.map(({className, ...panel}, i)=>( - {renderPanels(panels.left)} + {renderPanels(panels.left, false, {selectedTabId: selectedLeftTabId, onChange: onLeftTabChange})} - {renderPanels(panels.center, false, true)} + {renderPanels(panels.center, false, {selectedTabId: selectedCenterTabId, onChange: onCenterTabChange})} diff --git a/packages/editor/src/components/tree2.scss b/packages/editor/src/components/tree2.scss index cb9d6d1e..bd3c212e 100644 --- a/packages/editor/src/components/tree2.scss +++ b/packages/editor/src/components/tree2.scss @@ -96,3 +96,21 @@ font-size: $pt-font-size-small; font-weight: 400; } + +// A reference row points at a resource; it is not a child, so it reads quieter than one. +.tree-node-reference-label{ + font-style: italic; + opacity: 0.7; +} +.tree-node-reference-icon{ + display: inline-flex; + align-items: center; + gap: 1px; + padding: 0 5px; + opacity: 0.7; +} + +// A preview light belongs to the document view, not to the file it shows. +.tree-node-excluded > .bp5-tree-node-content .bp5-tree-node-label{ + opacity: 0.5; +} diff --git a/packages/editor/src/renderer.scss b/packages/editor/src/renderer.scss index 47d3a026..a930673d 100644 --- a/packages/editor/src/renderer.scss +++ b/packages/editor/src/renderer.scss @@ -213,6 +213,41 @@ button.kite3d-status-chip { margin-left: 0 !important; } +// The Resources tab: three sections, each holding one of the trees this tab replaced. +.resources-panel{ + display: flex; + flex-direction: column; + height: 100%; + overflow-y: auto; +} +.resources-section-header{ + align-items: center; + cursor: pointer; + display: flex; + gap: 4px; + padding: 4px 6px; + position: sticky; + top: 0; + user-select: none; + z-index: 1; + background: var(--pt-app-background-color); +} +.resources-section-header:hover{ + background: var(--pt-minimal-button-background-color-hover); +} +.resources-section-title{ + flex: 1 1 auto; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; +} +.resources-section-body .folderContent{ + height: auto; + margin-left: 0 !important; + max-width: 100%; +} + //.bp5-tree-drop-spacer { // div{ // background: red; diff --git a/packages/editor/src/utils/icons.tsx b/packages/editor/src/utils/icons.tsx index e51ff420..09937075 100644 --- a/packages/editor/src/utils/icons.tsx +++ b/packages/editor/src/utils/icons.tsx @@ -2,7 +2,8 @@ import {FileManifestEntry, SelectedInspectorItem} from "./AssetsProvider.ts"; import {SelectFileRef, SelObjectType} from "./projectUtils.ts"; import {objectToType} from "../components/RefSelectionObjectComponent.tsx"; import {IconName, MaybeElement} from "@blueprintjs/core"; -import {TypedClass} from "threepipe"; +import {IMaterial, TypedClass} from "threepipe"; +import {bpUiConfigIcons} from 'uiconfig-blueprint/lib/esm/lib' export function iconForSelectionObject(object?: SelectedInspectorItem|SelectFileRef|null): IconName | MaybeElement{ const type = objectToType(object) @@ -70,3 +71,18 @@ export function fileToIcon(f: {path: string, type?: 'file'|'directory'}):IconNam }, ['', 'document'])[1] return icon; } + +/** + * The icons the resource rows wear. The Resources tab and the reference rows under a mesh share + * them, so the same material looks the same wherever it is listed. + */ +export function iconForMaterial(material: IMaterial): IconName | MaybeElement { + if(material.isPhysicalMaterial) + return bpUiConfigIcons['shape-sphere-filled-1']({style: {color: 'transparent'}, className: 'bp5-tree-node-icon-svg'}) + if(material.isUnlitMaterial) return 'full-circle' + return undefined +} + +export const textureIcon: IconName = 'media' +export const geometryIcon: IconName = 'grid-view' +export const componentIcon: IconName = 'package' diff --git a/packages/editor/src/utils/resourceReveal.ts b/packages/editor/src/utils/resourceReveal.ts new file mode 100644 index 00000000..43f0402f --- /dev/null +++ b/packages/editor/src/utils/resourceReveal.ts @@ -0,0 +1,33 @@ +import {EventDispatcher} from "threepipe"; + +export type ResourceKind = 'material' | 'texture' | 'geometry' + +export interface ResourceRevealRequest { + kind: ResourceKind + /** The uuid of the material, texture or geometry, which is also its row id in the Resources tab. */ + uuid: string +} + +/** + * A double click on a reference row under a mesh asks for the Resources tab. Two listeners answer: + * the editor switches the left panel, and the Resources tab opens the section and scrolls the row + * into view. The tab is not mounted while the Objects tab is showing, so the last request is kept + * and the tab reads it when it mounts. + */ +class ResourceRevealHub extends EventDispatcher<{reveal: ResourceRevealRequest & {type: 'reveal'}}> { + pending: ResourceRevealRequest | null = null + + reveal(request: ResourceRevealRequest) { + this.pending = request + this.dispatchEvent({type: 'reveal', ...request}) + } + + /** The Resources tab takes the request, so a later mount does not scroll again. */ + take(): ResourceRevealRequest | null { + const request = this.pending + this.pending = null + return request + } +} + +export const resourceReveal = new ResourceRevealHub() diff --git a/packages/editor/src/utils/three/objectReferences.ts b/packages/editor/src/utils/three/objectReferences.ts new file mode 100644 index 00000000..4bb26135 --- /dev/null +++ b/packages/editor/src/utils/three/objectReferences.ts @@ -0,0 +1,110 @@ +import { + EntityComponentPlugin, + IGeometry, + iMaterialCommons, + IMaterial, + IObject3D, + ITexture, + Object3DComponent, +} from "threepipe"; + +export type ObjectReferenceKind = 'geometry' | 'material' | 'texture' | 'component' + +export interface ObjectReference { + /** Unique per row. A material used by two objects gets one row under each of them. */ + id: string + kind: ObjectReferenceKind + /** What the row points at. A shared resource is the same instance under every object using it. */ + resource: IMaterial | ITexture | IGeometry | Object3DComponent + /** The object the row hangs under, which is what a component row selects. */ + owner: IObject3D + /** The row's only text. The kind is the icon, never a word. */ + name: string + /** The row's tooltip. A texture names its slot here, which is the only place the slot shows. */ + tooltip: string + /** A material's assigned texture slots. */ + children?: ObjectReference[] +} + +/** The same label the Materials, Textures and Geometries trees give a resource. */ +function resourceName(resource: {name?: string, type?: string | number}): string { + return resource.name ? resource.name : resource.type ? `(${resource.type})` : 'unnamed' +} + +/** The texture slots a material has something in, in the order the material declares them. */ +export function materialTextureReferences(owner: IObject3D, prefix: string, material: IMaterial): ObjectReference[] { + const maps: Map = iMaterialCommons.getMapsForMaterial.call(material) + const rows: ObjectReference[] = [] + for (const [slot, texture] of maps) { + if (!texture?.isTexture) continue + rows.push({ + id: `${prefix}|${slot}|${texture.uuid}`, + kind: 'texture', + resource: texture, + owner, + name: resourceName(texture), + tooltip: `${slot}: ${resourceName(texture)}`, + }) + } + return rows +} + +/** + * What an object points at: its geometry, each of its materials with that material's texture slots + * under it, and each component the entity component plugin has on it. These are references, not + * children: the same resource appears under every object that uses it, and nothing here can be + * dragged, reordered or deleted. + */ +export function objectReferences(object: IObject3D): ObjectReference[] { + const rows: ObjectReference[] = [] + + const geometry = (object as {geometry?: IGeometry}).geometry + if (geometry?.isBufferGeometry) rows.push({ + id: `${object.uuid}|geometry|${geometry.uuid}`, + kind: 'geometry', + resource: geometry, + owner: object, + name: resourceName(geometry), + tooltip: `Geometry: ${resourceName(geometry)}`, + }) + + const material = (object as {material?: IMaterial | IMaterial[]}).material + const materials = Array.isArray(material) ? material : material ? [material] : [] + materials.forEach((mat, i) => { + if (!mat?.isMaterial) return + // An array material names its index, because two rows would otherwise read the same. + const slot = Array.isArray(material) ? `Material ${i}` : 'Material' + const prefix = `${object.uuid}|material|${i}|${mat.uuid}` + rows.push({ + id: prefix, + kind: 'material', + resource: mat, + owner: object, + name: resourceName(mat), + tooltip: `${slot}: ${resourceName(mat)}`, + children: materialTextureReferences(object, prefix, mat), + }) + }) + + for (const component of EntityComponentPlugin.ObjectToComponents.get(object) || []) { + const type = component.constructor?.ComponentType || 'Component' + rows.push({ + id: `${object.uuid}|component|${component.uuid}`, + kind: 'component', + resource: component, + owner: object, + name: type, + tooltip: `Component: ${type}`, + }) + } + + return rows +} + +/** Whether a row needs a caret, answered without building the rows behind it. */ +export function hasObjectReferences(object: IObject3D): boolean { + if ((object as {geometry?: IGeometry}).geometry?.isBufferGeometry) return true + const material = (object as {material?: IMaterial | IMaterial[]}).material + if (Array.isArray(material) ? material.length > 0 : !!material) return true + return (EntityComponentPlugin.ObjectToComponents.get(object)?.length ?? 0) > 0 +} diff --git a/packages/editor/src/utils/three/sceneResources.ts b/packages/editor/src/utils/three/sceneResources.ts new file mode 100644 index 00000000..c211ef7a --- /dev/null +++ b/packages/editor/src/utils/three/sceneResources.ts @@ -0,0 +1,18 @@ +import {IGeometry, IMaterial, ITexture, ThreeViewer} from "threepipe"; +import {filterObjectsInSceneRoot} from "./filterObjectsInSceneRoot.ts"; + +/** + * What the current document holds. The Resources tab lists these and counts them, and each of the + * three trees builds its rows from the same call, so a badge can never disagree with its list. + */ +export function sceneMaterials(viewer: ThreeViewer): IMaterial[] { + return Array.from(filterObjectsInSceneRoot(viewer.object3dManager.getMaterials())) +} + +export function sceneTextures(viewer: ThreeViewer): ITexture[] { + return viewer.object3dManager.getTextures() +} + +export function sceneGeometries(viewer: ThreeViewer): IGeometry[] { + return Array.from(filterObjectsInSceneRoot(viewer.object3dManager.getGeometries())) +} diff --git a/packages/editor/src/utils/three/selectResource.ts b/packages/editor/src/utils/three/selectResource.ts new file mode 100644 index 00000000..d005a625 --- /dev/null +++ b/packages/editor/src/utils/three/selectResource.ts @@ -0,0 +1,23 @@ +import {IGeometry, IMaterial, ITexture, PickingPlugin, ThreeViewer} from "threepipe"; + +export type SceneResource = IMaterial | ITexture | IGeometry + +/** + * Puts a material, texture or geometry in the Inspector. A material carries its own select event so + * the object holding it highlights too; the other two go straight to the picker. The Resources tab + * and the reference rows under a mesh both call this, so one click means one thing everywhere. + * + * @param resource the row that was clicked, which is the event target for a material + * @param value what to select, which is null when a click clears the row it is on + */ +export function selectResource(viewer: ThreeViewer, resource: SceneResource, value: SceneResource | null) { + const material = resource as IMaterial + if (material.isMaterial) { + material.dispatchEvent({ + type: 'select', value: value ?? null, material, + ui: true, bubbleToObject: true, bubbleToParent: true, + } as any) + return + } + viewer.getPlugin(PickingPlugin)?.setSelectedObject(value as any ?? undefined) +}