/**
* 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 ManipTarget from "@ff/browser/ManipTarget"
import System from "@ff/graph/System";
import RenderQuadView, { ILayoutChange } from "@ff/scene/RenderQuadView";
import SystemView, { customElement, html } from "@ff/scene/ui/SystemView";
import Notification from "@ff/ui/Notification";
import QuadSplitter, { EQuadViewLayout, IQuadSplitterChangeMessage } from "@ff/ui/QuadSplitter";
import CVDocumentProvider from "client/components/CVDocumentProvider";
import CVOrbitNavigation, { EKeyNavMode } from "client/components/CVOrbitNavigation";
import CVSetup from "client/components/CVSetup";
import {getFocusableElements, focusTrap} from "../utils/focusHelpers";
////////////////////////////////////////////////////////////////////////////////
/**
* Displays up to four viewports rendering 3D content from a node/component system.
* The built-in quad split functionality provides four different layouts: single view, horizontal split,
* vertical split, and quad split. The split proportions can be adjusted by moving the split handles
* between viewports.
*/
@customElement("sv-scene-view")
export default class SceneView extends SystemView
{
protected manipTarget: ManipTarget;
protected view: RenderQuadView = null;
protected canvas: HTMLCanvasElement = null;
protected overlay: HTMLDivElement = null;
protected srAnnouncement: HTMLDivElement = null;
protected splitter: QuadSplitter = null;
protected resizeObserver: ResizeObserver = null;
protected pointerEventsEnabled: boolean = false;
protected measuring: boolean = false;
getView() : RenderQuadView
{
return this.view;
}
constructor(system?: System)
{
super(system);
//this.onResize = this.onResize.bind(this);
this.onPointerUpOrCancel = this.onPointerUpOrCancel.bind(this);
this.onKeyDownOverlay = this.onKeyDownOverlay.bind(this);
this.manipTarget = new ManipTarget();
this.addEventListener("pointerdown", this.onPointerDown);
this.addEventListener("pointermove", this.manipTarget.onPointerMove);
this.addEventListener("pointerup", this.onPointerUpOrCancel);
this.addEventListener("pointercancel", this.onPointerUpOrCancel);
this.ownerDocument.addEventListener("pointermove", this.manipTarget.onPointerMove); // To catch out of frame drag releases
this.ownerDocument.addEventListener("pointerup", this.onPointerUpOrCancel); // To catch out of frame drag releases
this.ownerDocument.addEventListener("pointercancel", this.onPointerUpOrCancel); // To catch out of frame drag releases
this.addEventListener("wheel", this.manipTarget.onWheel);
this.addEventListener("contextmenu", this.manipTarget.onContextMenu);
this.addEventListener("keydown", this.manipTarget.onKeyDown);
this.pointerEventsEnabled = true;
}
protected firstConnected()
{
this.classList.add("sv-scene-view");
// disable default touch action on mobile devices
this.style.touchAction = "none";
this.setAttribute("touch-action", "none");
this.tabIndex = 0;
this.id = "sv-scene"
this.ariaLabel = "Interactive 3D Model. Use mouse, touch, or arrow keys to rotate. Escape key to exit annotations.";
this.setAttribute("role", "application"),
// Add screen readertext only element
this.srAnnouncement = this.appendElement("div");
this.srAnnouncement.classList.add("sr-only");
this.srAnnouncement.setAttribute("aria-live", "polite");
this.srAnnouncement.setAttribute("id", "sceneview-sr");
this.canvas = this.appendElement("canvas", {
display: "block",
width: "100%",
height: "100%"
});
this.overlay = this.appendElement("div", {
position: "absolute",
top: "0", bottom: "0", left: "0", right: "0",
overflow: "hidden"
});
this.overlay.classList.add("sv-content-overlay");
this.overlay.addEventListener("keydown", this.onKeyDownOverlay);
// Check that WebGL2 is enabled
if(!this.canvas.getContext('webgl2')) {
this.overlay.innerHTML = "
WebGL2 is required and unavailable in your browser.
"
+ "Please see
this WebGL2 test page for more information.
";
throw new Error("WebGL2 unavailable. Try updating drivers and/or browser.");
}
this.splitter = this.appendElement(QuadSplitter, {
position: "absolute",
top: "0", bottom: "0", left: "0", right: "0",
overflow: "hidden"
});
this.splitter.onChange = (message: IQuadSplitterChangeMessage) => {
this.view.horizontalSplit = message.horizontalSplit;
this.view.verticalSplit = message.verticalSplit;
};
this.view = new RenderQuadView(this.system, this.canvas, this.overlay);
this.view.on("layout", event => {this.splitter.layout = event.layout; this.dispatchEvent(new CustomEvent("layout"))});
this.view.layout = EQuadViewLayout.Single;
this.splitter.layout = EQuadViewLayout.Single;
this.manipTarget.next = this.view;
}
protected connected()
{
this.view.attach();
if(!this.resizeObserver) {
this.resizeObserver = new ResizeObserver(() => this.view.resize());
}
this.resizeObserver.observe(this.view.renderer.domElement);
this.system.getMainComponent(CVDocumentProvider).activeComponent.setup.navigation.ins.pointerEnabled.on("value", this.enablePointerEvents, this);
this.system.getComponent(CVOrbitNavigation).ins.keyNavActive.on("value", this.onKeyboardNavigation, this);
this.system.getComponent(CVSetup).tape.ins.enabled.on("value", this.onMeasure, this);
}
protected disconnected()
{
this.resizeObserver.disconnect();
this.system.getComponent(CVSetup).tape.ins.enabled.off("value", this.onMeasure, this);
this.system.getComponent(CVOrbitNavigation).ins.keyNavActive.off("value", this.onKeyboardNavigation, this);
this.system.getMainComponent(CVDocumentProvider).activeComponent.setup.navigation.ins.pointerEnabled.off("value", this.enablePointerEvents, this);
this.view.detach();
}
protected enablePointerEvents() {
const needsEnabled = this.system.getMainComponent(CVDocumentProvider).activeComponent.setup.navigation.ins.pointerEnabled.value;
if(needsEnabled && !this.pointerEventsEnabled) {
this.addEventListener("pointerdown", this.onPointerDown);
this.addEventListener("pointermove", this.manipTarget.onPointerMove);
this.addEventListener("pointerup", this.onPointerUpOrCancel);
this.addEventListener("pointercancel", this.onPointerUpOrCancel);
this.ownerDocument.addEventListener("pointermove", this.manipTarget.onPointerMove); // To catch out of frame drag releases
this.ownerDocument.addEventListener("pointerup", this.onPointerUpOrCancel); // To catch out of frame drag releases
this.ownerDocument.addEventListener("pointercancel", this.onPointerUpOrCancel); // To catch out of frame drag releases
this.addEventListener("wheel", this.manipTarget.onWheel);
this.addEventListener("contextmenu", this.manipTarget.onContextMenu);
this.addEventListener("keydown", this.manipTarget.onKeyDown);
// disable default touch action on mobile devices
this.style.touchAction = "none";
this.setAttribute("touch-action", "none");
this.pointerEventsEnabled = true;
}
else if(!needsEnabled && this.pointerEventsEnabled) {
this.removeEventListener("pointerdown", this.onPointerDown);
this.removeEventListener("pointermove", this.manipTarget.onPointerMove);
this.removeEventListener("pointerup", this.onPointerUpOrCancel);
this.removeEventListener("pointercancel", this.onPointerUpOrCancel);
this.ownerDocument.removeEventListener("pointermove", this.manipTarget.onPointerMove); // To catch out of frame drag releases
this.ownerDocument.removeEventListener("pointerup", this.onPointerUpOrCancel); // To catch out of frame drag releases
this.ownerDocument.removeEventListener("pointercancel", this.onPointerUpOrCancel); // To catch out of frame drag releases
this.removeEventListener("wheel", this.manipTarget.onWheel);
this.removeEventListener("contextmenu", this.manipTarget.onContextMenu);
this.removeEventListener("keydown", this.manipTarget.onKeyDown);
// enable default touch action on mobile devices
this.style.touchAction = "auto";
this.setAttribute("touch-action", "auto");
this.pointerEventsEnabled = false;
this.style.cursor = "default";
}
}
protected onPointerDown(event: PointerEvent) {
if(this.pointerEventsEnabled) {
this.style.cursor = this.style.cursor == "default" ? "default" : "grabbing";
this.manipTarget.onPointerDown(event);
}
}
protected onPointerUpOrCancel(event: PointerEvent) {
if(this.pointerEventsEnabled) {
this.style.cursor = this.style.cursor == "default" ? "default" : "grab";
this.manipTarget.onPointerUpOrCancel(event);
}
}
protected onKeyboardNavigation() {
const navIns = this.system.getComponent(CVOrbitNavigation).ins;
const activeNavMode = navIns.keyNavActive.value;
switch(activeNavMode) {
case EKeyNavMode.Orbit:
this.srAnnouncement.textContent = "Orbit " + navIns.orbit.value[0].toFixed(0) + ", " +
navIns.orbit.value[1].toFixed(0) + ", " + navIns.orbit.value[2].toFixed(0);
break;
case EKeyNavMode.Pan:
case EKeyNavMode.Zoom:
this.srAnnouncement.textContent = "Offset " + navIns.offset.value[0].toFixed(0) + ", " +
navIns.offset.value[1].toFixed(0) + ", " + navIns.offset.value[2].toFixed(0);
}
}
protected onMeasure() {
this.measuring = this.system.getComponent(CVSetup).tape.ins.enabled.value;
this.style.cursor = this.measuring ? "default" : "grab";
}
protected onKeyDownOverlay(e: KeyboardEvent)
{
const viewer = this.system.getComponent(CVSetup).viewer;
if (e.code === "Escape") {
e.preventDefault();
if(viewer.outs.tagCloud.value.length > 0) {
const tagElement = viewer.rootElement.shadowRoot.querySelector('.sv-tag-buttons');
const elem = tagElement.getElementsByClassName("ff-button")[0] as HTMLElement;
if(elem) {
elem.focus();
}
}
else {
this.system.getComponent(CVSetup).viewer.ins.annotationExit.set();
}
}
else if(e.code === "Tab") {
focusTrap(getFocusableElements(this.overlay) as HTMLElement[], e, true);
}
}
/*protected onResize()
{
this.view.resize();
}*/
}