From 71b14e6eafa11135fd04069fc4d366ecf9e4715d Mon Sep 17 00:00:00 2001 From: MickaelK Date: Mon, 23 Dec 2024 08:02:12 +1100 Subject: [PATCH] feature (3d): embed 3d viewer anywhere This contains a bunch of things packaged in 1: 1) UI improvements for the 3D viewer to support all sort of file types and create a nice rendering in a clean way with all sort of options 2) enable people to use Filestash as an SDK so we can embed the 3d viewer elsewhere --- public/assets/components/sidebar.js | 4 +- public/assets/helpers/sdk.js | 15 + public/assets/index.js | 54 ++ public/assets/lib/ajax.js | 7 + public/assets/lib/settings.js | 4 +- public/assets/lib/vendor/three/viewcube.js | 899 ++++++++++++++++++ public/assets/pages/ctrl_error.js | 11 +- public/assets/pages/ctrl_viewerpage.js | 4 +- public/assets/pages/filespage/ctrl_submenu.js | 3 +- .../pages/viewerpage/application_3d.css | 48 +- .../assets/pages/viewerpage/application_3d.js | 143 ++- .../pages/viewerpage/application_3d/init.js | 137 +++ .../viewerpage/application_3d/scene_cube.js | 23 + .../viewerpage/application_3d/scene_light.js | 38 + .../viewerpage/application_3d/toolbar.js | 97 ++ public/assets/pages/viewerpage/common.js | 2 +- .../pages/viewerpage/component_menubar.js | 12 +- server/middleware/http.go | 14 + server/routes.go | 8 +- 19 files changed, 1417 insertions(+), 106 deletions(-) create mode 100644 public/assets/helpers/sdk.js create mode 100644 public/assets/index.js create mode 100644 public/assets/lib/vendor/three/viewcube.js create mode 100644 public/assets/pages/viewerpage/application_3d/init.js create mode 100644 public/assets/pages/viewerpage/application_3d/scene_cube.js create mode 100644 public/assets/pages/viewerpage/application_3d/scene_light.js create mode 100644 public/assets/pages/viewerpage/application_3d/toolbar.js diff --git a/public/assets/components/sidebar.js b/public/assets/components/sidebar.js index 024a80d8..70b7fd70 100644 --- a/public/assets/components/sidebar.js +++ b/public/assets/components/sidebar.js @@ -102,8 +102,8 @@ async function ctrlNavigationPane(render, { $sidebar, nRestart }) { $anchor.appendChild($list); } catch (err) { await cache().remove("/", false); - if (nRestart < 2) ctrlSidebar(render, nRestart + 1); - else if (err instanceof DOMException) {} + if (err instanceof DOMException) return; + else if (nRestart < 2) ctrlSidebar(render, nRestart + 1); else throw err; } } diff --git a/public/assets/helpers/sdk.js b/public/assets/helpers/sdk.js new file mode 100644 index 00000000..4754f4b7 --- /dev/null +++ b/public/assets/helpers/sdk.js @@ -0,0 +1,15 @@ +// feature detection if we're using Filestash as a standalone app or as an SDK +// see: ../index.js + +export function isSDK() { + const importURL = new URL(import.meta.url); + return location.origin !== importURL.origin; +} + +export function urlSDK(url) { + const importURL = new URL(import.meta.url); + if (new RegExp("^/").test(url) === false) { + url = "/" + url + } + return importURL.origin + url; +} diff --git a/public/assets/index.js b/public/assets/index.js new file mode 100644 index 00000000..20b90f79 --- /dev/null +++ b/public/assets/index.js @@ -0,0 +1,54 @@ +// Want to integrate Filestash as a SDK in your application? You are in the right place! +// +// How it works you may ask? it's simple: +// 1) pick a component to render. Filestash components look like this: +// function(render, opts = {}) { +// // ... +// } +// 2) similarly to every framework out there, call the framework bootstrap procedure: +// render(Component, $node, args = {}); +// +// +// /***********************************************/ +// /* example to render the 3D viewer application */ +// /***********************************************/ +// import { render } from "https://demo.filestash.app/assets/index.js"; +// import * as Component from "https://demo.filestash.app/assets/pages/viewerpage/application_3d.js"; +// +// render(Component, document.getElementById("app"), {}); +// +// +// +import { createRender, createElement } from "./lib/skeleton/index.js"; +import { loadCSS } from "./helpers/loader.js"; + +export function render(module, $app, opts = {}) { + assertArgs(module, $app); + execute(module, $app, opts); +} + +function assertArgs(module, $app) { + if (typeof module.default !== "function") throw new TypeError("Unsupported module - see documentation on how to use Filestash"); + else if (!($app instanceof Node)) throw new TypeError("Invalid node - see documentation on how to use Filestash"); +} + +function execute(module, $app, opts) { + const priors = [ + import("./boot/ctrl_boot_frontoffice.js"), + loadCSS(import.meta.url, "./css/designsystem.css"), + ]; + if (typeof module.init === "function") priors.push(module.init($app)); + + return Promise.all(priors) + .then(async() => await module.default(createRender($app), opts)) + .then(() => $app.appendChild(poweredBy())) + .catch((err) => console.error(err)); +} + +function poweredBy($app) { + return createElement(` +
+ Powered By Filestash +
+ `); +} diff --git a/public/assets/lib/ajax.js b/public/assets/lib/ajax.js index ce7ced70..4840a4c3 100644 --- a/public/assets/lib/ajax.js +++ b/public/assets/lib/ajax.js @@ -1,5 +1,6 @@ import rxjs, { ajax } from "./rx.js"; import { AjaxError } from "./error.js"; +import { isSDK, urlSDK } from "../helpers/sdk.js"; export default function(opts) { if (typeof opts === "string") opts = { url: opts, withCredentials: true }; @@ -7,6 +8,12 @@ export default function(opts) { if (!opts.headers) opts.headers = {}; opts.headers["X-Requested-With"] = "XmlHttpRequest"; if (window.BEARER_TOKEN) opts.headers["Authorization"] = `Bearer ${window.BEARER_TOKEN}`; + + if (isSDK()) { + if (["/api/config"].indexOf(opts.url) === -1) opts.withCredentials = false; + opts.url = urlSDK(opts.url); + } + return ajax({ withCredentials: true, ...opts, responseType: "text" }).pipe( rxjs.map((res) => { const result = res.xhr.responseText; diff --git a/public/assets/lib/settings.js b/public/assets/lib/settings.js index d5843585..901c976f 100644 --- a/public/assets/lib/settings.js +++ b/public/assets/lib/settings.js @@ -1,8 +1,8 @@ const settings = JSON.parse(window.localStorage.getItem("settings") || "null") || {}; -export function settings_get(key) { +export function settings_get(key, def = null) { if (settings[key] === undefined) { - return null; + return def; } return settings[key]; } diff --git a/public/assets/lib/vendor/three/viewcube.js b/public/assets/lib/vendor/three/viewcube.js new file mode 100644 index 00000000..f3071171 --- /dev/null +++ b/public/assets/lib/vendor/three/viewcube.js @@ -0,0 +1,899 @@ +import * as THREE from "./three.module.js"; +const DEFAULT_FACENAMES = { + top: "TOP", + front: "FRONT", + right: "RIGHT", + back: "BACK", + left: "LEFT", + bottom: "BOTTOM" +}; +var ObjectPosition = /* @__PURE__ */ ((ObjectPosition2) => { + ObjectPosition2[ObjectPosition2["LEFT_BOTTOM"] = 0] = "LEFT_BOTTOM"; + ObjectPosition2[ObjectPosition2["LEFT_TOP"] = 1] = "LEFT_TOP"; + ObjectPosition2[ObjectPosition2["RIGHT_TOP"] = 2] = "RIGHT_TOP"; + ObjectPosition2[ObjectPosition2["RIGHT_BOTTOM"] = 4] = "RIGHT_BOTTOM"; + return ObjectPosition2; +})(ObjectPosition || {}); +class FixedPosGizmo extends THREE.Object3D { + /** + * Construct one instance of this gizmo + * @param camera Camera used in your canvas + * @param renderer Renderer used in your canvas + * @param dimension Size of area ocupied by this gizmo. Because width and height of this area is same, + * it is single value. The real size of the objet will be calculated automatically considering rotation. + * @param pos Position of the gizmo + */ + constructor(camera, renderer, dimension = 150, pos = 2) { + super(); + this.camera = camera; + this.renderer = renderer; + this.gizmoCamera = new THREE.OrthographicCamera(-2, 2, 2, -2, 0, 4); + this.gizmoCamera.position.set(0, 0, 2); + this.gizmoDim = dimension; + this.gizmoPos = pos; + this.initialize(); + } + /** + * Function called by constructor to initialize this gizmo. The children class can override this function + * to add its own initialization logic. + */ + initialize() { + } + /** + * Update and rerender this gizmo + */ + update() { + this.updateOrientation(); + const autoClear = this.renderer.autoClear; + this.renderer.autoClear = false; + this.renderer.clearDepth(); + const viewport = new THREE.Vector4(); + this.renderer.getViewport(viewport); + const pos = this.calculateViewportPos(); + this.renderer.setViewport(pos.x, pos.y, this.gizmoDim, this.gizmoDim); + this.renderer.render(this, this.gizmoCamera); + this.renderer.setViewport(viewport.x, viewport.y, viewport.z, viewport.w); + this.renderer.autoClear = autoClear; + } + /** + * Free the GPU-related resources allocated by this instance. Call this method whenever this instance + * is no longer used in your app. + */ + dispose() { + } + updateOrientation() { + this.quaternion.copy(this.camera.quaternion).invert(); + this.updateMatrixWorld(); + } + calculatePosInViewport(offsetX, offsetY, bbox) { + const x = (offsetX - bbox.min.x) / this.gizmoDim * 2 - 1; + const y = -((offsetY - bbox.min.y) / this.gizmoDim) * 2 + 1; + return { x, y }; + } + calculateViewportPos() { + const domElement = this.renderer.domElement; + const canvasWidth = domElement.offsetWidth; + const canvasHeight = domElement.offsetHeight; + const pos = this.gizmoPos; + const length = this.gizmoDim; + let x = canvasWidth - length; + let y = canvasHeight - length; + switch (pos) { + case 0: + x = 0; + y = 0; + break; + case 1: + x = 0; + break; + case 4: + y = 0; + break; + } + return { x, y }; + } + calculateViewportBbox() { + const domElement = this.renderer.domElement; + const canvasWidth = domElement.offsetWidth; + const canvasHeight = domElement.offsetHeight; + const pos = this.gizmoPos; + const length = this.gizmoDim; + const bbox = new THREE.Box2( + new THREE.Vector2(canvasWidth - length, 0), + new THREE.Vector2(canvasWidth, length) + ); + switch (pos) { + case 0: + bbox.set( + new THREE.Vector2(0, canvasHeight - length), + new THREE.Vector2(length, canvasHeight) + ); + break; + case 1: + bbox.set(new THREE.Vector2(0, 0), new THREE.Vector2(length, length)); + break; + case 4: + bbox.set( + new THREE.Vector2(canvasWidth - length, canvasHeight - length), + new THREE.Vector2(canvasWidth, canvasHeight) + ); + break; + } + return bbox; + } +} +function createTextTexture(text, props) { + const fontface = props.font || "Helvetica"; + const fontsize = props.fontSize || 30; + const width = props.width || 200; + const height = props.height || 200; + const bgColor = props.bgColor ? props.bgColor.join(", ") : "255, 255, 255, 1.0"; + const fgColor = props.color ? props.color.join(", ") : "0, 0, 0, 1.0"; + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext("2d"); + if (context) { + context.font = `bold ${fontsize}px ${fontface}`; + context.fillStyle = `rgba(${bgColor})`; + context.fillRect(0, 0, width, height); + const metrics = context.measureText(text); + const textWidth = metrics.width; + context.fillStyle = `rgba(${fgColor})`; + context.fillText( + text, + width / 2 - textWidth / 2, + height / 2 + fontsize / 2 - 2 + ); + } + const texture = new THREE.Texture(canvas); + texture.minFilter = THREE.LinearFilter; + texture.needsUpdate = true; + return texture; +} +function createTextSprite(text) { + const texture = createTextTexture(text, { + fontSize: 100, + font: "Arial Narrow, sans-serif", + color: [255, 255, 255, 1], + bgColor: [0, 0, 0, 0] + }); + const material = new THREE.SpriteMaterial({ map: texture, transparent: true }); + return new THREE.Sprite(material); +} +function createFaceMaterials(faceNames = DEFAULT_FACENAMES) { + const materials = [ + { + name: FACES.FRONT, + map: createTextTexture(faceNames.front, { + fontSize: 55, + font: "Arial Narrow, sans-serif", + color: ["87", "89", "90", "1"] + }) + }, + { + name: FACES.RIGHT, + map: createTextTexture(faceNames.right, { + fontSize: 55, + font: "Arial Narrow, sans-serif", + color: ["87", "89", "90", "1"] + }) + }, + { + name: FACES.BACK, + map: createTextTexture(faceNames.back, { + fontSize: 55, + font: "Arial Narrow, sans-serif", + color: ["87", "89", "90", "1"] + }) + }, + { + name: FACES.LEFT, + map: createTextTexture(faceNames.left, { + fontSize: 55, + font: "Arial Narrow, sans-serif", + color: ["87", "89", "90", "1"] + }) + }, + { + name: FACES.TOP, + map: createTextTexture(faceNames.top, { + fontSize: 60, + font: "Arial Narrow, sans-serif", + color: ["87", "89", "90", "1"] + }) + }, + { + name: FACES.BOTTOM, + map: createTextTexture(faceNames.bottom, { + fontSize: 48, + font: "Arial Narrow, sans-serif", + color: ["87", "89", "90", "1"] + }) + } + ]; + return materials; +} +const FACES = { + TOP: "1", + FRONT: "2", + RIGHT: "3", + BACK: "4", + LEFT: "5", + BOTTOM: "6", + TOP_FRONT_EDGE: "7", + TOP_RIGHT_EDGE: "8", + TOP_BACK_EDGE: "9", + TOP_LEFT_EDGE: "10", + FRONT_RIGHT_EDGE: "11", + BACK_RIGHT_EDGE: "12", + BACK_LEFT_EDGE: "13", + FRONT_LEFT_EDGE: "14", + BOTTOM_FRONT_EDGE: "15", + BOTTOM_RIGHT_EDGE: "16", + BOTTOM_BACK_EDGE: "17", + BOTTOM_LEFT_EDGE: "18", + TOP_FRONT_RIGHT_CORNER: "19", + TOP_BACK_RIGHT_CORNER: "20", + TOP_BACK_LEFT_CORNER: "21", + TOP_FRONT_LEFT_CORNER: "22", + BOTTOM_FRONT_RIGHT_CORNER: "23", + BOTTOM_BACK_RIGHT_CORNER: "24", + BOTTOM_BACK_LEFT_CORNER: "25", + BOTTOM_FRONT_LEFT_CORNER: "26" +}; +const CORNER_FACES = [ + { name: FACES.TOP_FRONT_RIGHT_CORNER }, + { name: FACES.TOP_BACK_RIGHT_CORNER }, + { name: FACES.TOP_BACK_LEFT_CORNER }, + { name: FACES.TOP_FRONT_LEFT_CORNER }, + { name: FACES.BOTTOM_BACK_RIGHT_CORNER }, + { name: FACES.BOTTOM_FRONT_RIGHT_CORNER }, + { name: FACES.BOTTOM_FRONT_LEFT_CORNER }, + { name: FACES.BOTTOM_BACK_LEFT_CORNER } +]; +const EDGE_FACES = [ + { name: FACES.TOP_FRONT_EDGE }, + { name: FACES.TOP_RIGHT_EDGE }, + { name: FACES.TOP_BACK_EDGE }, + { name: FACES.TOP_LEFT_EDGE }, + // flip back and front bottom edges + { name: FACES.BOTTOM_BACK_EDGE }, + { name: FACES.BOTTOM_RIGHT_EDGE }, + { name: FACES.BOTTOM_FRONT_EDGE }, + { name: FACES.BOTTOM_LEFT_EDGE } +]; +const EDGE_FACES_SIDE = [ + { name: FACES.FRONT_RIGHT_EDGE }, + { name: FACES.BACK_RIGHT_EDGE }, + { name: FACES.BACK_LEFT_EDGE }, + { name: FACES.FRONT_LEFT_EDGE } +]; +class ViewCube extends THREE.Object3D { + /** + * Construct one instance of view cube 3d object + * @param cubeSize Size of area ocupied by view cube + * @param borderSize Border size of view cube + * @param isShowOutline Flag to decide whether to show edge of view cube + * @param faceColor Face color of view cube + * @param outlineColor Edge color of view cube + * @param faceNames Texts in each face of view cube + */ + constructor(cubeSize = 60, borderSize = 5, isShowOutline = true, faceColor = 13421772, outlineColor = 10066329, faceNames = DEFAULT_FACENAMES) { + super(); + this._cubeSize = cubeSize; + this._borderSize = borderSize; + this._isShowOutline = isShowOutline; + this._faceColor = faceColor; + this._outlineColor = outlineColor; + this.build(faceNames); + } + /** + * Free the GPU-related resources allocated by this instance. Call this method whenever this instance + * is no longer used in your app. + */ + dispose() { + this.children.forEach((child) => { + var _a, _b, _c, _d; + const mesh = child; + (_a = mesh.material) == null ? void 0 : _a.dispose(); + (_c = (_b = mesh.material) == null ? void 0 : _b.map) == null ? void 0 : _c.dispose(); + (_d = mesh.geometry) == null ? void 0 : _d.dispose(); + }); + } + build(faceNames) { + const faceSize = this._cubeSize - this._borderSize * 2; + const faceOffset = this._cubeSize / 2; + const borderSize = this._borderSize; + const cubeFaces = this.createCubeFaces(faceSize, faceOffset); + const faceMaterials = createFaceMaterials(faceNames); + for (const [i, props] of faceMaterials.entries()) { + const face = cubeFaces.children[i]; + const material = face.material; + material.color.setHex(this._faceColor); + material.map = props.map; + face.name = props.name; + } + this.add(cubeFaces); + const corners = []; + for (const [i, props] of CORNER_FACES.entries()) { + const corner = this.createCornerFaces( + borderSize, + faceOffset, + props.name, + { color: this._faceColor } + ); + corner.rotateOnAxis( + new THREE.Vector3(0, 1, 0), + THREE.MathUtils.degToRad(i % 4 * 90) + ); + corners.push(corner); + } + const topCorners = new THREE.Group(); + const bottomCorners = new THREE.Group(); + this.add(topCorners.add(...corners.slice(0, 4))); + this.add( + bottomCorners.add(...corners.slice(4)).rotateOnAxis(new THREE.Vector3(1, 0, 0), Math.PI) + ); + const edges = []; + for (const [i, props] of EDGE_FACES.entries()) { + const edge = this.createHorzEdgeFaces( + faceSize, + borderSize, + faceOffset, + props.name, + { color: this._faceColor } + ); + edge.rotateOnAxis( + new THREE.Vector3(0, 1, 0), + THREE.MathUtils.degToRad(i % 4 * 90) + ); + edges.push(edge); + } + const topEdges = new THREE.Group(); + const bottomEdges = new THREE.Group(); + this.add(topEdges.add(...edges.slice(0, 4))); + this.add( + bottomEdges.add(...edges.slice(4)).rotateOnAxis(new THREE.Vector3(1, 0, 0), Math.PI) + ); + const sideEdges = new THREE.Group(); + for (const [i, props] of EDGE_FACES_SIDE.entries()) { + const edge = this.createVertEdgeFaces( + borderSize, + faceSize, + faceOffset, + props.name, + { color: this._faceColor } + ); + edge.rotateOnAxis( + new THREE.Vector3(0, 1, 0), + THREE.MathUtils.degToRad(i * 90) + ); + sideEdges.add(edge); + } + this.add(sideEdges); + if (this._isShowOutline) { + this.add(this.createCubeOutline(this._cubeSize)); + } + } + createFace(size, position, { axis = [0, 1, 0], angle = 0, name = "", matProps = {} } = {}) { + if (!Array.isArray(size)) size = [size, size]; + const material = new THREE.MeshBasicMaterial(matProps); + const geometry = new THREE.PlaneGeometry(size[0], size[1]); + const face = new THREE.Mesh(geometry, material); + face.name = name; + face.rotateOnAxis( + new THREE.Vector3(...axis), + THREE.MathUtils.degToRad(angle) + ); + face.position.set(position[0], position[1], position[2]); + return face; + } + createCubeFaces(faceSize, offset) { + const faces = new THREE.Object3D(); + faces.add( + this.createFace(faceSize, [0, 0, offset], { axis: [0, 1, 0], angle: 0 }) + ); + faces.add( + this.createFace(faceSize, [offset, 0, 0], { axis: [0, 1, 0], angle: 90 }) + ); + faces.add( + this.createFace(faceSize, [0, 0, -offset], { + axis: [0, 1, 0], + angle: 180 + }) + ); + faces.add( + this.createFace(faceSize, [-offset, 0, 0], { + axis: [0, 1, 0], + angle: 270 + }) + ); + faces.add( + this.createFace(faceSize, [0, offset, 0], { + axis: [1, 0, 0], + angle: -90 + }) + ); + faces.add( + this.createFace(faceSize, [0, -offset, 0], { + axis: [1, 0, 0], + angle: 90 + }) + ); + return faces; + } + createCornerFaces(faceSize, offset, name = "", matProps = {}) { + const corner = new THREE.Object3D(); + const borderOffset = offset - faceSize / 2; + corner.add( + this.createFace(faceSize, [borderOffset, borderOffset, offset], { + axis: [0, 1, 0], + angle: 0, + matProps, + name + }) + ); + corner.add( + this.createFace(faceSize, [offset, borderOffset, borderOffset], { + axis: [0, 1, 0], + angle: 90, + matProps, + name + }) + ); + corner.add( + this.createFace(faceSize, [borderOffset, offset, borderOffset], { + axis: [1, 0, 0], + angle: -90, + matProps, + name + }) + ); + return corner; + } + createHorzEdgeFaces(w, h, offset, name = "", matProps = {}) { + const edge = new THREE.Object3D(); + const borderOffset = offset - h / 2; + edge.add( + this.createFace([w, h], [0, borderOffset, offset], { + axis: [0, 1, 0], + angle: 0, + name, + matProps + }) + ); + edge.add( + this.createFace([w, h], [0, offset, borderOffset], { + axis: [1, 0, 0], + angle: -90, + name, + matProps + }) + ); + return edge; + } + createVertEdgeFaces(w, h, offset, name = "", matProps = {}) { + const edge = new THREE.Object3D(); + const borderOffset = offset - w / 2; + edge.add( + this.createFace([w, h], [borderOffset, 0, offset], { + axis: [0, 1, 0], + angle: 0, + name, + matProps + }) + ); + edge.add( + this.createFace([w, h], [offset, 0, borderOffset], { + axis: [0, 1, 0], + angle: 90, + name, + matProps + }) + ); + return edge; + } + createCubeOutline(size) { + const geometry = new THREE.BoxGeometry(size, size, size); + const geo = new THREE.EdgesGeometry(geometry); + const mat = new THREE.LineBasicMaterial({ + color: this._outlineColor, + linewidth: 1 + }); + const wireframe = new THREE.LineSegments(geo, mat); + return wireframe; + } +} +const MAIN_COLOR = 0xf9f9fa; +const HOVER_COLOR = 0xececec; +const OUTLINE_COLOR = 13421772; +const DEFAULT_VIEWCUBE_OPTIONS = { + pos: ObjectPosition.RIGHT_TOP, + dimension: 150, + faceColor: MAIN_COLOR, + hoverColor: HOVER_COLOR, + outlineColor: OUTLINE_COLOR, + faceNames: DEFAULT_FACENAMES +}; +class ViewCubeGizmo extends FixedPosGizmo { + /** + * Construct one instance of view cube gizmo + * @param camera Camera used in your canvas + * @param renderer Renderer used in your canvas + * @param options Options to customize view cube gizmo + */ + constructor(camera, renderer, options = DEFAULT_VIEWCUBE_OPTIONS) { + const mergedOptions = { + ...DEFAULT_VIEWCUBE_OPTIONS, + ...options + }; + super(camera, renderer, options.dimension, options.pos); + this.cube = new ViewCube( + 2, + 0.2, + true, + mergedOptions.faceColor, + mergedOptions.outlineColor, + mergedOptions.faceNames + ); + this.add(this.cube); + this.handleMouseMove = this.handleMouseMove.bind(this); + this.handleMouseClick = this.handleMouseClick.bind(this); + this.listen(renderer.domElement); + } + /** + * Free the GPU-related resources allocated by this instance. Call this method whenever this instance + * is no longer used in your app. + */ + dispose() { + this.cube.dispose(); + } + listen(domElement) { + domElement.addEventListener("mousemove", this.handleMouseMove); + domElement.addEventListener("click", this.handleMouseClick); + } + handleMouseClick(event) { + const bbox = this.calculateViewportBbox(); + if (bbox.containsPoint(new THREE.Vector2(event.offsetX, event.offsetY))) { + const pos = this.calculatePosInViewport( + event.offsetX, + event.offsetY, + bbox + ); + this.checkSideTouch(pos.x, pos.y); + } + } + handleMouseMove(event) { + const bbox = this.calculateViewportBbox(); + if (bbox.containsPoint(new THREE.Vector2(event.offsetX, event.offsetY))) { + const pos = this.calculatePosInViewport( + event.offsetX, + event.offsetY, + bbox + ); + this.checkSideOver(pos.x, pos.y); + } + } + checkSideTouch(x, y) { + const raycaster = new THREE.Raycaster(); + raycaster.setFromCamera(new THREE.Vector2(x, y), this.gizmoCamera); + const intersects = raycaster.intersectObjects(this.cube.children, true); + if (intersects.length) { + for (const { object } of intersects) { + if (object.name) { + const quaternion = this.getRotation(object.name); + this.dispatchEvent({ + type: "change", + quaternion + }); + break; + } + } + } + } + checkSideOver(x, y) { + const raycaster = new THREE.Raycaster(); + raycaster.setFromCamera(new THREE.Vector2(x, y), this.gizmoCamera); + const intersects = raycaster.intersectObjects(this.cube.children, true); + this.cube.traverse(function(obj) { + if (obj.name) { + const mesh = obj; + mesh.material.color.setHex(MAIN_COLOR); + } + }); + if (intersects.length) { + for (const { object } of intersects) { + if (object.name) { + object.parent.children.forEach(function(child) { + if (child.name === object.name) { + const mesh = child; + mesh.material.color.setHex( + HOVER_COLOR + ); + } + }); + break; + } + } + } + } + getRotation(side) { + const targetQuaternion = new THREE.Quaternion(); + switch (side) { + case FACES.FRONT: + targetQuaternion.setFromEuler(new THREE.Euler()); + break; + case FACES.RIGHT: + targetQuaternion.setFromEuler(new THREE.Euler(0, Math.PI * 0.5, 0)); + break; + case FACES.BACK: + targetQuaternion.setFromEuler(new THREE.Euler(0, Math.PI, 0)); + break; + case FACES.LEFT: + targetQuaternion.setFromEuler(new THREE.Euler(0, -Math.PI * 0.5, 0)); + break; + case FACES.TOP: + targetQuaternion.setFromEuler(new THREE.Euler(-Math.PI * 0.5, 0, 0)); + break; + case FACES.BOTTOM: + targetQuaternion.setFromEuler(new THREE.Euler(Math.PI * 0.5, 0, 0)); + break; + case FACES.TOP_FRONT_EDGE: + targetQuaternion.setFromEuler(new THREE.Euler(-Math.PI * 0.25, 0, 0)); + break; + case FACES.TOP_RIGHT_EDGE: + targetQuaternion.setFromEuler( + new THREE.Euler(-Math.PI * 0.25, Math.PI * 0.5, 0, "YXZ") + ); + break; + case FACES.TOP_BACK_EDGE: + targetQuaternion.setFromEuler( + new THREE.Euler(-Math.PI * 0.25, Math.PI, 0, "YXZ") + ); + break; + case FACES.TOP_LEFT_EDGE: + targetQuaternion.setFromEuler( + new THREE.Euler(-Math.PI * 0.25, -Math.PI * 0.5, 0, "YXZ") + ); + break; + case FACES.BOTTOM_FRONT_EDGE: + targetQuaternion.setFromEuler(new THREE.Euler(Math.PI * 0.25, 0, 0)); + break; + case FACES.BOTTOM_RIGHT_EDGE: + targetQuaternion.setFromEuler( + new THREE.Euler(Math.PI * 0.25, Math.PI * 0.5, 0, "YXZ") + ); + break; + case FACES.BOTTOM_BACK_EDGE: + targetQuaternion.setFromEuler( + new THREE.Euler(Math.PI * 0.25, Math.PI, 0, "YXZ") + ); + break; + case FACES.BOTTOM_LEFT_EDGE: + targetQuaternion.setFromEuler( + new THREE.Euler(Math.PI * 0.25, -Math.PI * 0.5, 0, "YXZ") + ); + break; + case FACES.FRONT_RIGHT_EDGE: + targetQuaternion.setFromEuler(new THREE.Euler(0, Math.PI * 0.25, 0)); + break; + case FACES.BACK_RIGHT_EDGE: + targetQuaternion.setFromEuler(new THREE.Euler(0, Math.PI * 0.75, 0)); + break; + case FACES.BACK_LEFT_EDGE: + targetQuaternion.setFromEuler(new THREE.Euler(0, -Math.PI * 0.75, 0)); + break; + case FACES.FRONT_LEFT_EDGE: + targetQuaternion.setFromEuler(new THREE.Euler(0, -Math.PI * 0.25, 0)); + break; + case FACES.TOP_FRONT_RIGHT_CORNER: + targetQuaternion.setFromEuler( + new THREE.Euler(-Math.PI * 0.25, -Math.PI * 1.75, 0) + ); + break; + case FACES.TOP_BACK_RIGHT_CORNER: + targetQuaternion.setFromEuler( + new THREE.Euler(Math.PI * 0.25, -Math.PI * 1.25, 0) + ); + break; + case FACES.TOP_BACK_LEFT_CORNER: + targetQuaternion.setFromEuler( + new THREE.Euler(Math.PI * 0.25, -Math.PI * 0.75, 0) + ); + break; + case FACES.TOP_FRONT_LEFT_CORNER: + targetQuaternion.setFromEuler( + new THREE.Euler(-Math.PI * 0.25, -Math.PI * 0.25, 0) + ); + break; + case FACES.BOTTOM_FRONT_RIGHT_CORNER: + targetQuaternion.setFromEuler( + new THREE.Euler(Math.PI * 0.25, -Math.PI * 1.75, 0) + ); + break; + case FACES.BOTTOM_BACK_RIGHT_CORNER: + targetQuaternion.setFromEuler( + new THREE.Euler(-Math.PI * 0.25, -Math.PI * 1.25, 0) + ); + break; + case FACES.BOTTOM_BACK_LEFT_CORNER: + targetQuaternion.setFromEuler( + new THREE.Euler(-Math.PI * 0.25, -Math.PI * 0.75, 0) + ); + break; + case FACES.BOTTOM_FRONT_LEFT_CORNER: + targetQuaternion.setFromEuler( + new THREE.Euler(Math.PI * 0.25, -Math.PI * 0.25, 0) + ); + break; + default: + console.error( + `[ViewCubeGizmo]: Invalid face, edge, or corner name '${side}'!` + ); + break; + } + return targetQuaternion; + } +} +const DEFAULT_AXES_OPTIONS = { + pos: ObjectPosition.LEFT_BOTTOM, + size: 100, + hasZAxis: true +}; +class AxesGizmo extends FixedPosGizmo { + constructor(camera, renderer, options) { + const mergedOptions = { + ...DEFAULT_AXES_OPTIONS, + ...options + }; + super(camera, renderer, mergedOptions.size, options.pos); + this.hasZAxis = mergedOptions.hasZAxis; + const vertices = [0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 2, 0]; + const colors = [1, 0, 0, 1, 0.6, 0, 0, 1, 0, 0.6, 1, 0]; + if (this.hasZAxis) { + vertices.push(0, 0, 0, 0, 0, 2); + colors.push(0, 0, 1, 0, 0.6, 1); + } + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute( + "position", + new THREE.Float32BufferAttribute(vertices, 3) + ); + geometry.setAttribute("color", new THREE.Float32BufferAttribute(colors, 3)); + const material = new THREE.LineBasicMaterial({ + vertexColors: true, + toneMapped: false + }); + this.axes = new THREE.LineSegments(geometry, material); + this.axes.position.set(-1, -1, -1); + this.add(this.axes); + this.xText = createTextSprite("X"); + this.xText.position.set(1.5, -1, -1); + this.add(this.xText); + this.yText = createTextSprite("Y"); + this.yText.position.set(-1, 1.5, -1); + this.add(this.yText); + if (this.hasZAxis) { + this.zText = createTextSprite("Z"); + this.zText.position.set(-1, -1, 1.5); + this.add(this.zText); + } + } + /** + * Set color of x-axis and y-axis + * @param xAxisColor color of x-axis + * @param yAxisColor color of y-axis + */ + setLineColors(xAxisColor, yAxisColor) { + const color = new THREE.Color(); + const array = this.axes.geometry.attributes.color.array; + color.set(xAxisColor); + color.toArray(array, 0); + color.toArray(array, 3); + color.set(yAxisColor); + color.toArray(array, 6); + color.toArray(array, 9); + this.axes.geometry.attributes.color.needsUpdate = true; + return this; + } + /** + * Set text color + * @param color text color + */ + setTextColor(color) { + this.xText.material.color = color; + this.yText.material.color = color; + } + /** + * Free the GPU-related resources allocated by this instance. Call this method whenever this instance + * is no longer used in your app. + */ + dispose() { + var _a, _b; + this.axes.geometry.dispose(); + const material = this.axes.material; + material.dispose(); + this.xText.geometry.dispose(); + this.xText.material.dispose(); + this.yText.geometry.dispose(); + this.yText.material.dispose(); + if (this.hasZAxis) { + (_a = this.zText) == null ? void 0 : _a.geometry.dispose(); + (_b = this.zText) == null ? void 0 : _b.material.dispose(); + } + } +} +class SimpleCameraControls { + /** + * Construct one instance of view cube helper + * @param camera Camera used in your canvas + * @param renderer Renderer used in your canvas + * @param options Options to customize view cube helper + */ + constructor(camera) { + this.camera = camera; + this.animating = false; + this.turnRate = 2 * Math.PI; + this.target = new THREE.Vector3(); + this.q1 = new THREE.Quaternion(); + this.q2 = new THREE.Quaternion(); + this.radius = 0; + this.clock = new THREE.Clock(); + } + /** + * Set associated obit controls + * @param controls The associated orbit controls + */ + setControls(controls) { + if (!controls) return; + this.controls = controls; + } + /** + * Animation loop + */ + update() { + var _a; + if (this.animating === false) return; + const delta = this.clock.getDelta(); + const step = delta * this.turnRate; + this.q1.rotateTowards(this.q2, step); + this.camera.position.set(0, 0, 1).applyQuaternion(this.q1).multiplyScalar(this.radius).add(this.target); + this.camera.quaternion.rotateTowards(this.q2, step); + this.camera.updateProjectionMatrix(); + (_a = this.controls) == null ? void 0 : _a.update(); + if (this.q1.angleTo(this.q2) <= 1e-5) { + this.animating = false; + this.clock.stop(); + } + } + /** + * Fly with the target quaterion + * @param quaternion + */ + flyTo(quaternion) { + const focusPoint = new THREE.Vector3(); + const targetPosition = new THREE.Vector3(0, 0, 1); + this.radius = this.camera.position.distanceTo(focusPoint); + targetPosition.applyQuaternion(quaternion).multiplyScalar(this.radius).add(focusPoint); + const dummy = new THREE.Object3D(); + dummy.position.copy(focusPoint); + dummy.lookAt(this.camera.position); + this.q1.copy(dummy.quaternion); + dummy.lookAt(targetPosition); + this.q2.copy(dummy.quaternion); + this.animating = true; + this.clock.start(); + } +} +export { + AxesGizmo, + DEFAULT_AXES_OPTIONS, + DEFAULT_FACENAMES, + DEFAULT_VIEWCUBE_OPTIONS, + FixedPosGizmo, + ObjectPosition, + SimpleCameraControls, + ViewCube, + ViewCubeGizmo +}; diff --git a/public/assets/pages/ctrl_error.js b/public/assets/pages/ctrl_error.js index 0f73c828..4151d5eb 100644 --- a/public/assets/pages/ctrl_error.js +++ b/public/assets/pages/ctrl_error.js @@ -14,7 +14,14 @@ const strToHTML = (str) => str .replaceAll(">", ">") .replaceAll(" ", " "); -export default function(render = createRender(qs(document.body, "[role=\"main\"]"))) { +export default function(render) { + let hasBack = true; + if (!render) { + render = createRender(document.body); + try { render = createRender(qs(document.body, "[role=\"main\"]")); } + catch (err) { hasBack = false; } + } + return function(err) { const [msg, trace] = processError(err); @@ -22,7 +29,7 @@ export default function(render = createRender(qs(document.body, "[role=\"main\"] const $page = createElement(`
- + ${t("home")} diff --git a/public/assets/pages/ctrl_viewerpage.js b/public/assets/pages/ctrl_viewerpage.js index bbf03024..68fee5ce 100644 --- a/public/assets/pages/ctrl_viewerpage.js +++ b/public/assets/pages/ctrl_viewerpage.js @@ -9,8 +9,8 @@ import { init as initMenubar } from "./viewerpage/component_menubar.js"; import { init as initCache } from "./filespage/cache.js"; import ctrlError from "./ctrl_error.js"; +import { getFilename, getDownloadUrl, getCurrentPath } from "./viewerpage/common.js"; import { opener } from "./viewerpage/mimetype.js"; -import { getCurrentPath } from "./viewerpage/common.js"; import { options } from "./viewerpage/model_files.js"; import "../components/breadcrumb.js"; @@ -54,7 +54,7 @@ export default WithShell(async function(render) { effect(rxjs.of(window.CONFIG["mime"] || {}).pipe( rxjs.map((mimes) => opener(basename(getCurrentPath()), mimes)), rxjs.mergeMap(([opener, opts]) => rxjs.from(loadModule(opener)).pipe(rxjs.tap((module) => { - module.default(createRender($page), { ...opts, acl$: options() }); + module.default(createRender($page), { ...opts, acl$: options(), getFilename, getDownloadUrl }); }))), rxjs.catchError(ctrlError()), )); diff --git a/public/assets/pages/filespage/ctrl_submenu.js b/public/assets/pages/filespage/ctrl_submenu.js index 05e8e292..a6000ef4 100644 --- a/public/assets/pages/filespage/ctrl_submenu.js +++ b/public/assets/pages/filespage/ctrl_submenu.js @@ -1,11 +1,10 @@ import { createElement, createRender, createFragment, onDestroy } from "../../lib/skeleton/index.js"; import rxjs, { effect, onClick, preventDefault } from "../../lib/rx.js"; import { animate, slideXIn, slideYIn } from "../../lib/animate.js"; -import { forwardURLParams } from "../../lib/path.js"; +import { basename, forwardURLParams } from "../../lib/path.js"; import { loadCSS } from "../../helpers/loader.js"; import assert from "../../lib/assert.js"; import { qs, qsa } from "../../lib/dom.js"; -import { basename } from "../../lib/path.js"; import t from "../../locales/index.js"; import "../../components/dropdown.js"; diff --git a/public/assets/pages/viewerpage/application_3d.css b/public/assets/pages/viewerpage/application_3d.css index a2638635..4edca688 100644 --- a/public/assets/pages/viewerpage/application_3d.css +++ b/public/assets/pages/viewerpage/application_3d.css @@ -1,3 +1,47 @@ -.component_3dviewer { - background: #525659; +.component_3dviewer .threeviewer_container { + position: relative; + overflow: hidden; +} + +.component_3dviewer .threeviewer_container, .component_3dviewer .threeviewer_container .drawarea { + height: 100%; +} + +.component_3dviewer .toolbar.open { + transition: 0.3s ease transform; + transform: translateX(0px); + height: 300px; +} +.component_3dviewer .toolbar { + position: absolute; + top: 0; + right: 0; + transform: translateX(250px); + transition: 0.1s ease transform; + border-left: 1px solid #e2e2e205; + background: #e2e2e205; + color: var(--dark); + width: 250px; + z-index: 1; + padding-top: 20px; +} + +.component_3dviewer .toolbar label { + display: block; + text-transform: capitalize; + margin-bottom: -5px; +} + +.component_3dviewer .toolbar label .component_checkbox { + margin-top: -10px; +} + +.component_3dviewer .toolbar label .text { + margin-left: -10px; + display: inline-block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + width: calc(100% - 25px); + cursor: pointer; } diff --git a/public/assets/pages/viewerpage/application_3d.js b/public/assets/pages/viewerpage/application_3d.js index a57d42b7..8e0e3edd 100644 --- a/public/assets/pages/viewerpage/application_3d.js +++ b/public/assets/pages/viewerpage/application_3d.js @@ -1,38 +1,42 @@ -import { createElement, onDestroy } from "../../lib/skeleton/index.js"; +import { createElement, createRender, nop } from "../../lib/skeleton/index.js"; import rxjs, { effect } from "../../lib/rx.js"; import { qs } from "../../lib/dom.js"; import { loadCSS } from "../../helpers/loader.js"; -import { join } from "../../lib/path.js"; import { createLoader } from "../../components/loader.js"; -import { getFilename, getDownloadUrl } from "./common.js"; - -import * as THREE from "../../lib/vendor/three/three.module.js"; -import { OrbitControls } from "../../lib/vendor/three/OrbitControls.js"; -import { GLTFLoader } from "../../lib/vendor/three/GLTFLoader.js"; -import { OBJLoader } from "../../lib/vendor/three/OBJLoader.js"; -import { STLLoader } from "../../lib/vendor/three/STLLoader.js"; -import { FBXLoader } from "../../lib/vendor/three/FBXLoader.js"; -import { Rhino3dmLoader } from "../../lib/vendor/three/3DMLoader.js"; +import ctrlError from "../ctrl_error.js"; import componentDownloader, { init as initDownloader } from "./application_downloader.js"; - import { renderMenubar, buttonDownload } from "./component_menubar.js"; -export default function(render, { mime }) { +import setup3D, { getLoader } from "./application_3d/init.js"; +import withLight from "./application_3d/scene_light.js"; +import withCube from "./application_3d/scene_cube.js"; +import ctrlToolbar from "./application_3d/toolbar.js"; + +export default async function(render, { mime, acl$, getDownloadUrl = nop, getFilename = nop, hasCube = true, hasMenubar = true }) { const $page = createElement(`
- -
+ +
+
+
+
`); render($page); - renderMenubar(qs($page, "component-menubar"), buttonDownload(getFilename(), getDownloadUrl())); - const removeLoader = createLoader(qs($page, ".threeviewer_container")); - effect(rxjs.of(getLoader(mime)).pipe( + const $menubar = renderMenubar( + qs($page, "component-menubar"), + buttonDownload(getFilename(), getDownloadUrl()), + ); + const $draw = qs($page, ".drawarea"); + const $toolbar = qs($page, ".toolbar"); + + const removeLoader = createLoader($draw); + await effect(rxjs.of(getLoader(mime)).pipe( rxjs.mergeMap(([loader, createMesh]) => { if (!loader) { - componentDownloader(render); + componentDownloader(render, { mime, acl$ }); return rxjs.EMPTY; } return rxjs.of([loader, createMesh]); @@ -44,81 +48,46 @@ export default function(render, { mime }) { (err) => observer.error(err), ))), removeLoader, - rxjs.mergeMap((mesh) => { - // setup the dom - const renderer = new THREE.WebGLRenderer(); - renderer.setSize($page.clientWidth, $page.clientHeight); - renderer.setClearColor(0x525659); - qs($page, ".threeviewer_container").appendChild(renderer.domElement); - - // setup the scene - const scene = new THREE.Scene(); - scene.add(mesh); - - // setup the main threeJS components: camera, controls & lighting - const camera = new THREE.PerspectiveCamera(45, $page.clientWidth / $page.clientHeight, 1, 1000); - const controls = new OrbitControls(camera, renderer.domElement); - [ - new THREE.AmbientLight(0xffffff, 1.5), - new THREE.DirectionalLight(0xffffff, 1.5), - new THREE.DirectionalLight(0xffffff, 1.5), - ].forEach((light, i) => { - if (i === 1) light.position.set(100, 100, 100); - else if (i === 2) light.position.set(-100, -100, -100); - scene.add(light); - }); - - // center everything - const box = new THREE.Box3().setFromObject(mesh); - const center = box.getCenter(new THREE.Vector3()); - const size = box.getSize(new THREE.Vector3()); - camera.position.set(center.x, center.y, center.z + Math.max(size.x, size.y, size.z) * 1.8); - controls.target.copy(center); - - // resize handler - const onResize = () => { - camera.aspect = $page.clientWidth / $page.clientHeight; - camera.updateProjectionMatrix(); - renderer.setSize($page.clientWidth, $page.clientHeight); - }; - window.addEventListener("resize", onResize); - onDestroy(() => window.removeEventListener("resize", onResize)); - - return rxjs.animationFrames().pipe(rxjs.tap(() => { - controls.update(); - renderer.render(scene, camera); - })); - }), + rxjs.mergeMap((mesh) => create3DScene({ mesh, $draw, $toolbar, $menubar, hasCube })), + rxjs.catchError(ctrlError()), )); } -function getLoader(mime) { - const identity = (s) => s; - switch (mime) { - case "application/object": - return [new OBJLoader(), identity]; - case "model/3dm": - const loader = new Rhino3dmLoader(); - loader.setLibraryPath(join(import.meta.url, "../../lib/vendor/three/rhino3dm/")); - return [loader, identity]; - case "model/gtlt-binary": - case "model/gltf+json": - return [new GLTFLoader(), (gltf) => gltf.scene]; - case "model/stl": - return [new STLLoader(), (geometry) => new THREE.Mesh( - geometry, - new THREE.MeshPhongMaterial(), - )]; - case "application/fbx": - return [new FBXLoader(), identity]; - default: - return [null, null]; - } +function create3DScene({ mesh, $draw, $toolbar, $menubar, hasCube }) { + const refresh = []; + const { renderer, camera, scene, controls, box } = setup3D({ + $page: $draw, + mesh, + refresh, + $menubar, + }); + + withLight({ scene, box }); + if (hasCube) withCube({ camera, renderer, refresh, controls }); + ctrlToolbar(createRender($toolbar), { + mesh, + controls, + camera, + refresh, + $menubar, + $toolbar, + }); + + return rxjs.animationFrames().pipe(rxjs.tap(() => { + refresh.forEach((fn) => fn()); + })); } -export function init() { +export function init($root) { + const priors = ($root && [ + $root.classList.add("component_page_viewerpage"), + loadCSS(import.meta.url, "./component_menubar.css"), + loadCSS(import.meta.url, "../ctrl_viewerpage.css"), + ]); + return Promise.all([ loadCSS(import.meta.url, "./application_3d.css"), initDownloader(), + ...priors, ]); } diff --git a/public/assets/pages/viewerpage/application_3d/init.js b/public/assets/pages/viewerpage/application_3d/init.js new file mode 100644 index 00000000..00034cda --- /dev/null +++ b/public/assets/pages/viewerpage/application_3d/init.js @@ -0,0 +1,137 @@ +import { createElement, onDestroy } from "../../../lib/skeleton/index.js"; +import { join } from "../../../lib/path.js"; + +import * as THREE from "../../../lib/vendor/three/three.module.js"; +import { OrbitControls } from "../../../lib/vendor/three/OrbitControls.js"; + +import { toCreasedNormals } from "../../../lib/vendor/three/utils/BufferGeometryUtils.js"; +import { GLTFLoader } from "../../../lib/vendor/three/GLTFLoader.js"; +import { OBJLoader } from "../../../lib/vendor/three/OBJLoader.js"; +import { STLLoader } from "../../../lib/vendor/three/STLLoader.js"; +import { FBXLoader } from "../../../lib/vendor/three/FBXLoader.js"; +import { Rhino3dmLoader } from "../../../lib/vendor/three/3DMLoader.js"; + +export default function({ $page, $menubar, mesh, refresh }) { + // setup the dom + const renderer = new THREE.WebGLRenderer({ antialias: true, shadowMapEnabled: true }); + renderer.setSize($page.clientWidth, $page.clientHeight); + renderer.setPixelRatio(window.devicePixelRatio); + renderer.setClearColor(0xf5f5f5); + renderer.shadowMap.enabled = true; + renderer.shadowMap.type = THREE.PCFSoftShadowMap; + $page.appendChild(renderer.domElement); + + // center everything + const box = new THREE.Box3().setFromObject(mesh); + const center = box.getCenter(new THREE.Vector3()); + const size = box.getSize(new THREE.Vector3()); + const maxDim = Math.max(size.x, size.y, size.z); + + // setup the scene, camera and controls + const scene = new THREE.Scene(); + const camera = new THREE.PerspectiveCamera( + 45, + $page.clientWidth / $page.clientHeight, + Math.max(0.1, maxDim / 100), + maxDim * 10, + ); + const controls = new OrbitControls(camera, renderer.domElement); + scene.add(mesh); + mesh.castShadow = true; + mesh.receiveShadow = true; + camera.position.set(center.x, center.y, center.z + maxDim * 1.8); + controls.target.copy(center); + + // enable animation if present + const mixer = new THREE.AnimationMixer(mesh); + if (mesh.animations.length > 0) { + const ICON = { + PLAY: "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcKICAgdmlld0JveD0iMCAwIDU4Ljc1MiA1OC43NTIiCiAgIHZlcnNpb249IjEuMSIKICAgaWQ9InN2ZzE1OCIKICAgc29kaXBvZGk6ZG9jbmFtZT0icGxheS5zdmciCiAgIGlua3NjYXBlOnZlcnNpb249IjEuMi4yIChiMGE4NDg2NTQxLCAyMDIyLTEyLTAxKSIKICAgeG1sbnM6aW5rc2NhcGU9Imh0dHA6Ly93d3cuaW5rc2NhcGUub3JnL25hbWVzcGFjZXMvaW5rc2NhcGUiCiAgIHhtbG5zOnNvZGlwb2RpPSJodHRwOi8vc29kaXBvZGkuc291cmNlZm9yZ2UubmV0L0RURC9zb2RpcG9kaS0wLmR0ZCIKICAgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIgogICB4bWxuczpzdmc9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8ZGVmcwogICAgIGlkPSJkZWZzMTYyIiAvPgogIDxzb2RpcG9kaTpuYW1lZHZpZXcKICAgICBpZD0ibmFtZWR2aWV3MTYwIgogICAgIHBhZ2Vjb2xvcj0iI2ZmZmZmZiIKICAgICBib3JkZXJjb2xvcj0iIzAwMDAwMCIKICAgICBib3JkZXJvcGFjaXR5PSIwLjI1IgogICAgIGlua3NjYXBlOnNob3dwYWdlc2hhZG93PSIyIgogICAgIGlua3NjYXBlOnBhZ2VvcGFjaXR5PSIwLjAiCiAgICAgaW5rc2NhcGU6cGFnZWNoZWNrZXJib2FyZD0iMCIKICAgICBpbmtzY2FwZTpkZXNrY29sb3I9IiNkMWQxZDEiCiAgICAgc2hvd2dyaWQ9ImZhbHNlIgogICAgIGlua3NjYXBlOnpvb209IjE1LjQyMDc1MiIKICAgICBpbmtzY2FwZTpjeD0iMjkuMzQzNTc2IgogICAgIGlua3NjYXBlOmN5PSIyNS45MzkwNzMiCiAgICAgaW5rc2NhcGU6d2luZG93LXdpZHRoPSIxOTA0IgogICAgIGlua3NjYXBlOndpbmRvdy1oZWlnaHQ9IjExNTciCiAgICAgaW5rc2NhcGU6d2luZG93LXg9IjciCiAgICAgaW5rc2NhcGU6d2luZG93LXk9IjM0IgogICAgIGlua3NjYXBlOndpbmRvdy1tYXhpbWl6ZWQ9IjEiCiAgICAgaW5rc2NhcGU6Y3VycmVudC1sYXllcj0ic3ZnMTU4IiAvPgogIDxwYXRoCiAgICAgZD0iTSA0NS42MDY5MTMsMjUuMTc0NzEyIDIyLjY5MTQwMSw2LjEwODk4MjUgYyAtMS40Njk2MjUsLTAuOTA3ODUyNSAtMy4zNzIzNTQsLTAuOTA1Mzc2NiAtNC44MzY1ODQsMCAtMS40OTM1MTUsMC45MjEwNTc3IC0yLjQyMjE0NSwyLjY0MTg1MSAtMi40MjIxNDUsNC40ODk3NDM1IFYgNDguNzMyNjYgYyAwLDEuODQ4NzE4IDAuOTI3ODYsMy41Njk1MTEgMi40MTI4OTcsNC40ODU2MTcgMC43MzQ0MjcsMC40NTgwNTMgMS41NzM2NjMsMC42OTk4NzIgMi40MjY3NjksMC42OTk4NzIgMC44NTA3OTUsMCAxLjY4OTI2LC0wLjI0MDk5NCAyLjQyMDYwNCwtMC42OTU3NDUgbCAyMi45MTU1MTIsLTE5LjA2NzM4IGMgMS40OTE5NzQsLTAuOTIzNTMzIDIuNDE4MjkyLC0yLjY0MzUwMSAyLjQxODI5MiwtNC40ODg5MTggLTcuN2UtNCwtMS44NDI5NDEgLTAuOTI2MzE4LC0zLjU2MjkwOSAtMi40MTk4MzMsLTQuNDkxMzk0IHogbSAtMi42MDU3MDIsNC42OTM1OTggYyAtMjguNjY3NDc0LC0xOS45MTIyMDY3IC0xNC4zMzM3MzcsLTkuOTU2MTAzIDAsMCB6IgogICAgIHN0eWxlPSJmaWxsOiNmMmYyZjIiCiAgICAgaWQ9InBhdGgxNTYiCiAgICAgc29kaXBvZGk6bm9kZXR5cGVzPSJjY2Nzc2NzY2NjY2NjIiAvPgo8L3N2Zz4K", + PAUSE: "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcKICAgdmlld0JveD0iMCAwIDU4Ljc1MiA1OC43NTIiCiAgIHZlcnNpb249IjEuMSIKICAgaWQ9InN2ZzE1OCIKICAgc29kaXBvZGk6ZG9jbmFtZT0icGF1c2Uuc3ZnIgogICBpbmtzY2FwZTp2ZXJzaW9uPSIxLjIuMiAoYjBhODQ4NjU0MSwgMjAyMi0xMi0wMSkiCiAgIHhtbG5zOmlua3NjYXBlPSJodHRwOi8vd3d3Lmlua3NjYXBlLm9yZy9uYW1lc3BhY2VzL2lua3NjYXBlIgogICB4bWxuczpzb2RpcG9kaT0iaHR0cDovL3NvZGlwb2RpLnNvdXJjZWZvcmdlLm5ldC9EVEQvc29kaXBvZGktMC5kdGQiCiAgIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgeG1sbnM6c3ZnPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPGRlZnMKICAgICBpZD0iZGVmczE2MiIgLz4KICA8c29kaXBvZGk6bmFtZWR2aWV3CiAgICAgaWQ9Im5hbWVkdmlldzE2MCIKICAgICBwYWdlY29sb3I9IiNmZmZmZmYiCiAgICAgYm9yZGVyY29sb3I9IiMwMDAwMDAiCiAgICAgYm9yZGVyb3BhY2l0eT0iMC4yNSIKICAgICBpbmtzY2FwZTpzaG93cGFnZXNoYWRvdz0iMiIKICAgICBpbmtzY2FwZTpwYWdlb3BhY2l0eT0iMC4wIgogICAgIGlua3NjYXBlOnBhZ2VjaGVja2VyYm9hcmQ9IjAiCiAgICAgaW5rc2NhcGU6ZGVza2NvbG9yPSIjZDFkMWQxIgogICAgIHNob3dncmlkPSJmYWxzZSIKICAgICBpbmtzY2FwZTp6b29tPSIxNS40MjA3NTIiCiAgICAgaW5rc2NhcGU6Y3g9IjI4LjQzNTcwOSIKICAgICBpbmtzY2FwZTpjeT0iMjUuOTM5MDczIgogICAgIGlua3NjYXBlOndpbmRvdy13aWR0aD0iMTkwNCIKICAgICBpbmtzY2FwZTp3aW5kb3ctaGVpZ2h0PSIxMTU3IgogICAgIGlua3NjYXBlOndpbmRvdy14PSI3IgogICAgIGlua3NjYXBlOndpbmRvdy15PSIzNCIKICAgICBpbmtzY2FwZTp3aW5kb3ctbWF4aW1pemVkPSIxIgogICAgIGlua3NjYXBlOmN1cnJlbnQtbGF5ZXI9InN2ZzE1OCIgLz4KICA8cmVjdAogICAgIHN0eWxlPSJmaWxsOiNmMmYyZjI7ZmlsbC1vcGFjaXR5OjE7c3Ryb2tlOm5vbmU7c3Ryb2tlLXdpZHRoOjAuOTM2NjQxIgogICAgIGlkPSJyZWN0NTA3IgogICAgIHdpZHRoPSIzOS42NTU5MTQiCiAgICAgaGVpZ2h0PSI0NS4xMjEzNTciCiAgICAgeD0iMTAuMzExNDcyIgogICAgIHk9IjcuMTUyMjY4OSIKICAgICByeD0iNS43NSIgLz4KPC9zdmc+Cg==", + }; + const $button = createElement(`play`); + const action = mixer.clipAction(mesh.animations[0]); + let isPlaying = false; + $button.onclick = () => { + if (isPlaying === false) action.play(); + else action.stop(); + isPlaying = !isPlaying; + $button.setAttribute("src", isPlaying ? ICON.PAUSE : ICON.PLAY); + }; + $menubar.add($button); + } + + // sizing of the window + const onResize = () => { + camera.aspect = $page.clientWidth / $page.clientHeight; + camera.updateProjectionMatrix(); + renderer.setSize($page.clientWidth, $page.clientHeight); + }; + window.addEventListener("resize", onResize); + onDestroy(() => window.removeEventListener("resize", onResize)); + + // stuff we refresh constantly + const clock = new THREE.Clock(); + refresh.push(() => { + controls.update(); + renderer.render(scene, camera); + mixer.update(clock.getDelta()); + }); + + return { renderer, scene, camera, controls, box }; +} + +export function getLoader(mime) { + const identity = (s) => s; + switch (mime) { + case "application/object": + return [ + new OBJLoader(), + (obj) => { + obj.traverse((child) => { + if (child.isMesh) { + child.material = new THREE.MeshPhongMaterial({ + color: 0x40464b, + emissive: 0x40464b, + specular: 0xf9f9fa, + shininess: 10, + transparent: true, + }); + // smooth the edges: https://discourse.threejs.org/t/how-to-smooth-an-obj-with-threejs/3950/16 + child.geometry = toCreasedNormals(child.geometry, (30 / 180) * Math.PI); + } + }); + return obj; + }, + ]; + case "model/3dm": + THREE.Object3D.DEFAULT_UP.set(0, 0, 1); + const loader = new Rhino3dmLoader(); + loader.setLibraryPath(join(import.meta.url, "../../../lib/vendor/three/rhino3dm/")); + return [loader, identity]; + case "model/gtlt-binary": + case "model/gltf+json": + return [new GLTFLoader(), (gltf) => gltf.scene]; + case "model/stl": + return [new STLLoader(), (geometry) => { + const material = new THREE.MeshPhongMaterial({ + emissive: 0x40464b, + specular: 0xf9f9fa, + shininess: 15, + transparent: true, + }); + if (geometry.hasColors) material.vertexColors = true; + else material.color = material.emissive; + return new THREE.Mesh(geometry, material); + }]; + case "application/fbx": + return [new FBXLoader(), (obj) => { + obj.traverse((child) => { + // console.log(child); + }); + return obj; + }]; + default: + return [null, null]; + } +} diff --git a/public/assets/pages/viewerpage/application_3d/scene_cube.js b/public/assets/pages/viewerpage/application_3d/scene_cube.js new file mode 100644 index 00000000..4f428005 --- /dev/null +++ b/public/assets/pages/viewerpage/application_3d/scene_cube.js @@ -0,0 +1,23 @@ +import { onDestroy } from "../../../lib/skeleton/index.js"; +import { ViewCubeGizmo, SimpleCameraControls, ObjectPosition } from "../../../lib/vendor/three/viewcube.js"; + +export default function({ camera, renderer, refresh, controls }) { + const viewCubeGizmo = new ViewCubeGizmo(camera, renderer, { + pos: ObjectPosition.RIGHT_BOTTOM, + dimension: 130, + faceColor: 0xf9f9fa, + outlineColor: 0xe2e2e2, + }); + + const simpleCameraControls = new SimpleCameraControls(camera); + simpleCameraControls.setControls(controls); + + refresh.push(() => { + viewCubeGizmo.update(); + simpleCameraControls.update(); + }); + + const onCubeClick = (event) => simpleCameraControls.flyTo(event.quaternion); + viewCubeGizmo.addEventListener("change", onCubeClick); + onDestroy(() => viewCubeGizmo.removeEventListener("change", onCubeClick)); +} diff --git a/public/assets/pages/viewerpage/application_3d/scene_light.js b/public/assets/pages/viewerpage/application_3d/scene_light.js new file mode 100644 index 00000000..85fb8385 --- /dev/null +++ b/public/assets/pages/viewerpage/application_3d/scene_light.js @@ -0,0 +1,38 @@ +import { settings_get } from "../../../lib/settings.js"; +import * as THREE from "../../../lib/vendor/three/three.module.js"; + +const LIGHT_COLOR = 0xf5f5f5; + +export default function({ scene, box }) { + addLight( + scene, + new THREE.AmbientLight(LIGHT_COLOR), + settings_get("viewerpage_3d_light", 2), + ); + + // to make things "look nice", a good setup is to get lights positioned + // in a 3D cube with a couple "twist" in term of position & intensity + const l = addLight.bind(this, scene, new THREE.DirectionalLight(LIGHT_COLOR)); + l(0.25, [plus(box.max.x*3), 0, 20]); // right + l(0.25, [minus(box.min.x*3), 0, -20]); // left + l(0.35, [0, plus(box.max.y*4), 20]); // top + l(0.35, [0, minus(box.min.y*4), -20]); // bottom + + l(0.5, [0, 0, plus(7*box.max.z)]); // front + l(0.2, [0, 0, minus(15*box.min.z)]); // back +} + +function addLight(scene, light, intensity, pos = []) { + light = light.clone(); + light.intensity = intensity; + light.position.set(...pos); + if (light.type !== "AmbientLight") light.castShadow = true; + scene.add(light); +} + +const plus = notZero.bind(null, 1); +const minus = notZero.bind(null, -1); +function notZero(sgn, n) { + if (n === 0) return sgn; + return n; +} diff --git a/public/assets/pages/viewerpage/application_3d/toolbar.js b/public/assets/pages/viewerpage/application_3d/toolbar.js new file mode 100644 index 00000000..fbcdf602 --- /dev/null +++ b/public/assets/pages/viewerpage/application_3d/toolbar.js @@ -0,0 +1,97 @@ +import { createElement } from "../../../lib/skeleton/index.js"; +import { qs } from "../../../lib/dom.js"; +import * as THREE from "../../../lib/vendor/three/three.module.js"; + +export default function(render, { camera, controls, mesh, $menubar, $toolbar }) { + if (mesh.children.length <= 1) return; + + $menubar.add(buttonLayers({ $toolbar })); + render(createChild( + document.createDocumentFragment(), + mesh, + 0, + { camera, controls } + )); +} + +function buttonLayers({ $toolbar }) { + const $button = createElement(`layers`); + $button.onclick = () => $toolbar.classList.toggle("open"); + return $button; +} + +function createChild($fragment, mesh, child = 0, opts) { + if (["Bone"].indexOf(mesh.type) >= 0) return; + buildDOM($fragment, mesh, child, opts); + if (mesh.children.length > 0 && child < 4) { + for (let i=0; i +
+ + +
+ ${name(child)} + + `); + qs($label, "input").onchange = () => child.visible = !child.visible; + let block = false; let blockID = null; + $label.onclick = async(e) => { + if (e.target.nodeName === "INPUT" || e.target.classList.contains("component_checkbox")) return; + e.preventDefault(); e.stopPropagation(); + block = true; + clearTimeout(blockID); + blockID = setTimeout(() => block = false, 2000); + $label.onmouseenter(); + await flyTo({ mesh: child, camera, controls }); + }; + $label.onmouseenter = () => block === false && getRootObject(child).traverse((c) => { + if (!c.material) return; + c.material.opacity = c.id === child.id || c.parent.id === child.id ? 1 : 0.2; + c.material.depthWrite = c.material.opacity === 1; + }); + $label.onmouseleave = () => block === false && getRootObject(child).traverse((c) => { + if (!c.material) return; + c.material.depthWrite = true; + c.material.opacity = 1; + }); + $fragment.appendChild($label); +} + +async function flyTo({ mesh, camera, controls }) { + const box = new THREE.Box3().setFromObject(mesh); + const size = box.getSize(new THREE.Vector3()); + + const targetLookAt = box.getCenter(new THREE.Vector3()); + const targetDistance = Math.max(size.x, size.y, size.z) * 1.1; + const targetPosition = targetLookAt.clone().add(new THREE.Vector3(targetDistance, targetDistance, targetDistance)); + + const [startPosition, startLookAt] = [camera.position.clone(), controls.target.clone()]; + const startTime = performance.now(); + return new Promise((resolve) => (function animate() { + const t = Math.min((performance.now() - startTime) / 500, 1); + camera.position.lerpVectors(startPosition, targetPosition, t); + controls.target.lerpVectors(startLookAt, targetLookAt, t); + controls.update(); + t < 1 ? requestAnimationFrame(animate) : resolve(); + })()); +} + +function getRootObject(mesh) { + if (mesh.type === "Scene" || mesh.parent.type === "Scene") return mesh; + return getRootObject(mesh.parent); +} + +function name(mesh) { + if (mesh.name) return mesh.name; + else if (mesh.isGroup && mesh.uuid) return `group: ${mesh.uuid}`; + else if (mesh.uuid) return mesh.uuid; + return "N/A"; +} diff --git a/public/assets/pages/viewerpage/common.js b/public/assets/pages/viewerpage/common.js index 0a81bcfb..49ee10f0 100644 --- a/public/assets/pages/viewerpage/common.js +++ b/public/assets/pages/viewerpage/common.js @@ -7,7 +7,7 @@ export function transition($node) { } export function getFilename() { - return basename(getCurrentPath()) || "untitled.dat"; + return basename(getCurrentPath()) || " "; } export function getDownloadUrl() { diff --git a/public/assets/pages/viewerpage/component_menubar.js b/public/assets/pages/viewerpage/component_menubar.js index d3d2fc85..7e5cc259 100644 --- a/public/assets/pages/viewerpage/component_menubar.js +++ b/public/assets/pages/viewerpage/component_menubar.js @@ -2,7 +2,7 @@ import { createElement } from "../../lib/skeleton/index.js"; import { qs } from "../../lib/dom.js"; import { animate, slideYIn } from "../../lib/animate.js"; import { loadCSS } from "../../helpers/loader.js"; -import { getFilename } from "./common.js"; +import { isSDK } from "../../helpers/sdk.js"; import assert from "../../lib/assert.js"; import "../../components/dropdown.js"; @@ -14,7 +14,7 @@ export default class ComponentMenubar extends HTMLElement { this.innerHTML = `
-
${getFilename()}
+
${this.getAttribute("filename") || " "}
@@ -45,6 +45,12 @@ export default class ComponentMenubar extends HTMLElement { } animate($item, { time: 250, keyframes: slideYIn(2) }); } + + add($button) { + const $item = assert.type(this.querySelector(".action-item"), HTMLElement); + $item.prepend($button); + animate($button, { time: 250, keyframes: slideYIn(2) }); + } } export function buttonDownload(name, link) { @@ -61,6 +67,7 @@ export function buttonDownload(name, link) { `); const $img = qs($el, "img"); qs($el, "a").onclick = () => { + if (isSDK()) return; document.cookie = "download=yes; path=/; max-age=120;"; $img.setAttribute("src", ICON.LOADING); const id = setInterval(() => { @@ -92,6 +99,7 @@ export function buttonFullscreen($screen) { export function renderMenubar($menubar, ...buttons) { assert.type($menubar, ComponentMenubar); $menubar.render(buttons.filter(($button) => $button)); + return $menubar; } export async function init() { diff --git a/server/middleware/http.go b/server/middleware/http.go index 39bcdcc0..81ffac82 100644 --- a/server/middleware/http.go +++ b/server/middleware/http.go @@ -32,6 +32,20 @@ func StaticHeaders(fn HandlerFunc) HandlerFunc { }) } +func PublicCORS(fn HandlerFunc) HandlerFunc { + return HandlerFunc(func(ctx *App, res http.ResponseWriter, req *http.Request) { + header := res.Header() + header.Set("Access-Control-Allow-Origin", "*") + header.Set("Access-Control-Allow-Headers", "x-requested-with") + if req.Method == http.MethodOptions { + header.Set("Access-Control-Allow-Methods", "GET, OPTIONS") + res.WriteHeader(http.StatusNoContent) + return + } + fn(ctx, res, req) + }) +} + func IndexHeaders(fn HandlerFunc) HandlerFunc { return HandlerFunc(func(ctx *App, res http.ResponseWriter, req *http.Request) { header := res.Header() diff --git a/server/routes.go b/server/routes.go index ea62000e..9a54a7d2 100644 --- a/server/routes.go +++ b/server/routes.go @@ -90,15 +90,15 @@ func Build(a App) *mux.Router { // Application Resources middlewares = []Middleware{ApiHeaders, SecureHeaders, PluginInjector} - r.HandleFunc(WithBase("/api/config"), NewMiddlewareChain(PublicConfigHandler, middlewares, a)).Methods("GET") r.HandleFunc(WithBase("/api/backend"), NewMiddlewareChain(AdminBackend, middlewares, a)).Methods("GET") - middlewares = []Middleware{StaticHeaders, SecureHeaders, PluginInjector} + r.HandleFunc(WithBase("/api/config"), NewMiddlewareChain(PublicConfigHandler, append(middlewares, PublicCORS), a)).Methods("GET", "OPTIONS") + middlewares = []Middleware{StaticHeaders, SecureHeaders, PublicCORS, PluginInjector} if os.Getenv("CANARY") == "" { // TODO: remove after migration is done - r.PathPrefix(WithBase("/assets")).Handler(http.HandlerFunc(NewMiddlewareChain(LegacyStaticHandler("/"), middlewares, a))).Methods("GET") + r.PathPrefix(WithBase("/assets")).Handler(http.HandlerFunc(NewMiddlewareChain(LegacyStaticHandler("/"), middlewares, a))).Methods("GET", "OPTIONS") r.HandleFunc(WithBase("/favicon.ico"), NewMiddlewareChain(LegacyStaticHandler("/assets/logo/"), middlewares, a)).Methods("GET") r.HandleFunc(WithBase("/sw_cache.js"), NewMiddlewareChain(LegacyStaticHandler("/assets/worker/"), middlewares, a)).Methods("GET") } else { // TODO: remove this after migration is done - r.PathPrefix(WithBase("/assets")).Handler(http.HandlerFunc(NewMiddlewareChain(ServeFile("/"), middlewares, a))).Methods("GET") + r.PathPrefix(WithBase("/assets")).Handler(http.HandlerFunc(NewMiddlewareChain(ServeFile("/"), middlewares, a))).Methods("GET", "OPTIONS") r.HandleFunc(WithBase("/favicon.ico"), NewMiddlewareChain(ServeFavicon, middlewares, a)).Methods("GET") }