/** * 3D Foundation Project * Copyright 2025 Smithsonian Institution * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import System from "@ff/graph/System"; import Tree, { customElement, property, PropertyValues, html } from "@ff/ui/Tree"; import { IComponentEvent, INodeChangeEvent } from "@ff/graph/Node"; import { lightTypes } from "../../applications/coreTypes"; import CVDocumentProvider, { IActiveDocumentEvent } from "../../components/CVDocumentProvider"; import CVLanguageManager from "../../components/CVLanguageManager"; import CVNodeProvider, { IActiveNodeEvent, INodesEvent } from "../../components/CVNodeProvider"; import { ELightType, ICVLight } from "../../components/lights/CVLight"; import CVSunLight from "../../components/lights/CVSunLight"; import NVNode from "../../nodes/NVNode"; import NVScene from "../../nodes/NVScene"; import Notification from "@ff/ui/Notification"; import CLight from "@ff/scene/components/CLight"; import ConfirmDeleteLightMenu from "./ConfirmDeleteLightMenu"; import CreateLightMenu from "./CreateLightMenu"; import CVOrbitNavigation from "client/components/CVOrbitNavigation"; import CVScene from "client/components/CVScene"; import unitScaleFactor from "client/utils/unitScaleFactor"; import { EUnitType } from "client/schema/common"; //////////////////////////////////////////////////////////////////////////////// @customElement("sv-node-tree") class NodeTree extends Tree { @property({ attribute: false }) system: System; protected documentProvider: CVDocumentProvider = null; protected nodeProvider: CVNodeProvider = null; protected firstConnected() { super.firstConnected(); this.classList.add("sv-node-tree"); this.addEventListener("click", this.onContainerClick.bind(this)); this.documentProvider = this.system.getMainComponent(CVDocumentProvider); this.nodeProvider = this.system.getMainComponent(CVNodeProvider); } protected get language() { return this.system.getComponent(CVLanguageManager); } protected connected() { super.connected(); this.documentProvider.on("active-component", this.onUpdate, this); this.nodeProvider.on("active-node", this.onActiveNode, this); this.nodeProvider.on("scoped-nodes", this.onUpdate, this); this.language.outs.uiLanguage.on("value", this.onUpdate, this); this.system.components.on(CLight, this.onLightNode, this); } protected disconnected() { this.system.components.off(CLight, this.onLightNode, this); this.nodeProvider.off("scoped-nodes", this.onUpdate, this); this.nodeProvider.off("active-node", this.onActiveNode, this); this.documentProvider.off("active-component", this.onUpdate, this); this.language.outs.uiLanguage.on("value", this.onUpdate, this); this.unregisterLightNodes(); super.disconnected(); } protected update(changedProperties: PropertyValues): void { const document = this.documentProvider.activeComponent; if (document) { this.root = { id: "scene", children: [ document.root ] } as any; } else { this.root = null; } super.update(changedProperties); } protected renderNodeHeader(node: NVNode) { let icons = []; let buttons = []; if (node.scene) { icons.push(html``); } if (node.model) { icons.push(html``); } if (node.name === "Lights") { buttons.push(html` this.onClickAddLight(e, node)}>`); } if (node.light) { icons.push(html``); if(node.light.canDelete) { buttons.push(html` this.onClickDeleteLight(e, node)}>`); } } if (node.camera) { icons.push(html``); } if (node.meta) { icons.push(html``); } return html`${icons}
${node.displayName}
${buttons}`; } protected isNodeSelected(node: NVNode): boolean { return node === this.nodeProvider.activeNode; } protected getClasses(treeNode: NVNode): string { if (treeNode.scene) { return "sv-node-scene" } if (treeNode.model) { return "sv-node-model"; } if (treeNode.light) { const light = treeNode.transform.getComponent(CLight); return light.ins.enabled.value ? "sv-node-light" : "sv-node-light disabled"; } if (treeNode.camera) { return "sv-node-camera"; } } protected getChildren(node: NVNode) { if (node === this.root) { return super.getChildren(node); } else { return node.transform.children .map(child => child.node) .filter(child => child.is(NVNode) || child.is(NVScene)); } } protected onNodeClick(event: MouseEvent, node: NVNode) { const rect = (event.currentTarget as HTMLElement).getBoundingClientRect(); if (event.clientX - rect.left < 30) { this.toggleExpanded(node); } else { this.nodeProvider.activeNode = node; } } protected onContainerClick() { this.nodeProvider.activeNode = null; } protected onActiveNode(event: IActiveNodeEvent) { if (event.previous) { event.previous.off("change", this.onUpdate, this); this.setSelected(event.previous, false); } if (event.next) { this.setSelected(event.next, true); event.next.on("change", this.onUpdate, this); } } protected onClickAddLight(event: MouseEvent, parentNode: NVNode) { event.stopPropagation(); const mainView = document.getElementsByTagName('voyager-story')[0] as HTMLElement; const language: CVLanguageManager = this.documentProvider.activeComponent.setup.language; CreateLightMenu .show(mainView, language) .then(([selectedType, name]) => { const lightNode = NodeTree.createLightNode(parentNode, selectedType, name); parentNode.transform.addChild(lightNode.transform); this.nodeProvider.activeNode = lightNode; this.setExpanded(parentNode, true); this.requestUpdate(); }) .catch(e => console.error("Error creating light:", e)); } static createLightNode(parentNode: NVNode, newType: ELightType, name: string): NVNode { const lightType = lightTypes.find(lt => lt.type === ELightType[newType].toString()); if (!lightType) throw new Error(`Unsupported light type: '${newType}'`); const lightNode: NVNode = parentNode.graph.createCustomNode(parentNode); lightNode.name = name; const newLight: ICVLight = lightNode.transform.createComponent(lightType); newLight.ins.name.setValue(name); // Set reasonable initial size const scene: CVScene = newLight.getGraphComponent(CVScene); let scale = unitScaleFactor(EUnitType.m, scene.ins.units.value)*0.5; scale *= newType === ELightType.rect ? 0.05 : 0.5; newLight.transform.ins.scale.setValue([scale,scale,scale]); newLight.update(this); // trigger light update before helper creation to ensure proper init if (newType === ELightType.sun) { const orbitNav = scene.getGraphComponent(CVOrbitNavigation); if (orbitNav && orbitNav.ins.lightsFollowCamera.value) { orbitNav.ins.lightsFollowCamera.setValue(false); Notification.show("Lights Follow Camera has been disabled for sunlight in Scene -> Orbit Navigation settings.", "info", 5000); } } newLight.getGraphComponent(CVScene).ins.lightUpdated.set(); return lightNode; } protected onClickDeleteLight(event: MouseEvent, node: NVNode) { event.stopPropagation(); if (!node.light) return; const mainView = document.getElementsByTagName('voyager-story')[0] as HTMLElement; const language: CVLanguageManager = this.documentProvider.activeComponent.setup.language; ConfirmDeleteLightMenu.show(mainView, language, node.name) .then(confirmed => { if (confirmed) { if (this.nodeProvider.activeNode === node) { this.nodeProvider.activeNode = node.transform.parent?.node as NVNode; } node.dispose(); this.requestUpdate(); } }); } protected getLightNodes(): NVNode[] { return this.system.getComponents(CLight).map(light => light.node as NVNode); } protected unregisterLightNodes(): void { this.getLightNodes().forEach(node => { node.off("change", this.onLightChanged, this); }); } protected onLightNode(event: IComponentEvent) { if(event.add) { event.object.node.on("change", this.onLightChanged, this); } else if(event.remove && event.object.node.hasEvent("change")) { event.object.node.off("change", this.onLightChanged, this); } } protected onLightChanged(event: INodeChangeEvent) { if (event.what === "enabled") { this.requestUpdate(); } } } export default NodeTree;