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
This commit is contained in:
MickaelK
2024-12-23 08:02:12 +11:00
parent 43d07e8555
commit 71b14e6eaf
19 changed files with 1417 additions and 106 deletions

View File

@ -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;
}
}

View File

@ -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;
}

54
public/assets/index.js Normal file
View File

@ -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(`
<div style="position: absolute; bottom: 0; left: 0; font-size: 0.9rem; display: inline-block;">
Powered By <a href="https://www.filestash.app" style="text-decoration:underline;">Filestash</a>
</div>
`);
}

View File

@ -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;

View File

@ -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];
}

View File

@ -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
};

View File

@ -14,7 +14,14 @@ const strToHTML = (str) => str
.replaceAll(">", "&gt;")
.replaceAll(" ", "&nbsp;");
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(`
<div>
<style>${css}</style>
<a href="${link}" class="backnav">
<a href="${link}" class="backnav ${!hasBack && "hidden"}">
<component-icon name="arrow_left"></component-icon>
${t("home")}
</a>

View File

@ -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()),
));

View File

@ -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";

View File

@ -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;
}

View File

@ -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(`
<div class="component_3dviewer">
<component-menubar></component-menubar>
<div class="threeviewer_container"></div>
<component-menubar filename="${getFilename() || ""}" class="${!hasMenubar && "hidden"}"></component-menubar>
<div class="threeviewer_container">
<div class="drawarea"></div>
<div class="toolbar scroll-y"></div>
</div>
</div>
`);
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,
]);
}

View File

@ -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(`<img class="component_icon" draggable="false" src="${ICON.PLAY}" alt="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];
}
}

View File

@ -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));
}

View File

@ -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;
}

View File

@ -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(`<img class="component_icon" draggable="false" src="data:image/svg+xml;base64,PHN2ZyB2aWV3Qm94PSIwIDAgMjQgMjQiIGZpbGw9IiNmMmYyZjIiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczpzdmc9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8cGF0aCBkPSJtIDEuODI0MDExMiw2LjUzMDQxMTMgOS44OTUxMjU4LDUuMzc3MjgwNyBhIDAuNTkyMzE0MDQsMC41OTIzMTQwNCAwIDAgMCAwLjU1NzQ3NCwwIGwgOS44OTUxMywtNS4zNzcyODA3IGEgMC41MTEwMTYwMiwwLjUxMTAxNjAyIDAgMCAwIC0wLjA1ODA4LC0wLjk0MDczMzkgbCAtOS44OTUxMiwtNC4wNDE2NzI1IGEgMC41ODA3MDAwNSwwLjU4MDcwMDA1IDAgMCAwIC0wLjQ0MTMzNCwwIEwgMS44ODIwODI1LDUuNTg5Njc3NCBhIDAuNTExMDE2MDIsMC41MTEwMTYwMiAwIDAgMCAtMC4wNTgwNzEsMC45NDA3MzM5IHoiIC8+CiAgPHBhdGggZD0iTSAyMi4xMTM2NywxMC40NDQzMzIgMTkuOTg4MzA2LDkuNTM4NDM3NiAxMi4yNzY2MTEsMTMuNzMxMDkxIGEgMC41OTIzMTQwNCwwLjU5MjMxNDA0IDAgMCAxIC0wLjU1NzQ3NCwwIEwgNC4wMDc0NDM1LDkuNTM4NDM3NiAxLjg4MjA4MTIsMTAuNDQ0MzMyIGEgMC41NTc0NzIwNCwwLjU1NzQ3MjA0IDAgMCAwIDAsMC45ODcxODcgbCA5Ljg5NTEyOTgsNS42OTA4NTkgYSAwLjUzNDI0NDA3LDAuNTM0MjQ0MDcgMCAwIDAgMC41NTc0NzEsMCBsIDkuODk1MTMsLTUuNjkwODU5IEEgMC41NTc0NzIwNCwwLjU1NzQ3MjA0IDAgMCAwIDIyLjExMzY3LDEwLjQ0NDMzMiBaIiAvPgogIDxwYXRoIGQ9Im0gMjIuMTEzNjcsMTUuNjAwOTQzIC0xLjgxMTc4NCwtMC43ODk3NSAtOC4wMjUyNzUsNC4zNjY4NjYgYSAwLjU5MjMxNDA0LDAuNTkyMzE0MDQgMCAwIDEgLTAuNTU3NDc0LDAgbCAtOC4wMjUyNzE1LC00LjM2Njg2NiAtMS44MTE3ODQzLDAuNzg5NzUgYSAwLjU2OTA4NjAzLDAuNTY5MDg2MDMgMCAwIDAgMCwxLjAxMDQyMiBsIDkuODk1MTI5OCw1LjgwNjk5OCBhIDAuNTkyMzE0MDQsMC41OTIzMTQwNCAwIDAgMCAwLjU1NzQ3MSwwIGwgOS44OTUxMywtNS44MDY5OTggQSAwLjU2OTA4NjAzLDAuNTY5MDg2MDMgMCAwIDAgMjIuMTEzNjcsMTUuNjAwOTQzIFoiIC8+Cjwvc3ZnPgo=" alt="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<mesh.children.length; i++) {
createChild($fragment, mesh.children[i], child + 1, opts);
}
}
return $fragment;
}
function buildDOM($fragment, child, left, { camera, controls }) {
const $label = createElement(`
<label class="no-select" style="padding-left: ${left*20}px">
<div class="component_checkbox">
<input type="checkbox" ${child.visible ? "checked" : ""} />
<span class="indicator"></span>
</div>
<span class="text">${name(child)}</span>
</label>
`);
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";
}

View File

@ -7,7 +7,7 @@ export function transition($node) {
}
export function getFilename() {
return basename(getCurrentPath()) || "untitled.dat";
return basename(getCurrentPath()) || "&nbsp;";
}
export function getDownloadUrl() {

View File

@ -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 = `
<div class="container">
<span>
<div class="titlebar ellipsis" style="opacity:0">${getFilename()}</div>
<div class="titlebar ellipsis" style="opacity:0">${this.getAttribute("filename") || "&nbsp;"}</div>
<div class="action-item no-select"></div>
</span>
</div>
@ -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() {

View File

@ -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()

View File

@ -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")
}