lots of performance improvements, bug fixes and some new features

This commit is contained in:
Yeicor
2024-03-25 21:37:28 +01:00
parent ec7139c809
commit 632e7e93c6
22 changed files with 710 additions and 296 deletions

View File

@@ -47,9 +47,9 @@ async function onModelUpdateRequest(event: NetworkUpdateEvent) {
let model = event.models[modelIndex];
try {
if (!model.isRemove) {
doc = await SceneMgr.loadModel(sceneUrl, doc, model.name, model.url, isLast, isLast);
doc = await SceneMgr.loadModel(sceneUrl, doc, model.name, model.url, isLast && settings.loadHelpers, isLast);
} else {
doc = await SceneMgr.removeModel(sceneUrl, doc, model.name, isLast);
doc = await SceneMgr.removeModel(sceneUrl, doc, model.name, isLast && settings.loadHelpers, isLast);
}
} catch (e) {
console.error("Error loading model", model, e);

View File

@@ -1,16 +1,17 @@
import {BufferAttribute, InterleavedBufferAttribute, Vector3} from 'three';
import type {MObject3D} from "../tools/Selection.vue";
import type { ModelScene } from '@google/model-viewer/lib/three-components/ModelScene';
import type {ModelScene} from '@google/model-viewer/lib/three-components/ModelScene';
import type {SelectionInfo} from "../tools/selection";
function getCenterAndVertexList(obj: MObject3D, scene: ModelScene): {
function getCenterAndVertexList(selInfo: SelectionInfo, scene: ModelScene): {
center: Vector3,
vertices: Array<Vector3>
} {
obj.updateMatrixWorld();
let pos: BufferAttribute | InterleavedBufferAttribute = obj.geometry.getAttribute('position');
let ind: BufferAttribute | null = obj.geometry.index;
if (!ind) {
selInfo.object.updateMatrixWorld();
let pos: BufferAttribute | InterleavedBufferAttribute = selInfo.object.geometry.getAttribute('position');
let ind: BufferAttribute | null = selInfo.object.geometry.index;
if (ind === null) {
ind = new BufferAttribute(new Uint16Array(pos.count), 1);
for (let i = 0; i < pos.count; i++) {
ind.array[i] = i;
@@ -18,14 +19,14 @@ function getCenterAndVertexList(obj: MObject3D, scene: ModelScene): {
}
let center = new Vector3();
let vertices = [];
for (let i = 0; i < ind.count; i++) {
let index = ind.array[i];
for (let i = selInfo.indices[0]; i < selInfo.indices[1]; i++) {
let index = ind.getX(i)
let vertex = new Vector3(pos.getX(index), pos.getY(index), pos.getZ(index));
vertex = scene.target.worldToLocal(obj.localToWorld(vertex));
vertex = scene.target.worldToLocal(selInfo.object.localToWorld(vertex));
center.add(vertex);
vertices.push(vertex);
}
center = center.divideScalar(ind.count);
center = center.divideScalar(selInfo.indices[1] - selInfo.indices[0]);
return {center, vertices};
}
@@ -33,7 +34,7 @@ function getCenterAndVertexList(obj: MObject3D, scene: ModelScene): {
* Given two THREE.Object3D objects, returns their closest and farthest vertices, and the geometric centers.
* All of them are approximated and should not be used for precise calculations.
*/
export function distances(a: MObject3D, b: MObject3D, scene: ModelScene): {
export function distances(a: SelectionInfo, b: SelectionInfo, scene: ModelScene): {
min: Array<Vector3>,
center: Array<Vector3>,
max: Array<Vector3>

View File

@@ -1,4 +1,4 @@
import {Document, Scene, type Transform, WebIO, Buffer} from "@gltf-transform/core";
import {Buffer, Document, Scene, type Transform, WebIO} from "@gltf-transform/core";
import {unpartition} from "@gltf-transform/functions";
let io = new WebIO();
@@ -12,10 +12,16 @@ export let extrasNameValueHelpers = "__helpers";
*
* Remember to call mergeFinalize after all models have been merged (slower required operations).
*/
export async function mergePartial(url: string, name: string, document: Document, networkFinished: () => void = () => {}): Promise<Document> {
export async function mergePartial(url: string, name: string, document: Document, networkFinished: () => void = () => {
}): Promise<Document> {
// Fetch the complete document from the network
// This could be done at the same time as the document is being processed, but I wanted better metrics
let response = await fetch(url);
let buffer = await response.arrayBuffer();
networkFinished();
// Load the new document
let newDoc = await io.read(url);
networkFinished()
let newDoc = await io.readBinary(new Uint8Array(buffer));
// Remove any previous model with the same name
await document.transform(dropByName(name));

View File

@@ -1,3 +1,5 @@
// noinspection JSVoidFunctionReturnValueUsed,JSUnresolvedReference
import {Document, type TypedArray} from '@gltf-transform/core'
import {Vector2} from "three/src/math/Vector2.js"
import {Vector3} from "three/src/math/Vector3.js"
@@ -26,7 +28,7 @@ function buildSimpleGltf(doc: Document, rawPositions: number[], rawIndices: numb
if (rawColors) {
colors = doc.createAccessor(name + 'Color')
.setArray(new Float32Array(rawColors) as TypedArray)
.setType('VEC3')
.setType('VEC4')
.setBuffer(buffer);
}
const material = doc.createMaterial(name + 'Material')
@@ -39,6 +41,11 @@ function buildSimpleGltf(doc: Document, rawPositions: number[], rawIndices: numb
if (rawColors) {
geometry.setAttribute('COLOR_0', colors)
}
if (mode == WebGL2RenderingContext.TRIANGLES) {
geometry.setExtras({face_triangles_end: [rawIndices.length / 6, rawIndices.length * 2 / 6, rawIndices.length * 3 / 6, rawIndices.length * 4 / 6, rawIndices.length * 5 / 6, rawIndices.length]})
} else if (mode == WebGL2RenderingContext.LINES) {
geometry.setExtras({edge_points_end: [rawIndices.length / 3, rawIndices.length * 2 / 3, rawIndices.length]})
}
const mesh = doc.createMesh(name + 'Mesh').addPrimitive(geometry)
const node = doc.createNode(name + 'Node').setMesh(mesh).setMatrix(transform.elements as any)
scene.addChild(node)
@@ -48,21 +55,19 @@ function buildSimpleGltf(doc: Document, rawPositions: number[], rawIndices: numb
* Create a new Axes helper as a GLTF model, useful for debugging positions and orientations.
*/
export function newAxes(doc: Document, size: Vector3, transform: Matrix4) {
let rawIndices = [0, 1, 2, 3, 4, 5];
let rawPositions = [
[0, 0, 0, size.x, 0, 0],
[0, 0, 0, 0, size.y, 0],
[0, 0, 0, 0, 0, -size.z],
0, 0, 0, size.x, 0, 0,
0, 0, 0, 0, size.y, 0,
0, 0, 0, 0, 0, -size.z,
];
let rawIndices = [0, 1];
let rawColors = [
[...(AxesColors.x[0]), ...(AxesColors.x[1])],
[...(AxesColors.y[0]), ...(AxesColors.y[1])],
[...(AxesColors.z[0]), ...(AxesColors.z[1])],
].map(g => g.map(x => x / 255.0));
buildSimpleGltf(doc, rawPositions[0], rawIndices, rawColors[0], transform, '__helper_axes');
buildSimpleGltf(doc, rawPositions[1], rawIndices, rawColors[1], transform, '__helper_axes');
buildSimpleGltf(doc, rawPositions[2], rawIndices, rawColors[2], transform, '__helper_axes');
buildSimpleGltf(doc, [0, 0, 0], [0], null, transform, '__helper_axes', WebGL2RenderingContext.POINTS);
...(AxesColors.x[0]), 255, ...(AxesColors.x[1]), 255,
...(AxesColors.y[0]), 255, ...(AxesColors.y[1]), 255,
...(AxesColors.z[0]), 255, ...(AxesColors.z[1]), 255
].map(x => x / 255.0);
buildSimpleGltf(doc, rawPositions, rawIndices, rawColors, new Matrix4(), '__helper_axes'); // Axes at (0,0,0)!
buildSimpleGltf(doc, [0, 0, 0], [0], [1, 1, 1, 1], transform, '__helper_axes', WebGL2RenderingContext.POINTS);
}
/**
@@ -71,8 +76,10 @@ export function newAxes(doc: Document, size: Vector3, transform: Matrix4) {
* The grid is built as a box of triangles (representing lines) looking to the inside of the box.
* This ensures that only the back of the grid is always visible, regardless of the camera position.
*/
export function newGridBox(doc: Document, size: Vector3, baseTransform: Matrix4 = new Matrix4(), divisions = 10) {
export async function newGridBox(doc: Document, size: Vector3, baseTransform: Matrix4, divisions = 10) {
// Create transformed positions for the inner faces of the box
let allPositions: number[] = [];
let allIndices: number[] = [];
for (let axis of [new Vector3(1, 0, 0), new Vector3(0, 1, 0), new Vector3(0, 0, -1)]) {
for (let positive of [1, -1]) {
let offset = axis.clone().multiply(size.clone().multiplyScalar(0.5 * positive));
@@ -82,13 +89,25 @@ export function newGridBox(doc: Document, size: Vector3, baseTransform: Matrix4
if (axis.x) size2.set(size.z, size.y);
if (axis.y) size2.set(size.x, size.z);
if (axis.z) size2.set(size.x, size.y);
let transform = baseTransform.clone().multiply(translation).multiply(rotation);
newGridPlane(doc, size2, transform, divisions);
let transform = new Matrix4().multiply(translation).multiply(rotation);
let [rawPositions, rawIndices] = newGridPlane(size2, divisions);
let baseIndex = allPositions.length / 3;
for (let i of rawIndices) {
allIndices.push(i + baseIndex);
}
// Apply transform to the positions before adding them to the list
for (let i = 0; i < rawPositions.length; i += 3) {
let pos = new Vector3(rawPositions[i], rawPositions[i + 1], rawPositions[i + 2]);
pos.applyMatrix4(transform);
allPositions.push(pos.x, pos.y, pos.z);
}
}
}
let colors = new Array(allPositions.length / 3 * 4).fill(1);
buildSimpleGltf(doc, allPositions, allIndices, colors, baseTransform, '__helper_grid', WebGL2RenderingContext.TRIANGLES);
}
export function newGridPlane(doc: Document, size: Vector2, transform: Matrix4 = new Matrix4(), divisions = 10, divisionWidth = 0.002) {
export function newGridPlane(size: Vector2, divisions = 10, divisionWidth = 0.002): [number[], number[]] {
const rawPositions = [];
const rawIndices = [];
// Build the grid as triangles
@@ -114,5 +133,5 @@ export function newGridPlane(doc: Document, size: Vector2, transform: Matrix4 =
rawIndices.push(baseIndex2, baseIndex2 + 1, baseIndex2 + 2);
rawIndices.push(baseIndex2, baseIndex2 + 2, baseIndex2 + 3);
}
buildSimpleGltf(doc, rawPositions, rawIndices, null, transform, '__helper_grid', WebGL2RenderingContext.TRIANGLES);
return [rawPositions, rawIndices];
}

55
frontend/misc/lines.ts Normal file
View File

@@ -0,0 +1,55 @@
import {BufferGeometry} from 'three/src/core/BufferGeometry.js';
import {Vector2} from 'three/src/math/Vector2.js';
// The following imports must be done dynamically to be able to import three.js separately (smaller bundle sizee)
// import {LineSegments2} from "three/examples/jsm/lines/LineSegments2.js";
// import {LineMaterial} from "three/examples/jsm/lines/LineMaterial.js";
// import {LineSegmentsGeometry} from 'three/examples/jsm/lines/LineSegmentsGeometry.js';
const LineSegments2Import = import('three/examples/jsm/lines/LineSegments2.js');
const LineMaterialImport = import('three/examples/jsm/lines/LineMaterial.js');
const LineSegmentsGeometryImport = import('three/examples/jsm/lines/LineSegmentsGeometry.js');
export async function toLineSegments(bufferGeometry: BufferGeometry) {
const LineSegments2 = (await LineSegments2Import).LineSegments2;
const LineMaterial = (await LineMaterialImport).LineMaterial;
return new LineSegments2(await toLineSegmentsGeometry(bufferGeometry), new LineMaterial({
color: 0xffffffff,
vertexColors: true,
linewidth: 0.1, // mm
worldUnits: true,
resolution: new Vector2(1, 1), // Update resolution on resize!!!
}));
}
async function toLineSegmentsGeometry(bufferGeometry: BufferGeometry) {
const LineSegmentsGeometry = (await LineSegmentsGeometryImport).LineSegmentsGeometry;
const lg = new LineSegmentsGeometry();
const position = bufferGeometry.getAttribute('position');
const indexAttribute = bufferGeometry.index!!;
const positions = [];
for (let index = 0; index != indexAttribute.count; ++index) {
const i = indexAttribute.getX(index);
const x = position.getX(i);
const y = position.getY(i);
const z = position.getZ(i);
positions.push(x, y, z);
}
lg.setPositions(positions);
const colors = [];
const color = bufferGeometry.getAttribute('color');
if (color) {
for (let index = 0; index != indexAttribute.count; ++index) {
const i = indexAttribute.getX(index);
const r = color.getX(i);
const g = color.getY(i);
const b = color.getZ(i);
colors.push(r, g, b);
}
lg.setColors(colors);
}
lg.userData = bufferGeometry.userData;
return lg;
}

View File

@@ -9,13 +9,15 @@ 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, updateHelpers: boolean = true, reloadScene: boolean = true, networkFinished: () => void = () => {}): 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();
let loadNetworkEnd: number;
// Start merging into the current document, replacing or adding as needed
document = await mergePartial(url, name, document, networkFinished);
document = await mergePartial(url, name, document, () => loadNetworkEnd = performance.now());
console.log("Model", name, "loaded in", performance.now() - loadStart, "ms");
console.log("Model", name, "loaded in", performance.now() - loadNetworkEnd!, "ms after",
loadNetworkEnd! - loadStart, "ms of transferring data (maybe building the object on the server)");
if (updateHelpers) {
// Reload the helpers to fit the new model

View File

@@ -12,6 +12,7 @@ export const settings = {
// Websocket URLs automatically listen for new models from the python backend
"dev+http://127.0.0.1:32323/"
],
loadHelpers: true,
displayLoadingEveryMs: 1000, /* How often to display partially loaded models */
monitorEveryMs: 100,
monitorOpenTimeoutMs: 1000,

View File

@@ -25,13 +25,14 @@ import {
mdiVectorRectangle
} from '@mdi/js'
import SvgIcon from '@jamescoyle/vue-icon';
import {SceneMgr} from "../misc/scene";
import {BackSide, FrontSide} from "three/src/constants.js";
import {Box3} from "three/src/math/Box3.js";
import {Color} from "three/src/math/Color.js";
import {Plane} from "three/src/math/Plane.js";
import {Vector3} from "three/src/math/Vector3.js";
import {Vector2} from "three/src/math/Vector2.js";
import type {MObject3D} from "../tools/Selection.vue";
import {toLineSegments} from "../misc/lines.js";
const props = defineProps<{
meshes: Array<Mesh>,
@@ -44,6 +45,8 @@ let modelName = props.meshes[0].getExtras()[extrasNameKey] // + " blah blah blah
// Reactive properties
const enabledFeatures = defineModel<Array<number>>("enabledFeatures", {default: [0, 1, 2]});
const opacity = defineModel<number>("opacity", {default: 1});
const wireframe = ref(false);
// Clipping planes are handled in y-up space (swapped on interface, Z inverted later)
const clipPlaneX = ref(1);
const clipPlaneSwappedX = ref(false);
const clipPlaneY = ref(1);
@@ -52,9 +55,18 @@ const clipPlaneZ = ref(1);
const clipPlaneSwappedZ = ref(false);
// Count the number of faces, edges and vertices
let faceCount = props.meshes.map((m) => m.listPrimitives().filter(p => p.getMode() === WebGL2RenderingContext.TRIANGLES).length).reduce((a, b) => a + b, 0)
let edgeCount = props.meshes.map((m) => m.listPrimitives().filter(p => p.getMode() in [WebGL2RenderingContext.LINE_STRIP, WebGL2RenderingContext.LINES]).length).reduce((a, b) => a + b, 0)
let vertexCount = props.meshes.map((m) => m.listPrimitives().filter(p => p.getMode() === WebGL2RenderingContext.POINTS).length).reduce((a, b) => a + b, 0)
let faceCount = props.meshes
.flatMap((m) => m.listPrimitives().filter(p => p.getMode() === WebGL2RenderingContext.TRIANGLES))
.map(p => (p.getExtras()?.face_triangles_end as any)?.length ?? 1)
.reduce((a, b) => a + b, 0)
let edgeCount = props.meshes
.flatMap((m) => m.listPrimitives().filter(p => p.getMode() in [WebGL2RenderingContext.LINE_STRIP, WebGL2RenderingContext.LINES]))
.map(p => (p.getExtras()?.edge_points_end as any)?.length ?? 0)
.reduce((a, b) => a + b, 0)
let vertexCount = props.meshes
.flatMap((m) => m.listPrimitives().filter(p => p.getMode() === WebGL2RenderingContext.POINTS))
.map(p => (p.getAttribute("POSITION")?.getCount() ?? 0))
.reduce((a, b) => a + b, 0)
// Set initial defaults for the enabled features
if (faceCount === 0) enabledFeatures.value = enabledFeatures.value.filter((f) => f !== 0)
@@ -73,7 +85,7 @@ function onEnabledFeaturesChange(newEnabledFeatures: Array<number>) {
sceneModel.traverse((child: MObject3D) => {
if (child.userData[extrasNameKey] === modelName) {
let childIsFace = child.type == 'Mesh' || child.type == 'SkinnedMesh'
let childIsEdge = child.type == 'Line' || child.type == 'LineSegments'
let childIsEdge = child.type == 'Line' || child.type == 'LineSegments' || child.type == 'LineSegments2'
let childIsVertex = child.type == 'Points'
if (childIsFace || childIsEdge || childIsVertex) {
let visible = newEnabledFeatures.includes(childIsFace ? 0 : childIsEdge ? 1 : childIsVertex ? 2 : -1);
@@ -111,6 +123,27 @@ function onOpacityChange(newOpacity: number) {
watch(opacity, onOpacityChange);
function onWireframeChange(newWireframe: boolean) {
let scene = props.viewer?.scene;
let sceneModel = (scene as any)?._model;
if (!scene || !sceneModel) return;
// Iterate all primitives of the mesh and set their wireframe based on the enabled features
// Use the scene graph instead of the document to avoid reloading the same model, at the cost
// of not actually removing the primitives from the scene graph
// console.log('Wireframe may have changed', newWireframe)
sceneModel.traverse((child: MObject3D) => {
if (child.userData[extrasNameKey] === modelName) {
if (child.material && child.material.wireframe !== newWireframe) {
child.material.wireframe = newWireframe;
child.material.needsUpdate = true;
}
}
});
scene.queueRender()
}
watch(wireframe, onWireframeChange);
let {sceneDocument} = inject<{ sceneDocument: ShallowRef<Document> }>('sceneDocument')!!;
function onClipPlanesChange() {
@@ -125,22 +158,25 @@ function onClipPlanesChange() {
if (props.viewer?.renderer && (enabledX || enabledY || enabledZ)) {
// Global value for all models, once set it cannot be unset (unknown for other models...)
props.viewer.renderer.threeRenderer.localClippingEnabled = true;
// Due to model-viewer's camera manipulation, the bounding box needs to be transformed
let boundingBox = SceneMgr.getBoundingBox(sceneDocument.value);
if (!boundingBox) return; // No models. Should not happen.
bbox = boundingBox.translate(scene.getTarget());
// Get the bounding box containing all features of this model
bbox = new Box3();
sceneModel.traverse((child: MObject3D) => {
if (child.userData[extrasNameKey] === modelName) {
bbox.expandByObject(child);
}
});
}
sceneModel.traverse((child: MObject3D) => {
if (child.userData[extrasNameKey] === modelName) {
if (child.material) {
if (bbox) {
if (bbox?.isEmpty() == false) {
let offsetX = bbox.min.x + clipPlaneX.value * (bbox.max.x - bbox.min.x);
let offsetY = bbox.min.z + clipPlaneY.value * (bbox.max.z - bbox.min.z);
let offsetZ = bbox.min.y + clipPlaneZ.value * (bbox.max.y - bbox.min.y);
let offsetY = bbox.min.y + clipPlaneY.value * (bbox.max.y - bbox.min.y);
let offsetZ = bbox.min.z + (1 - clipPlaneZ.value) * (bbox.max.z - bbox.min.z);
let planes = [
new Plane(new Vector3(-1, 0, 0), offsetX),
new Plane(new Vector3(0, 0, 1), offsetY),
new Plane(new Vector3(0, -1, 0), offsetZ),
new Plane(new Vector3(0, -1, 0), offsetY),
new Plane(new Vector3(0, 0, 1), -offsetZ),
];
if (clipPlaneSwappedX.value) planes[0].negate();
if (clipPlaneSwappedY.value) planes[1].negate();
@@ -177,9 +213,16 @@ function onModelLoad() {
// Use the scene graph instead of the document to avoid reloading the same model, at the cost
// of not actually removing the primitives from the scene graph
let childrenToAdd: Array<MObject3D> = [];
let linesToImprove: Array<MObject3D> = [];
sceneModel.traverse((child: MObject3D) => {
if (child.userData[extrasNameKey] === modelName) {
if (child.type == 'Mesh' || child.type == 'SkinnedMesh') {
// Compute a BVH for faster raycasting (MUCH faster selection)
// @ts-ignore
child.geometry?.computeBoundsTree({indirect: true}); // indirect to avoid changing index order
// TODO: Accelerated raycast for lines and points (https://github.com/gkjohnson/three-mesh-bvh/issues/243)
// TODO: ParallelMeshBVHWorker
// We could implement cutting planes using the stencil buffer:
// https://threejs.org/examples/?q=clipping#webgl_clipping_stencil
// But this is buggy for lots of models, so instead we just draw
@@ -194,29 +237,55 @@ function onModelLoad() {
backChild.material.side = BackSide;
backChild.material.color = new Color(0.25, 0.25, 0.25)
child.userData.backChild = backChild;
backChild.userData.noHit = true;
childrenToAdd.push(backChild as MObject3D);
}
}
// if (child.type == 'Line' || child.type == 'LineSegments') {
// child.material.linewidth = 3; // Not supported in WebGL2
// If wide lines are really needed, we need https://threejs.org/examples/?q=line#webgl_lines_fat
// }
if (child.type == 'Line' || child.type == 'LineSegments') {
// child.material.linewidth = 3; // Not supported in WebGL2
// Swap geometry with LineGeometry to support widths
// https://threejs.org/examples/?q=line#webgl_lines_fat
linesToImprove.push(child);
}
if (child.type == 'Points') {
(child.material as any).size = 5;
(child.material as any).size = 7;
child.material.needsUpdate = true;
}
}
});
childrenToAdd.forEach((child: MObject3D) => sceneModel.add(child));
scene.queueRender()
linesToImprove.forEach(async (line: MObject3D) => {
let line2 = await toLineSegments(line.geometry);
// Update resolution on resize
props.viewer!!.onElemReady((elem) => {
let l = () => {
line2.material.resolution.set(elem.clientWidth, elem.clientHeight);
line2.material.needsUpdate = true;
};
elem.addEventListener('resize', l); // TODO: Remove listener when line is replaced
l();
});
line2.computeLineDistances();
line2.userData = Object.assign({}, line.userData);
line.parent!.add(line2);
line.children.forEach((o) => line2.add(o));
line.visible = false;
line.userData.niceLine = line2;
// line.parent!.remove(line); // Keep it for better raycast and selection!
line2.userData.noHit = true;
});
// Furthermore...
// Enabled features may have been reset after a reload
onEnabledFeaturesChange(enabledFeatures.value)
// Opacity may have been reset after a reload
onOpacityChange(opacity.value)
// Wireframe may have been reset after a reload
onWireframeChange(wireframe.value)
// Clip planes may have been reset after a reload
onClipPlanesChange()
scene.queueRender()
}
// props.viewer.elem may not yet be available, so we need to wait for it
@@ -253,6 +322,10 @@ props.viewer!!.onElemReady((elem) => elem.addEventListener('load', onModelLoad))
<v-tooltip activator="parent">Change opacity</v-tooltip>
<svg-icon type="mdi" :path="mdiCircleOpacity"></svg-icon>
</template>
<template v-slot:append>
<v-tooltip activator="parent">Wireframe</v-tooltip>
<v-checkbox-btn trueIcon="mdi-triangle-outline" falseIcon="mdi-triangle" v-model="wireframe"></v-checkbox-btn>
</template>
</v-slider>
<v-divider></v-divider>
<v-slider v-model="clipPlaneX" hide-details min="0" max="1">
@@ -271,7 +344,7 @@ props.viewer!!.onElemReady((elem) => elem.addEventListener('load', onModelLoad))
</v-checkbox-btn>
</template>
</v-slider>
<v-slider v-model="clipPlaneY" hide-details min="0" max="1">
<v-slider v-model="clipPlaneZ" hide-details min="0" max="1">
<template v-slot:prepend>
<v-tooltip activator="parent">Clip plane Y</v-tooltip>
<svg-icon type="mdi" :path="mdiCube" :rotate="-120"></svg-icon>
@@ -280,14 +353,14 @@ props.viewer!!.onElemReady((elem) => elem.addEventListener('load', onModelLoad))
<template v-slot:append>
<v-tooltip activator="parent">Swap clip plane Y</v-tooltip>
<v-checkbox-btn trueIcon="mdi-checkbox-marked-outline" falseIcon="mdi-checkbox-blank-outline"
v-model="clipPlaneSwappedY">
v-model="clipPlaneSwappedZ">
<template v-slot:label>
<svg-icon type="mdi" :path="mdiSwapHorizontal"></svg-icon>
</template>
</v-checkbox-btn>
</template>
</v-slider>
<v-slider v-model="clipPlaneZ" hide-details min="0" max="1">
<v-slider v-model="clipPlaneY" hide-details min="0" max="1">
<template v-slot:prepend>
<v-tooltip activator="parent">Clip plane Z</v-tooltip>
<svg-icon type="mdi" :path="mdiCube"></svg-icon>
@@ -296,7 +369,7 @@ props.viewer!!.onElemReady((elem) => elem.addEventListener('load', onModelLoad))
<template v-slot:append>
<v-tooltip activator="parent">Swap clip plane Z</v-tooltip>
<v-checkbox-btn trueIcon="mdi-checkbox-marked-outline" falseIcon="mdi-checkbox-blank-outline"
v-model="clipPlaneSwappedZ">
v-model="clipPlaneSwappedY">
<template v-slot:label>
<svg-icon type="mdi" :path="mdiSwapHorizontal"></svg-icon>
</template>
@@ -359,4 +432,12 @@ props.viewer!!.onElemReady((elem) => elem.addEventListener('load', onModelLoad))
.mdi-checkbox-marked-outline { /* HACK: mdi is not fully imported, only required icons... */
background-image: url('data:image/svg+xml;charset=UTF-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white"><path d="M19,19H5V5H15V3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V11H19M7.91,10.08L6.5,11.5L11,16L21,6L19.59,4.58L11,13.17L7.91,10.08Z"/></svg>');
}
.mdi-triangle { /* HACK: mdi is not fully imported, only required icons... */
background-image: url('data:image/svg+xml;charset=UTF-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white"><path d="M1 21h22L12 2"/></svg>');
}
.mdi-triangle-outline { /* HACK: mdi is not fully imported, only required icons... */
background-image: url('data:image/svg+xml;charset=UTF-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white"><path d="M12 2L1 21h22M12 6l7.53 13H4.47"/></svg>');
}
</style>

View File

@@ -8,7 +8,7 @@ import type {ModelViewerElement} from '@google/model-viewer';
import {Vector3} from "three/src/math/Vector3.js";
import {Matrix4} from "three/src/math/Matrix4.js";
globalThis.THREE = {Vector3, Matrix4} as any // HACK: Required for the gizmo to work
(globalThis as any).THREE = {Vector3, Matrix4} as any // HACK: Required for the gizmo to work
const OrientationGizmo = OrientationGizmoRaw.default;

View File

@@ -6,24 +6,28 @@ import type {ModelViewerElement} from '@google/model-viewer';
import type {ModelScene} from "@google/model-viewer/lib/three-components/ModelScene";
import {mdiCubeOutline, mdiCursorDefaultClick, mdiFeatureSearch, mdiRuler} from '@mdi/js';
import type {Intersection, Material, Mesh, Object3D} from "three";
import {Box3, Matrix4, Raycaster, Vector3} from "three";
import {Box3, Color, Raycaster, Vector3} from "three";
import type ModelViewerWrapperT from "../viewer/ModelViewerWrapper.vue";
import {extrasNameKey} from "../misc/gltf";
import {SceneMgr} from "../misc/scene";
import {Document} from "@gltf-transform/core";
import {AxesColors} from "../misc/helpers";
import {distances} from "../misc/distances";
import {highlight, highlightUndo, hitToSelectionInfo, type SelectionInfo} from "./selection";
export type MObject3D = Mesh & {
userData: { noHit?: boolean },
material: Material & { color: { r: number, g: number, b: number }, __prevBaseColorFactor?: [number, number, number] }
material: Material & {
color: Color,
wireframe?: boolean
}
};
let props = defineProps<{ viewer: typeof ModelViewerWrapperT | null }>();
let emit = defineEmits<{ findModel: [string] }>();
let {setDisableTap} = inject<{ setDisableTap: (arg0: boolean) => void }>('disableTap')!!;
let selectionEnabled = ref(false);
let selected = defineModel<Array<Intersection<MObject3D>>>({default: []});
let selected = defineModel<Array<SelectionInfo>>({default: []});
let highlightNextSelection = ref([false, false]); // Second is whether selection was enabled before
let showBoundingBox = ref<Boolean>(false); // Enabled automatically on start
let showDistances = ref<Boolean>(true);
@@ -92,8 +96,27 @@ let selectionListener = (event: MouseEvent) => {
// let lineHandle = props.viewer?.addLine3D(actualFrom, actualTo, "Ray")
// setTimeout(() => props.viewer?.removeLine3D(lineHandle), 30000)
// Find all hit objects and select the wanted one based on the filter
const hits = raycaster.intersectObject(scene, true);
// Find all hit objects and raycast the wanted ones based on the filter
let objects: Array<any> = [];
scene.traverse((obj) => {
const kind = obj.type
let isFace = kind === 'Mesh' || kind === 'SkinnedMesh';
let isEdge = kind === 'Line' || kind === 'LineSegments';
let isVertex = kind === 'Points';
if (obj.userData.noHit !== true &&
((selectFilter.value === 'Any (S)' && (isFace || isEdge || isVertex)) ||
(selectFilter.value === '(F)aces' && isFace) ||
(selectFilter.value === '(E)dges' && isEdge) ||
(selectFilter.value === '(V)ertices' && isVertex))) {
objects.push(obj);
}
});
//console.log("Raycasting objects", objects)
// Run the raycaster on the selected objects only searching for the first hit
// @ts-ignore
raycaster.firstHitOnly = true;
const hits = raycaster.intersectObjects(objects, false);
let hit = hits
// Check feasibility
.filter((hit: Intersection<Object3D>) => {
@@ -106,7 +129,7 @@ let selectionListener = (event: MouseEvent) => {
(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;
return (!isFace || hit.object.visible) && kindOk;
})
// Sort for highlighting partially hidden edges/vertices
.sort((a, b) => {
@@ -123,17 +146,19 @@ let selectionListener = (event: MouseEvent) => {
})
// 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
if (hit) {
let selInfo: SelectionInfo | null = null;
if (hit) selInfo = hitToSelectionInfo(hit);
//console.log('Hit', hit, 'SelInfo', selInfo);
if (hit && selInfo !== null) {
// Toggle selection
const wasSelected = selected.value.find((m) => m.object.name === hit?.object?.name) !== undefined;
const wasSelected = selected.value.find((m) => m.getKey() === selInfo.getKey()) !== undefined;
if (wasSelected) {
deselect(hit)
deselect(selInfo)
} else {
select(hit)
select(selInfo)
}
} else {
deselectAll();
@@ -149,34 +174,22 @@ let selectionListener = (event: MouseEvent) => {
scene.queueRender() // Force rerender of model-viewer
}
function select(hit: Intersection<MObject3D>) {
// console.log('Selecting', hit.object.name)
if (selected.value.find((m) => m.object.name === hit.object.name) === undefined) {
selected.value.push(hit);
function select(selInfo: SelectionInfo) {
// console.log('Selecting', selInfo.object.name)
if (selected.value.find((m) => m.getKey() === selInfo.getKey()) === undefined) {
selected.value.push(selInfo);
}
hit.object.material.__prevBaseColorFactor = [
hit.object.material.color.r,
hit.object.material.color.g,
hit.object.material.color.b,
];
hit.object.material.color.r = 1;
hit.object.material.color.g = 0;
hit.object.material.color.b = 0;
highlight(selInfo);
}
function deselect(hit: Intersection<MObject3D>, alsoRemove = true) {
// console.log('Deselecting', hit.object.name)
function deselect(selInfo: SelectionInfo, alsoRemove = true) {
// console.log('Deselecting', selInfo.object.name)
if (alsoRemove) {
// Remove the matching object from the selection
let toRemove = selected.value.findIndex((m) => m.object.name === hit.object.name);
let toRemove = selected.value.findIndex((m) => m.getKey() === selInfo.getKey());
selected.value.splice(toRemove, 1);
}
if (hit.object.material.__prevBaseColorFactor) {
hit.object.material.color.r = hit.object.material.__prevBaseColorFactor[0]
hit.object.material.color.g = hit.object.material.__prevBaseColorFactor[1]
hit.object.material.color.b = hit.object.material.__prevBaseColorFactor[2]
delete hit.object.material.__prevBaseColorFactor;
}
highlightUndo(selInfo);
}
function deselectAll(alsoRemove = true) {
@@ -273,9 +286,8 @@ function updateBoundingBox() {
if (selected.value.length > 0) {
bb = new Box3();
for (let hit of selected.value) {
bb.expandByObject(hit.object);
bb.union(hit.getBox())
}
bb.applyMatrix4(new Matrix4().makeTranslation(props.viewer?.scene.getTarget()));
} else {
let boundingBox = SceneMgr.getBoundingBox(sceneDocument.value);
if (!boundingBox) return; // No models. Should not happen.
@@ -380,9 +392,7 @@ function updateDistances() {
}
// Add lines (if not already added)
let objA = selected.value[0].object;
let objB = selected.value[1].object;
let {min, center, max} = distances(objA, objB, props.viewer?.scene);
let {min, center, max} = distances(selected.value[0], selected.value[1], props.viewer?.scene);
ensureLine(max[0], max[1], max[1].distanceTo(max[0]).toFixed(1) + "mm", "orange");
ensureLine(center[0], center[1], center[1].distanceTo(center[0]).toFixed(1) + "mm", "green");
ensureLine(min[0], min[1], min[1].distanceTo(min[0]).toFixed(1) + "mm", "cyan");

View File

@@ -16,11 +16,11 @@ import {OrthographicCamera} from "three/src/cameras/OrthographicCamera.js";
import {mdiClose, mdiCrosshairsGps, mdiDownload, mdiGithub, mdiLicense, mdiProjector} from '@mdi/js'
import SvgIcon from '@jamescoyle/vue-icon';
import type {ModelViewerElement} from '@google/model-viewer';
import type {Intersection} from "three";
import type {MObject3D} from "./Selection.vue";
import Loading from "../misc/Loading.vue";
import type ModelViewerWrapper from "../viewer/ModelViewerWrapper.vue";
import {defineAsyncComponent, type Ref, ref} from "vue";
import type {SelectionInfo} from "./selection";
const SelectionComponent = defineAsyncComponent({
loader: () => import("./Selection.vue"),
@@ -39,10 +39,10 @@ const LicensesDialogContent = defineAsyncComponent({
let props = defineProps<{ viewer: InstanceType<typeof ModelViewerWrapper> | null }>();
const emit = defineEmits<{ findModel: [string] }>()
let selection: Ref<Array<Intersection<MObject3D>>> = ref([]);
let selectionFaceCount = () => selection.value.filter((s) => s.object.type == "Mesh" || s.object.type == "SkinnedMesh").length
let selectionEdgeCount = () => selection.value.filter((s) => s.object.type == "Line").length
let selectionVertexCount = () => selection.value.filter((s) => s.object.type == "Points").length
let selection: Ref<Array<SelectionInfo>> = ref([]);
let selectionFaceCount = () => selection.value.filter((s) => s.kind == 'face').length
let selectionEdgeCount = () => selection.value.filter((s) => s.kind == 'edge').length
let selectionVertexCount = () => selection.value.filter((s) => s.kind == "vertex").length
function syncOrthoCamera(force: boolean) {
let scene = props.viewer?.scene;

151
frontend/tools/selection.ts Normal file
View File

@@ -0,0 +1,151 @@
// Model management from the graphics side
import type {MObject3D} from "./Selection.vue";
import type {Intersection} from "three";
import {Box3} from "three";
/** Information about a single item in the selection */
export class SelectionInfo {
/** The object which was (partially) selected */
object: MObject3D
/** The type of the selection */
kind: 'face' | 'edge' | 'vertex'
/** Start and end indices of the primitives in the geometry */
indices: [number, number]
constructor(object: MObject3D, kind: 'face' | 'edge' | 'vertex', indices: [number, number]) {
this.object = object;
this.kind = kind;
this.indices = indices;
}
public getKey() {
return this.object.uuid + this.kind + this.indices[0].toFixed() + this.indices[1].toFixed();
}
public getBox(): Box3 {
let index = this.object.geometry.index || {getX: (i: number) => i};
let pos = this.object.geometry.getAttribute('position');
let min = [Infinity, Infinity, Infinity];
let max = [-Infinity, -Infinity, -Infinity];
for (let i = this.indices[0]; i < this.indices[1]; i++) {
let vertIndex = index!.getX(i);
let x = pos.getX(vertIndex);
let y = pos.getY(vertIndex);
let z = pos.getZ(vertIndex);
min[0] = Math.min(min[0], x);
min[1] = Math.min(min[1], y);
min[2] = Math.min(min[2], z);
max[0] = Math.max(max[0], x);
max[1] = Math.max(max[1], y);
max[2] = Math.max(max[2], z);
}
return new Box3().setFromArray([...min, ...max]);
}
}
export function hitToSelectionInfo(hit: Intersection<MObject3D>): SelectionInfo | null {
let kind = hit.object.type;
if (kind == 'Mesh' || kind == 'SkinnedMesh') {
let indices = hitFaceTriangleIndices(hit);
if (indices === null) return null;
return new SelectionInfo(hit.object, 'face', indices);
} else if (kind == 'Line' || kind == 'LineSegments') {
// Select raw lines, not the wide meshes representing them
// This is because the indices refer to the raw lines, not the wide meshes
// Furthermore, this allows better "fuzzy" raycasting logic
let indices = hitEdgePointIndices(hit);
if (indices === null) return null;
return new SelectionInfo(hit.object, 'edge', indices);
} else if (kind == 'Points') {
if (hit.index === undefined) return null;
return new SelectionInfo(hit.object, 'vertex', [hit.index, hit.index + 1]);
}
return null;
}
function hitFaceTriangleIndices(hit: Intersection<MObject3D>): [number, number] | null {
let faceTrianglesEnd = hit?.object?.geometry?.userData?.face_triangles_end;
if (hit.faceIndex === undefined) return null;
if (!faceTrianglesEnd) { // Fallback to selecting the whole imported mesh
//console.log("No face_triangles_end found, selecting the whole mesh");
return [0, (hit.object.geometry.index ?? hit.object.geometry.attributes.position).count];
} else { // Normal CAD model
let rawIndex = hit.faceIndex * 3; // Faces are triangles with 3 indices
for (let i = 0; i < faceTrianglesEnd.length; i++) {
let faceSwapIndex = faceTrianglesEnd[i]
if (rawIndex < faceSwapIndex) {
let start = i === 0 ? 0 : faceTrianglesEnd[i - 1];
return [start, faceTrianglesEnd[i]];
}
}
}
return null;
}
function hitEdgePointIndices(hit: Intersection<MObject3D>): [number, number] | null {
let edgePointsEnd = hit?.object?.geometry?.userData?.edge_points_end;
if (!edgePointsEnd || hit.index === undefined) return null;
let rawIndex = hit.index; // Faces are triangles with 3 indices
for (let i = 0; i < edgePointsEnd.length; i++) {
let edgeSwapIndex = edgePointsEnd[i]
if (rawIndex < edgeSwapIndex) {
let start = i === 0 ? 0 : edgePointsEnd[i - 1];
return [start, edgePointsEnd[i]];
}
}
return null;
}
function applyColor(selInfo: SelectionInfo, colorAttribute: any, color: [number, number, number, number]): [number, number, number, number] {
let index = selInfo.object.geometry.index
let prevColor: [number, number, number, number] | null = null;
if (colorAttribute !== undefined) {
for (let i = selInfo.indices[0]; i < selInfo.indices[1]; i++) {
let vertIndex = index!.getX(i);
if (prevColor === null) prevColor = [colorAttribute.getX(vertIndex), colorAttribute.getY(vertIndex), colorAttribute.getZ(vertIndex), colorAttribute.getW(vertIndex)];
colorAttribute.setXYZW(vertIndex, color[0], color[1], color[2], color[3]);
}
colorAttribute.needsUpdate = true;
if (selInfo.object.userData.niceLine !== undefined) {
// Need to update the color of the nice line as well
let indexAttribute = selInfo.object.geometry.index!!;
let allNewColors = [];
for (let i = 0; i < indexAttribute.count; i++) {
if (indexAttribute.getX(i) >= selInfo.indices[0] && indexAttribute.getX(i) < selInfo.indices[1]) {
allNewColors.push(color[0], color[1], color[2]);
} else {
allNewColors.push(colorAttribute.getX(indexAttribute.getX(i)), colorAttribute.getY(indexAttribute.getX(i)), colorAttribute.getZ(indexAttribute.getX(i)));
}
}
selInfo.object.userData.niceLine.geometry.setColors(allNewColors);
for (let attribute of Object.values(selInfo.object.userData.niceLine.geometry.attributes)) {
(attribute as any).needsUpdate = true;
}
}
} else { // Fallback to tinting the whole mesh for imported models
//console.log("No color attribute found, tinting the whole mesh")
let tmpPrevColor = selInfo.object.material.color;
prevColor = [tmpPrevColor.r, tmpPrevColor.g, tmpPrevColor.b, 1];
selInfo.object.material.color.setRGB(color[0], color[1], color[2]);
selInfo.object.material.needsUpdate = true;
}
return prevColor!;
}
export function highlight(selInfo: SelectionInfo): void {
// Update the color of all the triangles in the face
let geometry = selInfo.object.geometry;
let colorAttr = selInfo.object.geometry.getAttribute('color');
geometry.userData.savedColor = geometry.userData.savedColor || {};
geometry.userData.savedColor[selInfo.getKey()] = applyColor(selInfo, colorAttr, [1.0, 0.0, 0.0, 1.0]);
}
export function highlightUndo(selInfo: SelectionInfo): void {
// Update the color of all the triangles in the face
let geometry = selInfo.object.geometry;
let colorAttr = selInfo.object.geometry.getAttribute('color');
let savedColor = geometry.userData.savedColor[selInfo.getKey()];
applyColor(selInfo, colorAttr, savedColor);
delete geometry.userData.savedColor[selInfo.getKey()];
}

View File

@@ -8,8 +8,16 @@ import type {ModelScene} from "@google/model-viewer/lib/three-components/ModelSc
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 { computeBoundsTree, disposeBoundsTree, acceleratedRaycast } from 'three-mesh-bvh';
import {BufferGeometry, Mesh} from "three";
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 emit = defineEmits<{ load: [] }>()