mirror of
https://github.com/yeicor-3d/yet-another-cad-viewer.git
synced 2025-12-19 22:24:17 +01:00
big rewrite focusing on faster performance and selection improvements
This commit is contained in:
@@ -32,8 +32,18 @@ const disableTap = ref(false);
|
||||
const setDisableTap = (val: boolean) => disableTap.value = val;
|
||||
provide('disableTap', {disableTap, setDisableTap});
|
||||
|
||||
async function onModelLoadRequest(model: NetworkUpdateEvent) {
|
||||
sceneDocument.value = await SceneMgr.loadModel(sceneUrl, sceneDocument.value, model.name, model.url);
|
||||
async function onModelLoadRequest(event: NetworkUpdateEvent) {
|
||||
// Load a new batch of models to optimize rendering time
|
||||
let doc = sceneDocument.value;
|
||||
for (let model of event.models) {
|
||||
let isLast = event.models[event.models.length - 1].url == model.url;
|
||||
if (!model.isRemove) {
|
||||
doc = await SceneMgr.loadModel(sceneUrl, doc, model.name, model.url, isLast, isLast);
|
||||
} else {
|
||||
doc = await SceneMgr.removeModel(sceneUrl, doc, model.name, isLast);
|
||||
}
|
||||
}
|
||||
sceneDocument.value = doc
|
||||
triggerRef(sceneDocument); // Why not triggered automatically?
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ function getCenterAndVertexList(obj: MObject3D, scene: ModelScene): {
|
||||
vertices.push(vertex);
|
||||
}
|
||||
center = center.divideScalar(ind.count);
|
||||
console.log("center", center)
|
||||
return {center, vertices};
|
||||
}
|
||||
|
||||
@@ -46,7 +45,6 @@ export function distances(a: MObject3D, b: MObject3D, scene: ModelScene): {
|
||||
|
||||
// Find the closest and farthest vertices.
|
||||
// TODO: Compute actual min and max distances between the two objects.
|
||||
// FIXME: Working for points and lines, but not triangles...
|
||||
// FIXME: Really slow... (use a BVH or something)
|
||||
let minDistance = Infinity;
|
||||
let minDistanceVertices = [new Vector3(), new Vector3()];
|
||||
|
||||
@@ -1,19 +1,36 @@
|
||||
import {settings} from "./settings";
|
||||
|
||||
export class NetworkUpdateEvent extends Event {
|
||||
const batchTimeout = 250; // ms
|
||||
|
||||
class NetworkUpdateEventModel {
|
||||
name: string;
|
||||
url: string;
|
||||
// TODO: Detect and manage instances of the same object (same hash, different name)
|
||||
hash: string | null;
|
||||
isRemove: boolean;
|
||||
|
||||
constructor(name: string, url: string) {
|
||||
super("update");
|
||||
constructor(name: string, url: string, hash: string | null, isDelete: boolean) {
|
||||
this.name = name;
|
||||
this.url = url;
|
||||
this.hash = hash;
|
||||
this.isRemove = isDelete;
|
||||
}
|
||||
}
|
||||
|
||||
export class NetworkUpdateEvent extends Event {
|
||||
models: NetworkUpdateEventModel[];
|
||||
|
||||
constructor(models: NetworkUpdateEventModel[]) {
|
||||
super("update");
|
||||
this.models = models;
|
||||
}
|
||||
}
|
||||
|
||||
/** Listens for updates and emits events when a model changes */
|
||||
export class NetworkManager extends EventTarget {
|
||||
private knownObjectHashes: { [name: string]: string | null } = {};
|
||||
private bufferedUpdates: NetworkUpdateEventModel[] = [];
|
||||
private batchTimeout: number | null = null;
|
||||
|
||||
/**
|
||||
* Tries to load a new model (.glb) from the given URL.
|
||||
@@ -36,7 +53,7 @@ export class NetworkManager extends EventTarget {
|
||||
let response = await fetch(url, {method: "HEAD"});
|
||||
let hash = response.headers.get("etag");
|
||||
// Only trigger an update if the hash has changed
|
||||
this.foundModel(name, hash, url);
|
||||
this.foundModel(name, hash, url, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,17 +61,17 @@ export class NetworkManager extends EventTarget {
|
||||
try {
|
||||
// WARNING: This will spam the console logs with failed requests when the server is down
|
||||
let response = await fetch(url.toString());
|
||||
console.log("Monitoring", url.toString(), response);
|
||||
// console.log("Monitoring", url.toString(), response);
|
||||
if (response.status === 200) {
|
||||
let lines = readLinesStreamings(response.body!.getReader());
|
||||
for await (let line of lines) {
|
||||
if (!line || !line.startsWith("data:")) continue;
|
||||
let data = JSON.parse(line.slice(5));
|
||||
console.debug("WebSocket message", data);
|
||||
// console.debug("WebSocket message", data);
|
||||
let urlObj = new URL(url);
|
||||
urlObj.searchParams.delete("api_updates");
|
||||
urlObj.searchParams.set("api_object", data.name);
|
||||
this.foundModel(data.name, data.hash, urlObj.toString());
|
||||
this.foundModel(data.name, data.hash, urlObj.toString(), data.is_remove);
|
||||
}
|
||||
}
|
||||
} catch (e) { // Ignore errors (retry very soon)
|
||||
@@ -63,12 +80,21 @@ export class NetworkManager extends EventTarget {
|
||||
return;
|
||||
}
|
||||
|
||||
private foundModel(name: string, hash: string | null, url: string) {
|
||||
private foundModel(name: string, hash: string | null, url: string, isRemove: boolean) {
|
||||
let prevHash = this.knownObjectHashes[name];
|
||||
// TODO: Detect and manage instances of the same object (same hash, different name)
|
||||
if (!hash || hash !== prevHash) {
|
||||
let hashToCheck = hash + (isRemove ? "-remove" : "");
|
||||
// console.debug("Found model", name, "with hash", hash, "and previous hash", prevHash);
|
||||
if (!hash || hashToCheck !== prevHash) {
|
||||
this.knownObjectHashes[name] = hash;
|
||||
this.dispatchEvent(new NetworkUpdateEvent(name, url));
|
||||
let newModel = new NetworkUpdateEventModel(name, url, hash, isRemove);
|
||||
this.bufferedUpdates.push(newModel);
|
||||
|
||||
// Optimization: try to batch updates automatically for faster rendering
|
||||
if (this.batchTimeout !== null) clearTimeout(this.batchTimeout);
|
||||
this.batchTimeout = setTimeout(() => {
|
||||
this.dispatchEvent(new NetworkUpdateEvent(this.bufferedUpdates));
|
||||
this.bufferedUpdates = [];
|
||||
}, batchTimeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {Matrix4} from "three/src/math/Matrix4.js"
|
||||
/** This class helps manage SceneManagerData. All methods are static to support reactivity... */
|
||||
export class SceneMgr {
|
||||
/** Loads a GLB model from a URL and adds it to the viewer or replaces it if the names match */
|
||||
static async loadModel(sceneUrl: Ref<string>, document: Document, name: string, url: string): Promise<Document> {
|
||||
static async loadModel(sceneUrl: Ref<string>, document: Document, name: string, url: string, updateHelpers: boolean = true, reloadScene: boolean = true): Promise<Document> {
|
||||
let loadStart = performance.now();
|
||||
|
||||
// Start merging into the current document, replacing or adding as needed
|
||||
@@ -17,11 +17,13 @@ export class SceneMgr {
|
||||
|
||||
console.log("Model", name, "loaded in", performance.now() - loadStart, "ms");
|
||||
|
||||
if (name !== extrasNameValueHelpers) {
|
||||
if (updateHelpers) {
|
||||
// Reload the helpers to fit the new model
|
||||
// TODO: Only reload the helpers after a few milliseconds of no more models being added/removed
|
||||
await this.reloadHelpers(sceneUrl, document);
|
||||
} else {
|
||||
await this.reloadHelpers(sceneUrl, document, reloadScene);
|
||||
reloadScene = false;
|
||||
}
|
||||
|
||||
if (reloadScene) {
|
||||
// Display the final fully loaded model
|
||||
let displayStart = performance.now();
|
||||
document = await this.showCurrentDoc(sceneUrl, document);
|
||||
@@ -31,7 +33,7 @@ export class SceneMgr {
|
||||
return document;
|
||||
}
|
||||
|
||||
private static async reloadHelpers(sceneUrl: Ref<string>, document: Document): Promise<Document> {
|
||||
private static async reloadHelpers(sceneUrl: Ref<string>, document: Document, reloadScene: boolean): Promise<Document> {
|
||||
let bb = SceneMgr.getBoundingBox(document);
|
||||
|
||||
// Create the helper axes and grid box
|
||||
@@ -40,7 +42,7 @@ export class SceneMgr {
|
||||
newAxes(helpersDoc, bb.getSize(new Vector3()).multiplyScalar(0.5), transform);
|
||||
newGridBox(helpersDoc, bb.getSize(new Vector3()), transform);
|
||||
let helpersUrl = URL.createObjectURL(new Blob([await toBuffer(helpersDoc)]));
|
||||
return await SceneMgr.loadModel(sceneUrl, document, extrasNameValueHelpers, helpersUrl);
|
||||
return await SceneMgr.loadModel(sceneUrl, document, extrasNameValueHelpers, helpersUrl, false, reloadScene);
|
||||
}
|
||||
|
||||
static getBoundingBox(document: Document): Box3 {
|
||||
@@ -67,7 +69,7 @@ export class SceneMgr {
|
||||
}
|
||||
|
||||
/** Removes a model from the viewer */
|
||||
static async removeModel(sceneUrl: Ref<string>, document: Document, name: string): Promise<Document> {
|
||||
static async removeModel(sceneUrl: Ref<string>, document: Document, name: string, updateHelpers: boolean = true, reloadScene: boolean = true): Promise<Document> {
|
||||
let loadStart = performance.now();
|
||||
|
||||
// Remove the model from the document
|
||||
@@ -75,8 +77,10 @@ export class SceneMgr {
|
||||
|
||||
console.log("Model", name, "removed in", performance.now() - loadStart, "ms");
|
||||
|
||||
// Reload the helpers to fit the new model (will also show the document)
|
||||
document = await this.reloadHelpers(sceneUrl, document);
|
||||
if (updateHelpers) {
|
||||
// Reload the helpers to fit the new model (will also show the document)
|
||||
document = await this.reloadHelpers(sceneUrl, document, reloadScene);
|
||||
}
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ function createGizmo(expectedParent: HTMLElement, scene: ModelScene): HTMLElemen
|
||||
}
|
||||
scene.queueRender();
|
||||
requestIdleCallback(() => props.elem?.dispatchEvent(
|
||||
new CustomEvent('camera-change', {detail: {source: 'none'}})))
|
||||
new CustomEvent('camera-change', {detail: {source: 'none'}})), {timeout: 100})
|
||||
}
|
||||
return gizmo;
|
||||
}
|
||||
@@ -60,7 +60,7 @@ let gizmo: HTMLElement & { update: () => void }
|
||||
function updateGizmo() {
|
||||
if (gizmo.isConnected) {
|
||||
gizmo.update();
|
||||
requestIdleCallback(updateGizmo);
|
||||
requestIdleCallback(updateGizmo, {timeout: 250});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ let reinstall = () => {
|
||||
if (gizmo) container.value.removeChild(gizmo);
|
||||
gizmo = createGizmo(container.value, props.scene as ModelScene) as typeof gizmo;
|
||||
container.value.appendChild(gizmo);
|
||||
requestIdleCallback(updateGizmo); // Low priority updates
|
||||
requestIdleCallback(updateGizmo, {timeout: 250}); // Low priority updates
|
||||
}
|
||||
onMounted(reinstall)
|
||||
onUpdated(reinstall);
|
||||
|
||||
@@ -54,15 +54,22 @@ let selectionListener = (event: MouseEvent) => {
|
||||
}
|
||||
|
||||
// Set raycaster parameters
|
||||
let paramScale = 1; // Make it easier to select vertices/edges based on camera distance
|
||||
if (props.viewer?.scene) {
|
||||
let scene = props.viewer.scene;
|
||||
let lookAtCenter = scene.getTarget().clone().add(scene.target.position);
|
||||
paramScale = scene.camera.position.distanceTo(lookAtCenter) / 150;
|
||||
// console.log('paramScale', paramScale)
|
||||
}
|
||||
if (selectFilter.value === 'Any (S)') {
|
||||
raycaster.params.Line.threshold = 0.2;
|
||||
raycaster.params.Points.threshold = 0.8;
|
||||
raycaster.params.Line.threshold = paramScale;
|
||||
raycaster.params.Points.threshold = paramScale * 2; // Make vertices easier to select than edges
|
||||
} else if (selectFilter.value === '(E)dges') {
|
||||
raycaster.params.Line.threshold = 0.8;
|
||||
raycaster.params.Line.threshold = paramScale;
|
||||
raycaster.params.Points.threshold = 0.0;
|
||||
} else if (selectFilter.value === '(V)ertices') {
|
||||
raycaster.params.Line.threshold = 0.0;
|
||||
raycaster.params.Points.threshold = 0.8;
|
||||
raycaster.params.Points.threshold = paramScale;
|
||||
} else if (selectFilter.value === '(F)aces') {
|
||||
raycaster.params.Line.threshold = 0.0;
|
||||
raycaster.params.Points.threshold = 0.0;
|
||||
@@ -74,7 +81,7 @@ let selectionListener = (event: MouseEvent) => {
|
||||
const ndcCoords = scene.getNDC(event.clientX, event.clientY);
|
||||
raycaster.setFromCamera(ndcCoords, scene.camera);
|
||||
if (!scene.camera.isPerspectiveCamera) {
|
||||
// Need to fix the ray direction for ortho camera FIXME: Still buggy...
|
||||
// Need to fix the ray direction for ortho camera FIXME: Still buggy for off-center clicks
|
||||
raycaster.ray.direction.copy(scene.camera.getWorldDirection(new Vector3()));
|
||||
}
|
||||
//console.log('Ray', raycaster.ray);
|
||||
@@ -87,19 +94,36 @@ let selectionListener = (event: MouseEvent) => {
|
||||
|
||||
// Find all hit objects and select the wanted one based on the filter
|
||||
const hits = raycaster.intersectObject(scene, true);
|
||||
let hit = hits.find((hit: Intersection<Object3D>) => {
|
||||
if (!hit.object) return false;
|
||||
const kind = hit.object.type
|
||||
let isFace = kind === 'Mesh' || kind === 'SkinnedMesh';
|
||||
let isEdge = kind === 'Line' || kind === 'LineSegments';
|
||||
let isVertex = kind === 'Points';
|
||||
const kindOk = (selectFilter.value === 'Any (S)') ||
|
||||
(isFace && selectFilter.value === '(F)aces') ||
|
||||
(isEdge && selectFilter.value === '(E)dges') ||
|
||||
(isVertex && selectFilter.value === '(V)ertices');
|
||||
return hit.object.visible && !hit.object.userData.noHit && kindOk;
|
||||
}) as Intersection<MObject3D> | undefined;
|
||||
//console.log('Hit', hit)
|
||||
let hit = hits
|
||||
// Check feasibility
|
||||
.filter((hit: Intersection<Object3D>) => {
|
||||
if (!hit.object) return false;
|
||||
const kind = hit.object.type
|
||||
let isFace = kind === 'Mesh' || kind === 'SkinnedMesh';
|
||||
let isEdge = kind === 'Line' || kind === 'LineSegments';
|
||||
let isVertex = kind === 'Points';
|
||||
const kindOk = (selectFilter.value === 'Any (S)') ||
|
||||
(isFace && selectFilter.value === '(F)aces') ||
|
||||
(isEdge && selectFilter.value === '(E)dges') ||
|
||||
(isVertex && selectFilter.value === '(V)ertices');
|
||||
return (!isFace || hit.object.visible) && !hit.object.userData.noHit && kindOk;
|
||||
})
|
||||
// Sort for highlighting partially hidden edges/vertices
|
||||
.sort((a, b) => {
|
||||
function lowerIsBetter(hit: Intersection<Object3D>) {
|
||||
let score = hit.distance;
|
||||
// Faces are easier to hit than 0-width edges/vertices, so we need to adjust scores
|
||||
if (hit.object.type === 'Mesh' || hit.object.type === 'SkinnedMesh') score += paramScale;
|
||||
// Edges are easier to hit than vertices, so we need to adjust scores
|
||||
if (hit.object.type === 'Line' || hit.object.type === 'LineSegments') score += paramScale / 2;
|
||||
return score;
|
||||
}
|
||||
|
||||
return lowerIsBetter(a) - lowerIsBetter(b);
|
||||
})
|
||||
// Return the best hit
|
||||
[0] as Intersection<MObject3D> | undefined;
|
||||
// console.log('Hit', hit)
|
||||
|
||||
if (!highlightNextSelection.value[0]) {
|
||||
// If we are selecting, toggle the selection or deselect all if no hit
|
||||
@@ -126,7 +150,7 @@ let selectionListener = (event: MouseEvent) => {
|
||||
}
|
||||
|
||||
function select(hit: Intersection<MObject3D>) {
|
||||
console.log('Selecting', hit.object.name)
|
||||
// console.log('Selecting', hit.object.name)
|
||||
if (selected.value.find((m) => m.object.name === hit.object.name) === undefined) {
|
||||
selected.value.push(hit);
|
||||
}
|
||||
@@ -141,7 +165,7 @@ function select(hit: Intersection<MObject3D>) {
|
||||
}
|
||||
|
||||
function deselect(hit: Intersection<MObject3D>, alsoRemove = true) {
|
||||
console.log('Deselecting', hit.object.name)
|
||||
// console.log('Deselecting', hit.object.name)
|
||||
if (alsoRemove) {
|
||||
// Remove the matching object from the selection
|
||||
let toRemove = selected.value.findIndex((m) => m.object.name === hit.object.name);
|
||||
@@ -293,6 +317,8 @@ function updateBoundingBox() {
|
||||
}
|
||||
let from = new Vector3(...corners[edge[0]]);
|
||||
let to = new Vector3(...corners[edge[1]]);
|
||||
let length = to.clone().sub(from).length();
|
||||
if (length < 0.05) continue; // Skip very small edges (e.g. a single point)
|
||||
let color = [AxesColors.x, AxesColors.y, AxesColors.z][edgeI][1]; // Secondary colors
|
||||
let lineCacheKey = JSON.stringify([from, to]);
|
||||
let matchingLine = boundingBoxLines[lineCacheKey];
|
||||
@@ -300,7 +326,7 @@ function updateBoundingBox() {
|
||||
boundingBoxLinesToRemove = boundingBoxLinesToRemove.filter((l) => l !== lineCacheKey);
|
||||
} else {
|
||||
let newLineId = props.viewer?.addLine3D(from, to,
|
||||
to.clone().sub(from).length().toFixed(1) + "mm", {
|
||||
length.toFixed(1) + "mm", {
|
||||
"stroke": "rgb(" + color.join(',') + ")",
|
||||
"stroke-width": "2"
|
||||
});
|
||||
@@ -337,7 +363,7 @@ function updateDistances() {
|
||||
let distanceLinesToRemove = Object.keys(distanceLines);
|
||||
|
||||
function ensureLine(from: Vector3, to: Vector3, text: string, color: string) {
|
||||
console.log('ensureLine', from, to, text, color)
|
||||
// console.log('ensureLine', from, to, text, color)
|
||||
let lineCacheKey = JSON.stringify([from, to]);
|
||||
let matchingLine = distanceLines[lineCacheKey];
|
||||
if (matchingLine) {
|
||||
@@ -444,7 +470,7 @@ window.addEventListener('keydown', (event) => {
|
||||
|
||||
.select-parent .v-btn {
|
||||
position: relative;
|
||||
top: -42px;
|
||||
top: -20px;
|
||||
}
|
||||
|
||||
.select-only {
|
||||
|
||||
@@ -81,7 +81,7 @@ function toggleProjection() {
|
||||
toggleProjectionText.value = wasPerspectiveCamera ? 'ORTHO' : 'PERSP';
|
||||
// The camera change may take a few frames to take effect, dispatch the event after a delay
|
||||
requestIdleCallback(() => props.viewer?.elem?.dispatchEvent(
|
||||
new CustomEvent('camera-change', {detail: {source: 'none'}})))
|
||||
new CustomEvent('camera-change', {detail: {source: 'none'}})), {timeout: 100})
|
||||
}
|
||||
|
||||
async function centerCamera() {
|
||||
|
||||
@@ -73,7 +73,7 @@ function addLine3D(p1: Vector3, p2: Vector3, centerText?: string, lineAttrs: { [
|
||||
lineAttrs: lineAttrs
|
||||
};
|
||||
scene.value.queueRender() // Needed to update the hotspots
|
||||
requestIdleCallback(() => onCameraChangeLine(id));
|
||||
requestIdleCallback(() => onCameraChangeLine(id), {timeout: 100});
|
||||
return id;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user