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) +}