mirror of
https://github.com/yeicor-3d/yet-another-cad-viewer.git
synced 2025-12-19 14:14:13 +01:00
Somewhat more conventional visual presentation: + Use a neutral gray background a-la most other CAD/modelling software for better contrast with the geometry (which defaults to a bright yellow). This is done with the "skybox-environment" image in model-viewer, so add a new setting value in settings.ts for this (it can be overriden in the URL just like other settings) + But using a skybox will cause that image to be used for lighting too, which is clearly not desired. So fetch a nice professional HDRI image from Polyhaven for lighting. This is much better (more directional, higher contrast) than the default light environment anyway. + The checkerboard texture isn't really a good default. Use a 1x1 white pixel instead, essentially presenting the model materials unchanged. Also collect the default color in gltf.py out of the code and put it next to the texture for clarity. This should probably be wired through to a setting at some point. Signed-off-by: Andy Ross <andy@plausible.org>
306 lines
12 KiB
Vue
306 lines
12 KiB
Vue
<script lang="ts" setup>
|
|
import {settings} from "../misc/settings";
|
|
import {inject, onUpdated, type Ref, ref, watch} from "vue";
|
|
import {$renderer, $scene} from "@google/model-viewer/lib/model-viewer-base";
|
|
import {$controls} from '@google/model-viewer/lib/features/controls.js';
|
|
import {type SmoothControls} from '@google/model-viewer/lib/three-components/SmoothControls';
|
|
import {ModelViewerElement} from '@google/model-viewer';
|
|
import type {ModelScene} from "@google/model-viewer/lib/three-components/ModelScene";
|
|
import {Hotspot} from "@google/model-viewer/lib/three-components/Hotspot";
|
|
import type {Renderer} from "@google/model-viewer/lib/three-components/Renderer";
|
|
import type {Vector3} from "three";
|
|
import {BufferGeometry, Mesh} from "three";
|
|
import {acceleratedRaycast, computeBoundsTree, disposeBoundsTree} from 'three-mesh-bvh';
|
|
|
|
ModelViewerElement.modelCacheSize = 0; // Also needed to avoid tree shaking
|
|
//@ts-ignore
|
|
BufferGeometry.prototype.computeBoundsTree = computeBoundsTree;
|
|
//@ts-ignore
|
|
BufferGeometry.prototype.disposeBoundsTree = disposeBoundsTree;
|
|
//@ts-ignore
|
|
Mesh.prototype.raycast = acceleratedRaycast;
|
|
|
|
const props = defineProps<{ src: string }>();
|
|
|
|
const elem = ref<ModelViewerElement | null>(null);
|
|
const scene = ref<ModelScene | null>(null);
|
|
const renderer = ref<Renderer | null>(null);
|
|
const controls = ref<SmoothControls | null>(null);
|
|
|
|
const sett = ref<any | null>(null);
|
|
(async () => sett.value = await settings())();
|
|
|
|
let lastCameraTargetPosition: Vector3 | undefined = undefined;
|
|
let lastCameraZoom: number | undefined = undefined;
|
|
let lastCameraUrl = props.src.toString();
|
|
let initialized = false
|
|
onUpdated(() => {
|
|
if (!elem.value) return; // Not ready yet
|
|
if (initialized) return; // Already initialized
|
|
initialized = true;
|
|
elem.value.addEventListener('before-render', () => {
|
|
if (!elem.value) return
|
|
// Extract internals of model-viewer in order to hack unsupported features
|
|
scene.value = elem.value[$scene] as ModelScene;
|
|
renderer.value = elem.value[$renderer] as Renderer;
|
|
controls.value = (elem.value as any)[$controls] as SmoothControls;
|
|
// Recover the camera position if it was set before
|
|
if (lastCameraTargetPosition) {
|
|
// console.log("RESTORING camera position?", lastCameraTargetPosition);
|
|
scene.value.setTarget(-lastCameraTargetPosition.x, -lastCameraTargetPosition.y, -lastCameraTargetPosition.z);
|
|
scene.value.jumpToGoal(); // Avoid move animation
|
|
}
|
|
(async () => {
|
|
let tries = 0
|
|
while (tries++ < 25) {
|
|
if (!lastCameraZoom || !elem.value?.getCameraOrbit()?.radius) break;
|
|
let change = lastCameraZoom - elem.value.getCameraOrbit().radius;
|
|
//console.log("Zooming to", lastCameraZoom, "from", elem.value.getCameraOrbit().radius, "change", change);
|
|
if (Math.abs(change) < 0.001) break;
|
|
elem.value.zoom(-Math.sign(change) * (Math.pow(Math.abs(change) + 1, 0.9) - 1)); // Arbitrary, experimental
|
|
elem.value.jumpCameraToGoal();
|
|
await elem.value.updateComplete;
|
|
}
|
|
//console.log("Ready to save!")
|
|
lastCameraUrl = props.src.toString();
|
|
})();
|
|
});
|
|
elem.value.addEventListener('camera-change', onCameraChange);
|
|
elem.value.addEventListener('progress', (ev) => onProgress((ev as any).detail.totalProgress));
|
|
});
|
|
|
|
function onCameraChange() {
|
|
// Remember the camera position to keep it in case of scene changes
|
|
if (scene.value && props.src.toString() == lastCameraUrl) { // Don't overwrite with initial unwanted positions
|
|
lastCameraTargetPosition = scene.value.target.position.clone();
|
|
lastCameraZoom = elem.value?.getCameraOrbit()?.radius;
|
|
//console.log("Saving camera?", lastCameraTargetPosition, lastCameraZoom);
|
|
}
|
|
// Also need to update the SVG overlay
|
|
for (let lineId in lines.value) {
|
|
onCameraChangeLine(lineId as any);
|
|
}
|
|
}
|
|
|
|
// Handles loading the events for <model-viewer>'s slotted progress bar
|
|
const progressBar = ref<HTMLElement | null>(null);
|
|
const updateBar = ref<HTMLElement | null>(null);
|
|
let onProgressHideTimeout: number | null = null;
|
|
const onProgress = (totalProgress: number) => {
|
|
if (!progressBar.value || !updateBar.value) return;
|
|
// Update the progress bar and ensure it's visible
|
|
progressBar.value.style.display = 'block';
|
|
progressBar.value.style.opacity = '1'; // Fade in
|
|
updateBar.value.style.width = `${totalProgress * 100}%`;
|
|
// Auto-hide smoothly when no progress is made for a while
|
|
if (onProgressHideTimeout) clearTimeout(onProgressHideTimeout);
|
|
onProgressHideTimeout = setTimeout(() => {
|
|
if (!progressBar.value) return;
|
|
progressBar.value.style.opacity = '0'; // Fade out
|
|
setTimeout(() => {
|
|
if (!progressBar.value) return;
|
|
progressBar.value.style.display = 'none'; // Actually hide
|
|
}, 300); // 0.3s fade out
|
|
}, 1000);
|
|
};
|
|
|
|
const poster = ref<string>("")
|
|
const setPosterText = (newText: string) => {
|
|
poster.value = "data:image/svg+xml;charset=utf-8;base64," + btoa(
|
|
'<svg width="800" height="600" xmlns="http://www.w3.org/2000/svg" fill="gray">' +
|
|
'<text x="50%" y="0%" dominant-baseline="middle" text-anchor="middle" font-size="48px">' +
|
|
newText +
|
|
'</text>' +
|
|
'</svg>')
|
|
}
|
|
setPosterText("Loading...")
|
|
|
|
class Line3DData {
|
|
startHotspot: HTMLElement = document.body
|
|
endHotspot: HTMLElement = document.body
|
|
start2D: [number, number] = [-1000, -1000];
|
|
end2D: [number, number] = [-1000, -1000];
|
|
lineAttrs: { [key: string]: string } = {"stroke-width": "2", "stroke": "red"}
|
|
centerText?: string = undefined;
|
|
centerTextSize: [number, number] = [0, 0];
|
|
}
|
|
|
|
let nextLineId = 1; // Avoid 0 (falsy!)
|
|
let lines = ref<{ [id: number]: Line3DData }>({});
|
|
|
|
function positionToHotspot(position: Vector3): string {
|
|
return position.x + ' ' + position.y + ' ' + position.z;
|
|
}
|
|
|
|
function addLine3D(p1: Vector3, p2: Vector3, centerText?: string, lineAttrs: { [key: string]: string } = {
|
|
"stroke-width": "2",
|
|
"stroke": "red",
|
|
}): number | null {
|
|
if (!scene.value || !elem.value?.shadowRoot) return null
|
|
let id = nextLineId++;
|
|
let hotspotName1 = 'line' + id + '_start';
|
|
let hotspotName2 = 'line' + id + '_end';
|
|
scene.value.addHotspot(new Hotspot({name: hotspotName1, position: positionToHotspot(p1)}));
|
|
scene.value.addHotspot(new Hotspot({name: hotspotName2, position: positionToHotspot(p2)}));
|
|
lines.value[id] = {
|
|
startHotspot: elem.value.shadowRoot.querySelector('slot[name="' + hotspotName1 + '"]')!!.parentElement!!,
|
|
endHotspot: elem.value.shadowRoot.querySelector('slot[name="' + hotspotName2 + '"]')!!.parentElement!!,
|
|
start2D: [-1000, -1000],
|
|
end2D: [-1000, -1000],
|
|
centerText: centerText,
|
|
centerTextSize: [0, 0],
|
|
lineAttrs: lineAttrs
|
|
};
|
|
scene.value.queueRender() // Needed to update the hotspots
|
|
requestIdleCallback(() => onCameraChangeLine(id), {timeout: 100});
|
|
return id;
|
|
}
|
|
|
|
function removeLine3D(id: number): boolean {
|
|
if (!scene.value || !(id in lines.value)) return false;
|
|
scene.value.removeHotspot(new Hotspot({name: 'line' + id + '_start'}));
|
|
lines.value[id].startHotspot.parentElement?.remove()
|
|
scene.value.removeHotspot(new Hotspot({name: 'line' + id + '_end'}));
|
|
lines.value[id].endHotspot.parentElement?.remove()
|
|
delete lines.value[id];
|
|
scene.value.queueRender() // Needed to update the hotspots
|
|
return true;
|
|
}
|
|
|
|
let svg = ref<SVGElement | null>(null);
|
|
|
|
function onCameraChangeLine(lineId: number) {
|
|
if (!(lineId in lines.value) || !(elem.value)) return // Silently ignore (not updated yet)
|
|
// Update start and end 2D positions
|
|
let {x: xB, y: yB} = elem.value.getBoundingClientRect();
|
|
let {x, y} = lines.value[lineId].startHotspot.getBoundingClientRect();
|
|
lines.value[lineId].start2D = [x - xB, y - yB];
|
|
let {x: x2, y: y2} = lines.value[lineId].endHotspot.getBoundingClientRect();
|
|
lines.value[lineId].end2D = [x2 - xB, y2 - yB];
|
|
|
|
// Update the center text size if needed
|
|
if (svg.value && lines.value[lineId].centerText && lines.value[lineId].centerTextSize[0] === 0) {
|
|
let text = svg.value.getElementsByClassName('line' + lineId + '_text')[0] as SVGTextElement | undefined;
|
|
if (text) {
|
|
let bbox = text.getBBox();
|
|
lines.value[lineId].centerTextSize = [bbox.width, bbox.height];
|
|
}
|
|
}
|
|
}
|
|
|
|
function onElemReady(callback: (elem: ModelViewerElement) => void) {
|
|
if (elem.value) {
|
|
callback(elem.value);
|
|
} else {
|
|
watch(() => elem.value, (elem) => {
|
|
if (elem) callback(elem);
|
|
});
|
|
}
|
|
}
|
|
|
|
function entries(lines: { [id: number]: Line3DData }): [string, Line3DData][] {
|
|
return Object.entries(lines);
|
|
}
|
|
|
|
defineExpose({elem, onElemReady, scene, renderer, controls, addLine3D, removeLine3D, onProgress, setPosterText});
|
|
|
|
let {disableTap} = inject<{ disableTap: Ref<boolean> }>('disableTap')!!;
|
|
watch(disableTap, (newDisableTap) => {
|
|
if (elem.value) elem.value.disableTap = newDisableTap;
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<!-- The main 3D model viewer -->
|
|
<model-viewer ref="elem" v-if="sett != null" :ar="sett.arModes.length > 0" :ar-modes="sett.arModes"
|
|
:environment-image="sett.background" :exposure="sett.exposure" :autoplay="sett.autoplay"
|
|
:orbit-sensitivity="sett.orbitSensitivity" :pan-sensitivity="sett.panSensitivity"
|
|
:poster="poster" :shadow-intensity="sett.shadowIntensity" :skybox-image="sett.skybox"
|
|
:src="props.src" :zoom-sensitivity="sett.zoomSensitivity" alt="The 3D model(s)" camera-controls
|
|
camera-orbit="30deg 75deg auto" interaction-prompt="none" max-camera-orbit="Infinity 180deg auto"
|
|
min-camera-orbit="-Infinity 0deg 5%" style="width: 100%; height: 100%">
|
|
<slot></slot>
|
|
<!-- Add a progress bar to the top of the model viewer -->
|
|
<div ref="progressBar" slot="progress-bar" class="progress-bar">
|
|
<div ref="updateBar" class="update-bar"/>
|
|
</div>
|
|
</model-viewer>
|
|
|
|
<!-- The SVG overlay for fake 3D lines attached to the model -->
|
|
<div class="overlay-svg-wrapper">
|
|
<svg ref="svg" class="overlay-svg" height="100%" width="100%" xmlns="http://www.w3.org/2000/svg">
|
|
<g v-for="[lineId, line] in entries(lines)" :key="lineId">
|
|
<line :x1="line.start2D[0]" :x2="line.end2D[0]" :y1="line.start2D[1]"
|
|
:y2="line.end2D[1]" v-bind="line.lineAttrs"/>
|
|
<g v-if="line.centerText !== undefined">
|
|
<rect v-if="line.centerText"
|
|
:height="line.centerTextSize[1] + 4"
|
|
:width="line.centerTextSize[0] + 8"
|
|
:x="(line.start2D[0] + line.end2D[0]) / 2 - line.centerTextSize[0]/2 - 4"
|
|
:y="(line.start2D[1] + line.end2D[1]) / 2 - line.centerTextSize[1]/2 - 2" fill="gray"
|
|
fill-opacity="0.75" rx="4" ry="4" stroke="black"/>
|
|
<text v-if="line.centerText" :class="'line' + lineId + '_text'"
|
|
:x="(line.start2D[0] + line.end2D[0]) / 2" :y="(line.start2D[1] + line.end2D[1]) / 2"
|
|
dominant-baseline="middle" fill="black"
|
|
font-size="16" text-anchor="middle">
|
|
{{ line.centerText }}
|
|
</text>
|
|
</g>
|
|
</g>
|
|
</svg>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
/* This keeps child nodes hidden while the element loads */
|
|
:not(:defined) > * {
|
|
display: none;
|
|
}
|
|
|
|
/* This is the SVG overlay that will be used for line annotations */
|
|
.overlay-svg-wrapper {
|
|
position: relative;
|
|
top: -100%;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 0;
|
|
pointer-events: none;
|
|
z-index: 1;
|
|
}
|
|
|
|
.overlay-svg {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100dvh;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.progress-bar {
|
|
display: block;
|
|
pointer-events: none;
|
|
width: 100%;
|
|
height: 10%;
|
|
max-height: 2%;
|
|
position: absolute;
|
|
left: 50%;
|
|
top: 0;
|
|
transform: translate3d(-50%, 0%, 0);
|
|
border-radius: 25px;
|
|
box-shadow: 0 3px 10px 3px rgba(0, 0, 0, 0.5), 0 0 5px 1px rgba(0, 0, 0, 0.6);
|
|
border: 1px solid rgba(255, 255, 255, 0.9);
|
|
background-color: rgba(0, 0, 0, 0.5);
|
|
transition: opacity 0.3s;
|
|
}
|
|
|
|
.update-bar {
|
|
background-color: rgba(255, 255, 255, 0.9);
|
|
width: 0;
|
|
height: 100%;
|
|
border-radius: 25px;
|
|
float: left;
|
|
transition: width 0.3s;
|
|
}
|
|
</style>
|