/** * 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 { NeutralToneMapping, NoToneMapping, Mesh } from "three"; import Component, { IComponentEvent, types } from "@ff/graph/Component"; import CRenderer from "@ff/scene/components/CRenderer"; import { EShaderMode, IViewer, TShaderMode } from "client/schema/setup"; import { EDerivativeQuality, EDerivativeUsage, TDerivativeQuality } from "client/schema/model"; import CVModel2, { IModelErrorEvent, IModelLoadEvent } from "./CVModel2"; import CVAnnotationView, { IActiveTagUpdateEvent, IAnnotationClickEvent, ITagUpdateEvent } from "./CVAnnotationView"; import CVAnalytics from "./CVAnalytics"; import CVLanguageManager from "./CVLanguageManager"; import CVARManager from "./CVARManager"; import {getFocusableElements} from "../utils/focusHelpers"; import CVSetup from "./CVSetup"; import { CLight } from "./lights/CVLight"; import CVAssetManager from "./CVAssetManager"; //////////////////////////////////////////////////////////////////////////////// /** * The public DOM events the viewer dispatches on its host element, mapped to the type of * their `event.detail`. An event whose detail is `void` carries none. */ export interface IViewerEventDetail { "annotation-active": string; "model-load": TDerivativeQuality; "model-error": string; "scene-content-load": void; "scene-load": void; } /** Forces a detail argument on the events that carry one, and forbids it on the others. */ type ViewerEventArgs = IViewerEventDetail[K] extends void ? [] : [detail: IViewerEventDetail[K]]; export default class CVViewer extends Component { static readonly typeName: string = "CVViewer"; static readonly text: string = "Viewer"; static readonly icon: string = ""; private _rootElement: HTMLElement = null; private _needsAnnoFocus: boolean = false; /** Models that have loaded the derivative they currently request. Drives the initial load. */ private _loadedModels = new Set(); protected static readonly ins = { annotationsVisible: types.Boolean("Annotations.Visible"), annotationExit: types.Event("Annotations.Exit"), annotationFocus: types.Boolean("Annotations.Focus", false), activeAnnotation: types.String("Annotations.ActiveId"), activeTags: types.Tags("Tags.Active"), sortedTags: types.Tags("Tags.Sorted"), radioTags: types.Boolean("Tags.Radio"), shader: types.Enum("Renderer.Shader", EShaderMode), variant: types.Option("Renderer.Variant", [], 0), toneMapping: types.Boolean("Renderer.ToneMapping", false), exposure: types.Number("Renderer.Exposure", 1), gamma: types.Number("Renderer.Gamma", 2), quality: types.Enum("Models.Quality", EDerivativeQuality, EDerivativeQuality.High), isWallMountAR: types.Boolean("AR.IsWallMount", false), arScale: types.Number("AR.Scale", 1.0) }; protected static readonly outs = { tagCloud: types.Tags("Tags.Cloud"), /** * Every model in the scene has a representation on display, whatever its quality. * Higher quality derivatives may still be downloading, much like DOMContentLoaded. */ sceneContentLoaded: types.Boolean("Viewer.SceneContentLoaded", false), /** The initial load is over: every model has loaded the quality it was asked for. */ sceneLoaded: types.Boolean("Viewer.SceneLoaded", false), }; ins = this.addInputs(CVViewer.ins); outs = this.addOutputs(CVViewer.outs); get settingProperties() { return [ this.ins.annotationsVisible, this.ins.activeTags, this.ins.sortedTags, this.ins.radioTags, this.ins.shader, this.ins.toneMapping, this.ins.exposure, this.ins.gamma, this.ins.isWallMountAR, this.ins.arScale, ]; } get snapshotProperties() { return [ this.ins.annotationsVisible, this.ins.activeAnnotation, this.ins.activeTags, this.ins.shader, this.ins.variant, this.ins.exposure, ]; } protected get analytics() { return this.getMainComponent(CVAnalytics); } protected get assetManager() { return this.getMainComponent(CVAssetManager); } protected get renderer() { return this.getMainComponent(CRenderer); } protected get ar() { return this.getMainComponent(CVARManager); } get rootElement() { return this._rootElement; } set rootElement(root: HTMLElement) { this._rootElement = root; } /** * Fires events on the host element. * Does nothing while the viewer is detached, as nobody could be listening. */ protected dispatch(type: K, ...detail: ViewerEventArgs) { this._rootElement?.dispatchEvent(new CustomEvent(type, { detail: detail[0] })); } create() { super.create(); this.graph.components.on(CVModel2, this.onModelComponent, this); this.graph.components.on(CLight, this.onLightComponent, this); this.graph.components.on(CVAnnotationView, this.onAnnotationsComponent, this); this.graph.components.on(CVLanguageManager, this.onLanguageComponent, this); this.graph.components.on(Component, this.onAnyComponent, this); this.outs.tagCloud.on("value", this.onTagCloudUpdate, this); this.ar.ins.wallMount.linkFrom(this.ins.isWallMountAR); this.ar.ins.arScale.linkFrom(this.ins.arScale); } dispose() { this.outs.tagCloud.off("value", this.onTagCloudUpdate, this); this.graph.components.off(CVModel2, this.onModelComponent, this); this.graph.components.off(CLight, this.onLightComponent, this); this.graph.components.off(CVAnnotationView, this.onAnnotationsComponent, this); this.graph.components.off(CVLanguageManager, this.onLanguageComponent, this); this.graph.components.off(Component, this.onAnyComponent, this); super.dispose(); } update(context) { const ins = this.ins; if (ins.variant.changed) { const variant = ins.variant.getOptionText(); this.getGraphComponents(CVModel2).forEach(model => { model.ins.variant.setOption(variant); }); ins.shader.setValue(0); } if (ins.shader.changed) { const shader = ins.shader.getValidatedValue(); this.getGraphComponents(CVModel2).forEach(model => model.ins.shader.setValue(shader)); } if (ins.exposure.changed) { this.renderer.ins.exposure.setValue(ins.exposure.value); } if (ins.toneMapping.changed) { this.renderer.views.forEach(view => view.renderer.toneMapping = ins.toneMapping.value ? NeutralToneMapping : NoToneMapping); const scene = this.renderer.activeScene; if (scene) { scene.traverse(object => { const mesh = object as Mesh; if (mesh.isMesh) { if (Array.isArray(mesh.material)) { mesh.material.forEach(material => material.needsUpdate = true); } else { mesh.material.needsUpdate = true; } } }); } } if (ins.gamma.changed) { //this.renderer.ins.gamma.setValue(ins.gamma.value); } if (ins.quality.changed) { const quality = ins.quality.getValidatedValue(); this.getGraphComponents(CVModel2).forEach(model => model.ins.quality.setValue(quality)); } if (ins.activeAnnotation.changed) { const id = ins.activeAnnotation.value; this.getGraphComponents(CVAnnotationView).forEach(view => view.setActiveAnnotationById(id)); } if(ins.annotationExit.changed) { ins.annotationsVisible.setValue(false); } if (ins.annotationsVisible.changed) { const visible = ins.annotationsVisible.value; this.getGraphComponents(CVAnnotationView).forEach(view => view.ins.visible.setValue(visible)); const setup = this.getGraphComponent(CVSetup); if(setup && ins.annotationFocus.value) { const tourIns = setup.tours.ins; this._needsAnnoFocus = ins.annotationsVisible.value && !tourIns.enabled.value; ins.annotationFocus.setValue(false); } } if (ins.radioTags.changed && ins.radioTags.value) { const tagString = ins.activeTags.value; const tags = tagString.split(","); if(tags.length > 1) { ins.activeTags.setValue(tags[0]); } } if (ins.activeTags.changed) { const tags = ins.activeTags.value; this.getGraphComponents(CVAnnotationView).forEach(view => view.ins.activeTags.setValue(tags)); this.getGraphComponents(CVModel2).forEach(model => model.ins.activeTags.setValue(tags)); this.getGraphComponents(CLight).forEach(light => light.ins.activeTags.setValue(tags)); } if (ins.sortedTags.changed) { this.refreshTagCloud(); } return true; } // preRender(context) // { // const qualityName = this.ins.quality.getOptionText(); // context.viewport.overlay.setLabel(ELocation.BottomRight, "quality", `Quality: ${qualityName}`); // } tock() { if(this._needsAnnoFocus) { let elem = null; if(this.outs.tagCloud.value.length > 0) { // handle annotation tag groups const tagElement = this.rootElement.shadowRoot.querySelector('.sv-tag-buttons'); elem = tagElement.getElementsByClassName("ff-button")[0] as HTMLElement; } else { const overlayElement = this.rootElement.shadowRoot.querySelector('ff-viewport-overlay'); elem = overlayElement.querySelector('[tabindex="0"]'); } if(elem) { elem.focus(); } this._needsAnnoFocus = false; } return false; } fromData(data: IViewer) { const ins = this.ins; ins.copyValues({ shader: EShaderMode[data.shader] || EShaderMode.Default, exposure: data.exposure !== undefined ? data.exposure : ins.exposure.schema.preset, toneMapping: data.toneMapping || false, gamma: data.gamma !== undefined ? data.gamma : ins.gamma.schema.preset, isWallMountAR: data.isWallMountAR || false, arScale: data.arScale || 1.0, annotationsVisible: !!data.annotationsVisible, activeTags: data.activeTags || "", sortedTags: data.sortedTags || "", radioTags: data.radioTags !== undefined ? !!data.radioTags : ins.radioTags.schema.preset, }); } toData(): IViewer { const ins = this.ins; const data: Partial = { shader: EShaderMode[ins.shader.value] as TShaderMode, exposure: ins.exposure.value, toneMapping: ins.toneMapping.value, gamma: ins.gamma.value, isWallMountAR: ins.isWallMountAR.value, arScale: ins.arScale.value }; if (ins.annotationsVisible.value) { data.annotationsVisible = true; } if (ins.activeTags.value) { data.activeTags = ins.activeTags.value; } if (ins.sortedTags.value) { data.sortedTags = ins.sortedTags.value; } if (ins.radioTags.value) { data.radioTags = ins.radioTags.value; } return data as IViewer; } protected refreshTagCloud() { const tagCloud = new Set(); const models = this.getGraphComponents(CVModel2); models.forEach(model => { const tags = model.ins.tags.value.split(",").map(tag => tag.trim()).filter(tag => tag); tags.forEach(tag => tagCloud.add(tag)); }); const lights = this.getGraphComponents(CLight); lights.forEach(light => { const tags = light.ins.tags.value.split(",").map(tag => tag.trim()).filter(tag => tag); tags.forEach(tag => tagCloud.add(tag)); }); const views = this.getGraphComponents(CVAnnotationView); views.forEach(component => { const annotations = component.getAnnotations(); annotations.forEach(annotation => { const tags = annotation.tags; tags.forEach(tag => { if(tag !== "Missing content") { tagCloud.add(tag) } }); }); }); const tagArray = Array.from(tagCloud); const sortedTags = this.ins.sortedTags.value.split(",").map(tag => tag.trim()).filter(tag => tag); tagArray.sort((a, b) => { const aIndex = sortedTags.indexOf(a); const bIndex = sortedTags.indexOf(b); return aIndex < bIndex ? -1 : (aIndex > bIndex ? 1 : 0); }); this.outs.tagCloud.setValue(tagArray.join(", ")); if (ENV_DEVELOPMENT) { console.log("CVViewer.refreshTagCloud - %s", tagArray.join(", ")); } } protected onTagCloudUpdate() { this.distributeTagOptions(); this.ins.activeTags.set(); this.ins.annotationsVisible.set(); } /** * Pushes the current tag cloud as `schema.options` onto every tag-semantic * input property in the graph. Consumers (PropertyTags) pick it up through * the "change" event emitted by Property.setOptions(). */ protected distributeTagOptions() { const options = this.parseTagCloud(); this.graph.components.getArray().forEach(component => { this.seedComponentTagOptions(component, options); }); } protected parseTagCloud(): string[] { return this.outs.tagCloud.value .split(",").map(t => t.trim()).filter(Boolean); } protected seedComponentTagOptions(component: Component, options?: string[]) { const opts = options || this.parseTagCloud(); component.ins.properties.forEach(property => { if (property.schema.semantic === "tags" && property.schema.options) { property.setOptions(opts); } }); } protected onAnyComponent(event: IComponentEvent) { if (event.add) { this.seedComponentTagOptions(event.object); } } protected onAnnotationClick(event: IAnnotationClickEvent) { const id = event.annotation ? event.annotation.id : ""; this.ins.activeAnnotation.setValue(id); this.dispatch('annotation-active', id); } protected onModelComponent(event: IComponentEvent) { const component = event.object; if (event.add) { component.on("tag-update", this.refreshTagCloud, this); component.on("model-load", this.onModelLoad, this); component.on("model-error", this.onModelError, this); } else if (event.remove) { component.off("tag-update", this.refreshTagCloud, this); component.off("model-load", this.onModelLoad, this); component.off("model-error", this.onModelError, this); this._loadedModels.delete(component); } this.updateSceneContentLoaded(); } protected onAnnotationsComponent(event: IComponentEvent) { const component = event.object; if (event.add) { component.on("tag-update", this.refreshTagCloud, this); component.on("active-tag-update", this.focusTags, this); component.on("click", this.onAnnotationClick, this); component.ins.visible.setValue(this.ins.annotationsVisible.value); } else if (event.remove) { component.off("tag-update", this.refreshTagCloud, this); component.off("active-tag-update", this.focusTags, this); component.off("click", this.onAnnotationClick, this); } } protected onLanguageComponent(event: IComponentEvent) { const component = event.object; if (event.add) { component.on("tag-update", this.refreshTagCloud, this); } else if (event.remove) { component.off("tag-update", this.refreshTagCloud, this); } } protected onLightComponent(event: IComponentEvent) { const component = event.object; if (event.add) { component.on("tag-update", this.refreshTagCloud, this); } else if (event.remove) { component.off("tag-update", this.refreshTagCloud, this); } } protected onModelLoad(event: IModelLoadEvent) { this.dispatch('model-load', EDerivativeQuality[event.quality] as TDerivativeQuality); this.refreshTagCloud(); // update variant list const variantSet = new Set(this.ins.variant.schema.options); const models = this.getGraphComponents(CVModel2); models.forEach(model => { model.ins.variant.schema.options.forEach(variantSet.add, variantSet); }); this.ins.variant.setOptions([...variantSet]); this.updateSceneContentLoaded(); // Track initial scene loading so the spinner is only shown once, during the initial load. // A model counts as loaded when it has loaded the derivative it currently requests: // - with LOD enabled, that is the quality picked by the derivatives controller // - otherwise it is the scene quality. if(this.assetManager.outs.initialLoad.value) { const lodEnabled = this.getGraphComponent(CVSetup).derivatives.ins.enabled.value; const requestedQuality = lodEnabled ? event.model.ins.quality.value : event.model.derivatives.select(EDerivativeUsage.Web3D, this.ins.quality.value).data.quality; if(event.quality === requestedQuality) { this._loadedModels.add(event.model); if(models.every(model => this._loadedModels.has(model))) { this.analytics.sendProperty("Loading_Time", this.analytics.getTimerTime()/1000); this.analytics.resetTimer(); this.assetManager.outs.initialLoad.setValue(false); this.outs.sceneLoaded.setValue(true); this.dispatch('scene-load'); } } } } protected onModelError(event: IModelErrorEvent) { this.dispatch('model-error', event.error.message); this.updateSceneContentLoaded(); } /** * Dispatches "scene-content-load" once every model in the scene has something to display, * whatever its quality. {@link CVModel2.isSettled}. * * Called on every "model-load" and "model-error", and whenever the set of models changes. * Tracks the reverse transition too, so that a scene which gains a model - through * appendModel() or a document reload - dispatches again once that model has settled. */ protected updateSceneContentLoaded() { const models = this.getGraphComponents(CVModel2); // An empty scene has no content: nothing has been asked of it yet. const contentLoaded = models.length > 0 && models.every(model => model.isSettled); if (contentLoaded === this.outs.sceneContentLoaded.value) { return; } this.outs.sceneContentLoaded.setValue(contentLoaded); if (contentLoaded) { this.dispatch('scene-content-load'); } } protected focusTags() { const ins = this.ins; const setup = this.getGraphComponent(CVSetup); if(setup && setup.tours.ins.enabled.value) { return; } const overlayElement = this.rootElement.shadowRoot.querySelector('ff-viewport-overlay') as HTMLElement; const elems = getFocusableElements(overlayElement) as HTMLElement[]; if(ins.annotationFocus.value && elems.length > 0) { elems[0].focus(); ins.annotationFocus.setValue(false); } } }