vox-viewer
Version:
like model-viewer but for .vox models
241 lines (202 loc) • 6.76 kB
JavaScript
import { LitElement, html } from "lit";
import voxelTriangulation from "voxel-triangulation";
import { flatten } from "ramda";
import {
BufferGeometry,
BufferAttribute,
MeshStandardMaterial,
Mesh,
} from "three";
import { GLTFExporter } from "three/examples/jsm/exporters/GLTFExporter.js";
import readVox from "vox-reader";
import zeros from "zeros";
import "@google/model-viewer";
/**
* `vox-viewer`
* displays voxel data
*
* @customElement
* @polymer
* @demo demo/index.html
*/
const MAX_VALUE_OF_A_BYTE = 255;
class VoxViewer extends LitElement {
static get is() {
return "vox-viewer";
}
static get properties() {
return {
src: { type: String },
alt: { type: String },
ar: { type: Boolean },
autoRotate: { type: Boolean, attribute: "auto-rotate" },
autoRotateDelay: { type: Number, attribute: "auto-rotate-delay" },
autoplay: { type: Boolean },
backgroundColor: { type: String, attribute: "background-color" },
backgroundImage: { type: String, attribute: "background-image" },
cameraControls: { type: Boolean, attribute: "camera-controls" },
cameraOrbit: { type: String, attribute: "camera-orbit" },
cameraTarget: { type: String, attribute: "camera-target" },
environmentImage: { type: String, attribute: "environment-image" },
exposure: { type: Number },
fieldOfView: { type: String, attribute: "field-of-view" },
interactionPolicy: { type: String },
interactionPrompt: { type: String },
interactionPromptStyle: { type: String },
interactionPromptTreshold: { type: Number },
preload: { type: Boolean },
reveal: { type: String },
shadowIntensity: { type: Number, attribute: "shadow-intensity" },
unstableWebxr: { type: Boolean, attribute: "unstable-webxr" },
};
}
constructor() {
super();
this.alt = "a voxel model"; // changed!
this.ar = false;
this.autoRotate = false;
this.autoRotateDelay = 3000;
this.autoplay = false;
this.backgroundColor = "white";
this.cameraControls = false;
this.cameraOrbit = "0deg 75deg 105%";
this.cameraTarget = "auto auto auto";
this.exposure = 1.0;
this.fieldOfView = "auto";
this.interactionPolicy = "always-allow";
this.interactionPrompt = "auto";
this.interactionPromptStyle = "wiggle";
this.interactionPromptTreshold = 3000;
this.preload = false;
this.reveal = "auto";
this.shadowIntensity = 0.0;
this.unstableWebxr = false;
}
get currentTime() {
return this.shadowRoot.querySelector("#model-viewer").currentTime;
}
get paused() {
return this.shadowRoot.querySelector("#model-viewer").paused;
}
getCameraOrbit() {
return this.shadowRoot.querySelector("#model-viewer").getCameraOrbit();
}
getFieldOfView() {
return this.shadowRoot.querySelector("#model-viewer").getFieldOfView();
}
jumpCameraToGoal() {
this.shadowRoot.querySelector("#model-viewer").jumpCameraToGoal();
}
play() {
this.shadowRoot.querySelector("#model-viewer").play();
}
pause() {
this.shadowRoot.querySelector("#model-viewer").pause();
}
resetTurntableRotation() {
this.shadowRoot.querySelector("#model-viewer").resetTurntableRotation();
}
toDataURL(type, encoderOptions) {
return this.shadowRoot
.querySelector("#model-viewer")
.toDataURL(type, encoderOptions);
}
updated(changedProperties) {
if (changedProperties.has("src")) {
this.initialized = false;
this.loadVoxModel(this.src, changedProperties);
}
this.setup(changedProperties);
}
setup(changedProperties) {
changedProperties.forEach((_, propertyName) => {
if (this[propertyName] != undefined) {
if (propertyName !== "src") {
this.shadowRoot.querySelector("#model-viewer")[propertyName] =
this[propertyName];
}
}
});
}
async loadVoxModel(fileURL, changedProperties) {
if (fileURL.slice(0, 5) == "blob:") {
const response = await fetch(fileURL);
const arrayBuffer = await response.arrayBuffer();
this.processVoxContent(arrayBuffer, changedProperties);
} else {
let request = new XMLHttpRequest();
request.responseType = "arraybuffer";
request.open("GET", fileURL, true);
request.onreadystatechange = () => {
if (request.readyState === 4 && request.status == "200") {
this.processVoxContent(request.response, changedProperties);
}
};
request.send(null);
}
}
processVoxContent(voxContent, changedProperties) {
const u8intArrayContent = new Uint8Array(voxContent);
let vox = readVox(u8intArrayContent);
let voxelData = vox.xyzi.values;
let size = vox.size;
let rgba = vox.rgba.values;
let componentizedColores = rgba.map((c) => [c.r, c.g, c.b]);
let voxels = zeros([size.x, size.y, size.z]);
voxelData.forEach(({ x, y, z, i }) => voxels.set(x, y, z, i));
voxels = voxels.transpose(1, 2, 0);
let { vertices, normals, indices, voxelValues } =
voxelTriangulation(voxels);
let normalizedColors = componentizedColores.map((color) =>
color.map((c) => c / MAX_VALUE_OF_A_BYTE)
);
let gammaCorrectedColors = normalizedColors.map((color) =>
color.map((c) => Math.pow(c, 2.2))
);
let alignedColors = [[0, 0, 0], ...gammaCorrectedColors];
let flattenedColors = flatten(voxelValues.map((v) => alignedColors[v]));
let geometry = new BufferGeometry();
geometry.setAttribute(
"position",
new BufferAttribute(new Float32Array(vertices), 3)
);
geometry.setAttribute(
"normal",
new BufferAttribute(new Float32Array(normals), 3)
);
geometry.setAttribute(
"color",
new BufferAttribute(new Float32Array(flattenedColors), 3)
);
geometry.setIndex(new BufferAttribute(new Uint32Array(indices), 1));
let material = new MeshStandardMaterial({
roughness: 1.0,
metalness: 0.0,
});
let mesh = new Mesh(geometry, material);
let exporter = new GLTFExporter();
exporter.parse(mesh, (json) => {
let string = JSON.stringify(json);
let blob = new Blob([string], { type: "text/plain" });
let url = URL.createObjectURL(blob);
this.shadowRoot.querySelector("#model-viewer").src = url;
this.setup(changedProperties);
});
}
render() {
return html`
<style>
:host {
display: block;
padding: 5px;
}
#model-viewer {
width: 100%;
height: 100%;
}
</style>
<model-viewer id="model-viewer"></model-viewer>
`;
}
}
window.customElements.define("vox-viewer", VoxViewer);