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

@@ -211,11 +211,12 @@ Apache License
----------- -----------
The following npm package may be included in this product: The following npm packages may be included in this product:
- source-map-js@1.0.2 - source-map-js@1.0.2
- source-map-js@1.2.0
This package contains the following license and notice below: These packages each contain the following license and notice below:
Copyright (c) 2009-2011, Mozilla Foundation and contributors Copyright (c) 2009-2011, Mozilla Foundation and contributors
All rights reserved. All rights reserved.
@@ -1290,7 +1291,7 @@ third-party archives.
The following npm package may be included in this product: The following npm package may be included in this product:
- typescript@5.4.2 - typescript@5.4.3
This package contains the following license and notice below: This package contains the following license and notice below:
@@ -1764,6 +1765,36 @@ SOFTWARE.
----------- -----------
The following npm package may be included in this product:
- three-mesh-bvh@0.7.3
This package contains the following license and notice below:
MIT License
Copyright (c) 2018 Garrett Johnson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-----------
The following npm package may be included in this product: The following npm package may be included in this product:
- napi-build-utils@1.0.2 - napi-build-utils@1.0.2
@@ -2194,13 +2225,13 @@ THE SOFTWARE.
The following npm package may be included in this product: The following npm package may be included in this product:
- three@0.160.1 - three@0.162.0
This package contains the following license and notice below: This package contains the following license and notice below:
The MIT License The MIT License
Copyright © 2010-2023 three.js authors Copyright © 2010-2024 three.js authors
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@@ -2408,7 +2439,7 @@ THE SOFTWARE.
The following npm package may be included in this product: The following npm package may be included in this product:
- vuetify@3.5.8 - vuetify@3.5.11
This package contains the following license and notice below: This package contains the following license and notice below:
@@ -2690,9 +2721,9 @@ THE SOFTWARE.
The following npm packages may be included in this product: The following npm packages may be included in this product:
- @gltf-transform/core@3.10.0 - @gltf-transform/core@3.10.1
- @gltf-transform/extensions@3.10.0 - @gltf-transform/extensions@3.10.1
- @gltf-transform/functions@3.10.0 - @gltf-transform/functions@3.10.1
These packages each contain the following license and notice below: These packages each contain the following license and notice below:
@@ -2843,7 +2874,7 @@ THE SOFTWARE.
The following npm package may be included in this product: The following npm package may be included in this product:
- postcss@8.4.35 - postcss@8.4.38
This package contains the following license and notice below: This package contains the following license and notice below:

View File

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

View File

@@ -1,16 +1,17 @@
import {BufferAttribute, InterleavedBufferAttribute, Vector3} from 'three'; import {BufferAttribute, InterleavedBufferAttribute, Vector3} from 'three';
import type {MObject3D} from "../tools/Selection.vue"; 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, center: Vector3,
vertices: Array<Vector3> vertices: Array<Vector3>
} { } {
obj.updateMatrixWorld(); selInfo.object.updateMatrixWorld();
let pos: BufferAttribute | InterleavedBufferAttribute = obj.geometry.getAttribute('position'); let pos: BufferAttribute | InterleavedBufferAttribute = selInfo.object.geometry.getAttribute('position');
let ind: BufferAttribute | null = obj.geometry.index; let ind: BufferAttribute | null = selInfo.object.geometry.index;
if (!ind) { if (ind === null) {
ind = new BufferAttribute(new Uint16Array(pos.count), 1); ind = new BufferAttribute(new Uint16Array(pos.count), 1);
for (let i = 0; i < pos.count; i++) { for (let i = 0; i < pos.count; i++) {
ind.array[i] = i; ind.array[i] = i;
@@ -18,14 +19,14 @@ function getCenterAndVertexList(obj: MObject3D, scene: ModelScene): {
} }
let center = new Vector3(); let center = new Vector3();
let vertices = []; let vertices = [];
for (let i = 0; i < ind.count; i++) { for (let i = selInfo.indices[0]; i < selInfo.indices[1]; i++) {
let index = ind.array[i]; let index = ind.getX(i)
let vertex = new Vector3(pos.getX(index), pos.getY(index), pos.getZ(index)); 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); center.add(vertex);
vertices.push(vertex); vertices.push(vertex);
} }
center = center.divideScalar(ind.count); center = center.divideScalar(selInfo.indices[1] - selInfo.indices[0]);
return {center, vertices}; 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. * 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. * 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>, min: Array<Vector3>,
center: Array<Vector3>, center: Array<Vector3>,
max: 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"; import {unpartition} from "@gltf-transform/functions";
let io = new WebIO(); 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). * 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 // Load the new document
let newDoc = await io.read(url); let newDoc = await io.readBinary(new Uint8Array(buffer));
networkFinished()
// Remove any previous model with the same name // Remove any previous model with the same name
await document.transform(dropByName(name)); await document.transform(dropByName(name));

View File

@@ -1,3 +1,5 @@
// noinspection JSVoidFunctionReturnValueUsed,JSUnresolvedReference
import {Document, type TypedArray} from '@gltf-transform/core' import {Document, type TypedArray} from '@gltf-transform/core'
import {Vector2} from "three/src/math/Vector2.js" import {Vector2} from "three/src/math/Vector2.js"
import {Vector3} from "three/src/math/Vector3.js" import {Vector3} from "three/src/math/Vector3.js"
@@ -26,7 +28,7 @@ function buildSimpleGltf(doc: Document, rawPositions: number[], rawIndices: numb
if (rawColors) { if (rawColors) {
colors = doc.createAccessor(name + 'Color') colors = doc.createAccessor(name + 'Color')
.setArray(new Float32Array(rawColors) as TypedArray) .setArray(new Float32Array(rawColors) as TypedArray)
.setType('VEC3') .setType('VEC4')
.setBuffer(buffer); .setBuffer(buffer);
} }
const material = doc.createMaterial(name + 'Material') const material = doc.createMaterial(name + 'Material')
@@ -39,6 +41,11 @@ function buildSimpleGltf(doc: Document, rawPositions: number[], rawIndices: numb
if (rawColors) { if (rawColors) {
geometry.setAttribute('COLOR_0', colors) 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 mesh = doc.createMesh(name + 'Mesh').addPrimitive(geometry)
const node = doc.createNode(name + 'Node').setMesh(mesh).setMatrix(transform.elements as any) const node = doc.createNode(name + 'Node').setMesh(mesh).setMatrix(transform.elements as any)
scene.addChild(node) 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. * Create a new Axes helper as a GLTF model, useful for debugging positions and orientations.
*/ */
export function newAxes(doc: Document, size: Vector3, transform: Matrix4) { export function newAxes(doc: Document, size: Vector3, transform: Matrix4) {
let rawIndices = [0, 1, 2, 3, 4, 5];
let rawPositions = [ let rawPositions = [
[0, 0, 0, size.x, 0, 0], 0, 0, 0, size.x, 0, 0,
[0, 0, 0, 0, size.y, 0], 0, 0, 0, 0, size.y, 0,
[0, 0, 0, 0, 0, -size.z], 0, 0, 0, 0, 0, -size.z,
]; ];
let rawIndices = [0, 1];
let rawColors = [ let rawColors = [
[...(AxesColors.x[0]), ...(AxesColors.x[1])], ...(AxesColors.x[0]), 255, ...(AxesColors.x[1]), 255,
[...(AxesColors.y[0]), ...(AxesColors.y[1])], ...(AxesColors.y[0]), 255, ...(AxesColors.y[1]), 255,
[...(AxesColors.z[0]), ...(AxesColors.z[1])], ...(AxesColors.z[0]), 255, ...(AxesColors.z[1]), 255
].map(g => g.map(x => x / 255.0)); ].map(x => x / 255.0);
buildSimpleGltf(doc, rawPositions[0], rawIndices, rawColors[0], transform, '__helper_axes'); buildSimpleGltf(doc, rawPositions, rawIndices, rawColors, new Matrix4(), '__helper_axes'); // Axes at (0,0,0)!
buildSimpleGltf(doc, rawPositions[1], rawIndices, rawColors[1], transform, '__helper_axes'); buildSimpleGltf(doc, [0, 0, 0], [0], [1, 1, 1, 1], transform, '__helper_axes', WebGL2RenderingContext.POINTS);
buildSimpleGltf(doc, rawPositions[2], rawIndices, rawColors[2], transform, '__helper_axes');
buildSimpleGltf(doc, [0, 0, 0], [0], null, 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. * 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. * 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 // 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 axis of [new Vector3(1, 0, 0), new Vector3(0, 1, 0), new Vector3(0, 0, -1)]) {
for (let positive of [1, -1]) { for (let positive of [1, -1]) {
let offset = axis.clone().multiply(size.clone().multiplyScalar(0.5 * positive)); 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.x) size2.set(size.z, size.y);
if (axis.y) size2.set(size.x, size.z); if (axis.y) size2.set(size.x, size.z);
if (axis.z) size2.set(size.x, size.y); if (axis.z) size2.set(size.x, size.y);
let transform = baseTransform.clone().multiply(translation).multiply(rotation); let transform = new Matrix4().multiply(translation).multiply(rotation);
newGridPlane(doc, size2, transform, divisions); 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 rawPositions = [];
const rawIndices = []; const rawIndices = [];
// Build the grid as triangles // 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 + 1, baseIndex2 + 2);
rawIndices.push(baseIndex2, baseIndex2 + 2, baseIndex2 + 3); 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... */ /** This class helps manage SceneManagerData. All methods are static to support reactivity... */
export class SceneMgr { export class SceneMgr {
/** Loads a GLB model from a URL and adds it to the viewer or replaces it if the names match */ /** 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 loadStart = performance.now();
let loadNetworkEnd: number;
// Start merging into the current document, replacing or adding as needed // 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) { if (updateHelpers) {
// Reload the helpers to fit the new model // 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 // Websocket URLs automatically listen for new models from the python backend
"dev+http://127.0.0.1:32323/" "dev+http://127.0.0.1:32323/"
], ],
loadHelpers: true,
displayLoadingEveryMs: 1000, /* How often to display partially loaded models */ displayLoadingEveryMs: 1000, /* How often to display partially loaded models */
monitorEveryMs: 100, monitorEveryMs: 100,
monitorOpenTimeoutMs: 1000, monitorOpenTimeoutMs: 1000,

View File

@@ -25,13 +25,14 @@ import {
mdiVectorRectangle mdiVectorRectangle
} from '@mdi/js' } from '@mdi/js'
import SvgIcon from '@jamescoyle/vue-icon'; import SvgIcon from '@jamescoyle/vue-icon';
import {SceneMgr} from "../misc/scene";
import {BackSide, FrontSide} from "three/src/constants.js"; import {BackSide, FrontSide} from "three/src/constants.js";
import {Box3} from "three/src/math/Box3.js"; import {Box3} from "three/src/math/Box3.js";
import {Color} from "three/src/math/Color.js"; import {Color} from "three/src/math/Color.js";
import {Plane} from "three/src/math/Plane.js"; import {Plane} from "three/src/math/Plane.js";
import {Vector3} from "three/src/math/Vector3.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 type {MObject3D} from "../tools/Selection.vue";
import {toLineSegments} from "../misc/lines.js";
const props = defineProps<{ const props = defineProps<{
meshes: Array<Mesh>, meshes: Array<Mesh>,
@@ -44,6 +45,8 @@ let modelName = props.meshes[0].getExtras()[extrasNameKey] // + " blah blah blah
// Reactive properties // Reactive properties
const enabledFeatures = defineModel<Array<number>>("enabledFeatures", {default: [0, 1, 2]}); const enabledFeatures = defineModel<Array<number>>("enabledFeatures", {default: [0, 1, 2]});
const opacity = defineModel<number>("opacity", {default: 1}); 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 clipPlaneX = ref(1);
const clipPlaneSwappedX = ref(false); const clipPlaneSwappedX = ref(false);
const clipPlaneY = ref(1); const clipPlaneY = ref(1);
@@ -52,9 +55,18 @@ const clipPlaneZ = ref(1);
const clipPlaneSwappedZ = ref(false); const clipPlaneSwappedZ = ref(false);
// Count the number of faces, edges and vertices // 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 faceCount = props.meshes
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) .flatMap((m) => m.listPrimitives().filter(p => p.getMode() === WebGL2RenderingContext.TRIANGLES))
let vertexCount = props.meshes.map((m) => m.listPrimitives().filter(p => p.getMode() === WebGL2RenderingContext.POINTS).length).reduce((a, b) => a + b, 0) .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 // Set initial defaults for the enabled features
if (faceCount === 0) enabledFeatures.value = enabledFeatures.value.filter((f) => f !== 0) if (faceCount === 0) enabledFeatures.value = enabledFeatures.value.filter((f) => f !== 0)
@@ -73,7 +85,7 @@ function onEnabledFeaturesChange(newEnabledFeatures: Array<number>) {
sceneModel.traverse((child: MObject3D) => { sceneModel.traverse((child: MObject3D) => {
if (child.userData[extrasNameKey] === modelName) { if (child.userData[extrasNameKey] === modelName) {
let childIsFace = child.type == 'Mesh' || child.type == 'SkinnedMesh' 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' let childIsVertex = child.type == 'Points'
if (childIsFace || childIsEdge || childIsVertex) { if (childIsFace || childIsEdge || childIsVertex) {
let visible = newEnabledFeatures.includes(childIsFace ? 0 : childIsEdge ? 1 : childIsVertex ? 2 : -1); let visible = newEnabledFeatures.includes(childIsFace ? 0 : childIsEdge ? 1 : childIsVertex ? 2 : -1);
@@ -111,6 +123,27 @@ function onOpacityChange(newOpacity: number) {
watch(opacity, onOpacityChange); 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')!!; let {sceneDocument} = inject<{ sceneDocument: ShallowRef<Document> }>('sceneDocument')!!;
function onClipPlanesChange() { function onClipPlanesChange() {
@@ -125,22 +158,25 @@ function onClipPlanesChange() {
if (props.viewer?.renderer && (enabledX || enabledY || enabledZ)) { if (props.viewer?.renderer && (enabledX || enabledY || enabledZ)) {
// Global value for all models, once set it cannot be unset (unknown for other models...) // Global value for all models, once set it cannot be unset (unknown for other models...)
props.viewer.renderer.threeRenderer.localClippingEnabled = true; props.viewer.renderer.threeRenderer.localClippingEnabled = true;
// Due to model-viewer's camera manipulation, the bounding box needs to be transformed // Get the bounding box containing all features of this model
let boundingBox = SceneMgr.getBoundingBox(sceneDocument.value); bbox = new Box3();
if (!boundingBox) return; // No models. Should not happen. sceneModel.traverse((child: MObject3D) => {
bbox = boundingBox.translate(scene.getTarget()); if (child.userData[extrasNameKey] === modelName) {
bbox.expandByObject(child);
}
});
} }
sceneModel.traverse((child: MObject3D) => { sceneModel.traverse((child: MObject3D) => {
if (child.userData[extrasNameKey] === modelName) { if (child.userData[extrasNameKey] === modelName) {
if (child.material) { if (child.material) {
if (bbox) { if (bbox?.isEmpty() == false) {
let offsetX = bbox.min.x + clipPlaneX.value * (bbox.max.x - bbox.min.x); 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 offsetY = bbox.min.y + clipPlaneY.value * (bbox.max.y - bbox.min.y);
let offsetZ = bbox.min.y + clipPlaneZ.value * (bbox.max.y - bbox.min.y); let offsetZ = bbox.min.z + (1 - clipPlaneZ.value) * (bbox.max.z - bbox.min.z);
let planes = [ let planes = [
new Plane(new Vector3(-1, 0, 0), offsetX), new Plane(new Vector3(-1, 0, 0), offsetX),
new Plane(new Vector3(0, 0, 1), offsetY), new Plane(new Vector3(0, -1, 0), offsetY),
new Plane(new Vector3(0, -1, 0), offsetZ), new Plane(new Vector3(0, 0, 1), -offsetZ),
]; ];
if (clipPlaneSwappedX.value) planes[0].negate(); if (clipPlaneSwappedX.value) planes[0].negate();
if (clipPlaneSwappedY.value) planes[1].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 // 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 // of not actually removing the primitives from the scene graph
let childrenToAdd: Array<MObject3D> = []; let childrenToAdd: Array<MObject3D> = [];
let linesToImprove: Array<MObject3D> = [];
sceneModel.traverse((child: MObject3D) => { sceneModel.traverse((child: MObject3D) => {
if (child.userData[extrasNameKey] === modelName) { if (child.userData[extrasNameKey] === modelName) {
if (child.type == 'Mesh' || child.type == 'SkinnedMesh') { 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: // We could implement cutting planes using the stencil buffer:
// https://threejs.org/examples/?q=clipping#webgl_clipping_stencil // https://threejs.org/examples/?q=clipping#webgl_clipping_stencil
// But this is buggy for lots of models, so instead we just draw // 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.side = BackSide;
backChild.material.color = new Color(0.25, 0.25, 0.25) backChild.material.color = new Color(0.25, 0.25, 0.25)
child.userData.backChild = backChild; child.userData.backChild = backChild;
backChild.userData.noHit = true;
childrenToAdd.push(backChild as MObject3D); childrenToAdd.push(backChild as MObject3D);
} }
} }
// if (child.type == 'Line' || child.type == 'LineSegments') { if (child.type == 'Line' || child.type == 'LineSegments') {
// child.material.linewidth = 3; // Not supported in WebGL2 // 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 // Swap geometry with LineGeometry to support widths
// } // https://threejs.org/examples/?q=line#webgl_lines_fat
linesToImprove.push(child);
}
if (child.type == 'Points') { if (child.type == 'Points') {
(child.material as any).size = 5; (child.material as any).size = 7;
child.material.needsUpdate = true; child.material.needsUpdate = true;
} }
} }
}); });
childrenToAdd.forEach((child: MObject3D) => sceneModel.add(child)); 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... // Furthermore...
// Enabled features may have been reset after a reload // Enabled features may have been reset after a reload
onEnabledFeaturesChange(enabledFeatures.value) onEnabledFeaturesChange(enabledFeatures.value)
// Opacity may have been reset after a reload // Opacity may have been reset after a reload
onOpacityChange(opacity.value) onOpacityChange(opacity.value)
// Wireframe may have been reset after a reload
onWireframeChange(wireframe.value)
// Clip planes may have been reset after a reload // Clip planes may have been reset after a reload
onClipPlanesChange() onClipPlanesChange()
scene.queueRender()
} }
// props.viewer.elem may not yet be available, so we need to wait for it // 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> <v-tooltip activator="parent">Change opacity</v-tooltip>
<svg-icon type="mdi" :path="mdiCircleOpacity"></svg-icon> <svg-icon type="mdi" :path="mdiCircleOpacity"></svg-icon>
</template> </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-slider>
<v-divider></v-divider> <v-divider></v-divider>
<v-slider v-model="clipPlaneX" hide-details min="0" max="1"> <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> </v-checkbox-btn>
</template> </template>
</v-slider> </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> <template v-slot:prepend>
<v-tooltip activator="parent">Clip plane Y</v-tooltip> <v-tooltip activator="parent">Clip plane Y</v-tooltip>
<svg-icon type="mdi" :path="mdiCube" :rotate="-120"></svg-icon> <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> <template v-slot:append>
<v-tooltip activator="parent">Swap clip plane Y</v-tooltip> <v-tooltip activator="parent">Swap clip plane Y</v-tooltip>
<v-checkbox-btn trueIcon="mdi-checkbox-marked-outline" falseIcon="mdi-checkbox-blank-outline" <v-checkbox-btn trueIcon="mdi-checkbox-marked-outline" falseIcon="mdi-checkbox-blank-outline"
v-model="clipPlaneSwappedY"> v-model="clipPlaneSwappedZ">
<template v-slot:label> <template v-slot:label>
<svg-icon type="mdi" :path="mdiSwapHorizontal"></svg-icon> <svg-icon type="mdi" :path="mdiSwapHorizontal"></svg-icon>
</template> </template>
</v-checkbox-btn> </v-checkbox-btn>
</template> </template>
</v-slider> </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> <template v-slot:prepend>
<v-tooltip activator="parent">Clip plane Z</v-tooltip> <v-tooltip activator="parent">Clip plane Z</v-tooltip>
<svg-icon type="mdi" :path="mdiCube"></svg-icon> <svg-icon type="mdi" :path="mdiCube"></svg-icon>
@@ -296,7 +369,7 @@ props.viewer!!.onElemReady((elem) => elem.addEventListener('load', onModelLoad))
<template v-slot:append> <template v-slot:append>
<v-tooltip activator="parent">Swap clip plane Z</v-tooltip> <v-tooltip activator="parent">Swap clip plane Z</v-tooltip>
<v-checkbox-btn trueIcon="mdi-checkbox-marked-outline" falseIcon="mdi-checkbox-blank-outline" <v-checkbox-btn trueIcon="mdi-checkbox-marked-outline" falseIcon="mdi-checkbox-blank-outline"
v-model="clipPlaneSwappedZ"> v-model="clipPlaneSwappedY">
<template v-slot:label> <template v-slot:label>
<svg-icon type="mdi" :path="mdiSwapHorizontal"></svg-icon> <svg-icon type="mdi" :path="mdiSwapHorizontal"></svg-icon>
</template> </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... */ .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>'); 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> </style>

View File

@@ -8,7 +8,7 @@ import type {ModelViewerElement} from '@google/model-viewer';
import {Vector3} from "three/src/math/Vector3.js"; import {Vector3} from "three/src/math/Vector3.js";
import {Matrix4} from "three/src/math/Matrix4.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; 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 type {ModelScene} from "@google/model-viewer/lib/three-components/ModelScene";
import {mdiCubeOutline, mdiCursorDefaultClick, mdiFeatureSearch, mdiRuler} from '@mdi/js'; import {mdiCubeOutline, mdiCursorDefaultClick, mdiFeatureSearch, mdiRuler} from '@mdi/js';
import type {Intersection, Material, Mesh, Object3D} from "three"; 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 type ModelViewerWrapperT from "../viewer/ModelViewerWrapper.vue";
import {extrasNameKey} from "../misc/gltf"; import {extrasNameKey} from "../misc/gltf";
import {SceneMgr} from "../misc/scene"; import {SceneMgr} from "../misc/scene";
import {Document} from "@gltf-transform/core"; import {Document} from "@gltf-transform/core";
import {AxesColors} from "../misc/helpers"; import {AxesColors} from "../misc/helpers";
import {distances} from "../misc/distances"; import {distances} from "../misc/distances";
import {highlight, highlightUndo, hitToSelectionInfo, type SelectionInfo} from "./selection";
export type MObject3D = Mesh & { export type MObject3D = Mesh & {
userData: { noHit?: boolean }, 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 props = defineProps<{ viewer: typeof ModelViewerWrapperT | null }>();
let emit = defineEmits<{ findModel: [string] }>(); let emit = defineEmits<{ findModel: [string] }>();
let {setDisableTap} = inject<{ setDisableTap: (arg0: boolean) => void }>('disableTap')!!; let {setDisableTap} = inject<{ setDisableTap: (arg0: boolean) => void }>('disableTap')!!;
let selectionEnabled = ref(false); 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 highlightNextSelection = ref([false, false]); // Second is whether selection was enabled before
let showBoundingBox = ref<Boolean>(false); // Enabled automatically on start let showBoundingBox = ref<Boolean>(false); // Enabled automatically on start
let showDistances = ref<Boolean>(true); let showDistances = ref<Boolean>(true);
@@ -92,8 +96,27 @@ let selectionListener = (event: MouseEvent) => {
// let lineHandle = props.viewer?.addLine3D(actualFrom, actualTo, "Ray") // let lineHandle = props.viewer?.addLine3D(actualFrom, actualTo, "Ray")
// setTimeout(() => props.viewer?.removeLine3D(lineHandle), 30000) // setTimeout(() => props.viewer?.removeLine3D(lineHandle), 30000)
// Find all hit objects and select the wanted one based on the filter // Find all hit objects and raycast the wanted ones based on the filter
const hits = raycaster.intersectObject(scene, true); 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 let hit = hits
// Check feasibility // Check feasibility
.filter((hit: Intersection<Object3D>) => { .filter((hit: Intersection<Object3D>) => {
@@ -106,7 +129,7 @@ let selectionListener = (event: MouseEvent) => {
(isFace && selectFilter.value === '(F)aces') || (isFace && selectFilter.value === '(F)aces') ||
(isEdge && selectFilter.value === '(E)dges') || (isEdge && selectFilter.value === '(E)dges') ||
(isVertex && selectFilter.value === '(V)ertices'); (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 for highlighting partially hidden edges/vertices
.sort((a, b) => { .sort((a, b) => {
@@ -123,17 +146,19 @@ let selectionListener = (event: MouseEvent) => {
}) })
// Return the best hit // Return the best hit
[0] as Intersection<MObject3D> | undefined; [0] as Intersection<MObject3D> | undefined;
// console.log('Hit', hit)
if (!highlightNextSelection.value[0]) { if (!highlightNextSelection.value[0]) {
// If we are selecting, toggle the selection or deselect all if no hit // 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 // 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) { if (wasSelected) {
deselect(hit) deselect(selInfo)
} else { } else {
select(hit) select(selInfo)
} }
} else { } else {
deselectAll(); deselectAll();
@@ -149,34 +174,22 @@ let selectionListener = (event: MouseEvent) => {
scene.queueRender() // Force rerender of model-viewer scene.queueRender() // Force rerender of model-viewer
} }
function select(hit: Intersection<MObject3D>) { function select(selInfo: SelectionInfo) {
// console.log('Selecting', hit.object.name) // console.log('Selecting', selInfo.object.name)
if (selected.value.find((m) => m.object.name === hit.object.name) === undefined) { if (selected.value.find((m) => m.getKey() === selInfo.getKey()) === undefined) {
selected.value.push(hit); selected.value.push(selInfo);
} }
hit.object.material.__prevBaseColorFactor = [ highlight(selInfo);
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;
} }
function deselect(hit: Intersection<MObject3D>, alsoRemove = true) { function deselect(selInfo: SelectionInfo, alsoRemove = true) {
// console.log('Deselecting', hit.object.name) // console.log('Deselecting', selInfo.object.name)
if (alsoRemove) { if (alsoRemove) {
// Remove the matching object from the selection // 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); selected.value.splice(toRemove, 1);
} }
if (hit.object.material.__prevBaseColorFactor) { highlightUndo(selInfo);
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;
}
} }
function deselectAll(alsoRemove = true) { function deselectAll(alsoRemove = true) {
@@ -273,9 +286,8 @@ function updateBoundingBox() {
if (selected.value.length > 0) { if (selected.value.length > 0) {
bb = new Box3(); bb = new Box3();
for (let hit of selected.value) { for (let hit of selected.value) {
bb.expandByObject(hit.object); bb.union(hit.getBox())
} }
bb.applyMatrix4(new Matrix4().makeTranslation(props.viewer?.scene.getTarget()));
} else { } else {
let boundingBox = SceneMgr.getBoundingBox(sceneDocument.value); let boundingBox = SceneMgr.getBoundingBox(sceneDocument.value);
if (!boundingBox) return; // No models. Should not happen. if (!boundingBox) return; // No models. Should not happen.
@@ -380,9 +392,7 @@ function updateDistances() {
} }
// Add lines (if not already added) // Add lines (if not already added)
let objA = selected.value[0].object; let {min, center, max} = distances(selected.value[0], selected.value[1], props.viewer?.scene);
let objB = selected.value[1].object;
let {min, center, max} = distances(objA, objB, props.viewer?.scene);
ensureLine(max[0], max[1], max[1].distanceTo(max[0]).toFixed(1) + "mm", "orange"); 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(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"); 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 {mdiClose, mdiCrosshairsGps, mdiDownload, mdiGithub, mdiLicense, mdiProjector} from '@mdi/js'
import SvgIcon from '@jamescoyle/vue-icon'; import SvgIcon from '@jamescoyle/vue-icon';
import type {ModelViewerElement} from '@google/model-viewer'; import type {ModelViewerElement} from '@google/model-viewer';
import type {Intersection} from "three";
import type {MObject3D} from "./Selection.vue"; import type {MObject3D} from "./Selection.vue";
import Loading from "../misc/Loading.vue"; import Loading from "../misc/Loading.vue";
import type ModelViewerWrapper from "../viewer/ModelViewerWrapper.vue"; import type ModelViewerWrapper from "../viewer/ModelViewerWrapper.vue";
import {defineAsyncComponent, type Ref, ref} from "vue"; import {defineAsyncComponent, type Ref, ref} from "vue";
import type {SelectionInfo} from "./selection";
const SelectionComponent = defineAsyncComponent({ const SelectionComponent = defineAsyncComponent({
loader: () => import("./Selection.vue"), loader: () => import("./Selection.vue"),
@@ -39,10 +39,10 @@ const LicensesDialogContent = defineAsyncComponent({
let props = defineProps<{ viewer: InstanceType<typeof ModelViewerWrapper> | null }>(); let props = defineProps<{ viewer: InstanceType<typeof ModelViewerWrapper> | null }>();
const emit = defineEmits<{ findModel: [string] }>() const emit = defineEmits<{ findModel: [string] }>()
let selection: Ref<Array<Intersection<MObject3D>>> = ref([]); let selection: Ref<Array<SelectionInfo>> = ref([]);
let selectionFaceCount = () => selection.value.filter((s) => s.object.type == "Mesh" || s.object.type == "SkinnedMesh").length let selectionFaceCount = () => selection.value.filter((s) => s.kind == 'face').length
let selectionEdgeCount = () => selection.value.filter((s) => s.object.type == "Line").length let selectionEdgeCount = () => selection.value.filter((s) => s.kind == 'edge').length
let selectionVertexCount = () => selection.value.filter((s) => s.object.type == "Points").length let selectionVertexCount = () => selection.value.filter((s) => s.kind == "vertex").length
function syncOrthoCamera(force: boolean) { function syncOrthoCamera(force: boolean) {
let scene = props.viewer?.scene; 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 {Hotspot} from "@google/model-viewer/lib/three-components/Hotspot";
import type {Renderer} from "@google/model-viewer/lib/three-components/Renderer"; import type {Renderer} from "@google/model-viewer/lib/three-components/Renderer";
import type {Vector3} from "three"; 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 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: [] }>() const emit = defineEmits<{ load: [] }>()

View File

@@ -21,15 +21,16 @@
"@jamescoyle/vue-icon": "^0.1.2", "@jamescoyle/vue-icon": "^0.1.2",
"@mdi/js": "^7.4.47", "@mdi/js": "^7.4.47",
"@mdi/svg": "^7.4.47", "@mdi/svg": "^7.4.47",
"three": "^0.160.1", "three": "^0.162.0",
"three-mesh-bvh": "^0.7.3",
"three-orientation-gizmo": "https://github.com/jrj2211/three-orientation-gizmo", "three-orientation-gizmo": "https://github.com/jrj2211/three-orientation-gizmo",
"vue": "^3.4.21", "vue": "^3.4.21",
"vuetify": "^3.5.11" "vuetify": "^3.5.11"
}, },
"devDependencies": { "devDependencies": {
"@tsconfig/node20": "^20.1.2", "@tsconfig/node20": "^20.1.3",
"@types/node": "^20.11.30", "@types/node": "^20.11.30",
"@types/three": "^0.160.0", "@types/three": "^0.162.0",
"@vitejs/plugin-vue": "^5.0.3", "@vitejs/plugin-vue": "^5.0.3",
"@vitejs/plugin-vue-jsx": "^3.1.0", "@vitejs/plugin-vue-jsx": "^3.1.0",
"@vue/tsconfig": "^0.5.1", "@vue/tsconfig": "^0.5.1",
@@ -39,7 +40,7 @@
"npm-run-all2": "^6.1.1", "npm-run-all2": "^6.1.1",
"terser": "^5.29.2", "terser": "^5.29.2",
"typescript": "~5.4.3", "typescript": "~5.4.3",
"vite": "^5.2.3", "vite": "^5.2.6",
"vue-tsc": "^2.0.7" "vue-tsc": "^2.0.7"
} }
} }

View File

@@ -28,6 +28,7 @@ export default defineConfig({
build: { build: {
assetsDir: '.', assetsDir: '.',
cssCodeSplit: false, // Small enough to inline cssCodeSplit: false, // Small enough to inline
chunkSizeWarningLimit: 550, // Three.js is huge
}, },
define: { define: {
__APP_NAME__: JSON.stringify(name), __APP_NAME__: JSON.stringify(name),

View File

@@ -15,7 +15,7 @@ if 'YACV_DISABLE_SERVER' not in os.environ:
# Expose some nice aliases using the default server instance # Expose some nice aliases using the default server instance
show = yacv.show show = yacv.show
show_all = yacv.show_cad_all show_all = yacv.show_cad_all
prepare_image = image_to_gltf image_to_gltf = image_to_gltf
export_all = yacv.export_all export_all = yacv.export_all
remove = yacv.remove remove = yacv.remove
clear = yacv.clear clear = yacv.clear

View File

@@ -60,7 +60,8 @@ def grab_all_cad() -> List[Tuple[str, CADCoreLike]]:
def image_to_gltf(source: str | bytes, center: any, width: Optional[float] = None, height: Optional[float] = None, def image_to_gltf(source: str | bytes, center: any, width: Optional[float] = None, height: Optional[float] = None,
name: Optional[str] = None, save_mime: str = 'image/jpeg') -> Tuple[bytes, str]: name: Optional[str] = None, save_mime: str = 'image/jpeg', power_of_two: bool = True) \
-> Tuple[bytes, str]:
"""Convert an image to a GLTF CAD object.""" """Convert an image to a GLTF CAD object."""
from PIL import Image from PIL import Image
import io import io
@@ -101,17 +102,27 @@ def image_to_gltf(source: str | bytes, center: any, width: Optional[float] = Non
# Load the image to a byte buffer # Load the image to a byte buffer
img = Image.open(source) img = Image.open(source)
img_buf = io.BytesIO() img_buf = io.BytesIO()
img.save(img_buf, format=format)
img_buf = img_buf.getvalue()
# Build the gltf # Use the original dimensions for scaling the model
mgr = GLTFMgr(image=(img_buf, save_mime))
if width is None and height is None: if width is None and height is None:
raise ValueError('At least one of width or height must be specified') # Fallback to pixels == mm? raise ValueError('At least one of width or height must be specified') # Fallback to pixels == mm?
elif width is None: elif width is None:
width = img.width / img.height * height width = img.width / img.height * height
elif height is None: elif height is None:
height = height or img.height / img.width * width # Apply default aspect ratio if unspecified height = height or img.height / img.width * width # Apply default aspect ratio if unspecified
# Resize the image to a power of two if requested (recommended for GLTF)
if power_of_two:
new_width = 2 ** (img.width - 1).bit_length()
new_height = 2 ** (img.height - 1).bit_length()
img = img.resize((new_width, new_height))
# Save the image to a buffer
img.save(img_buf, format=format)
img_buf = img_buf.getvalue()
# Build the gltf
mgr = GLTFMgr(image=(img_buf, save_mime))
mgr.add_face([ mgr.add_face([
vert(plane.origin - plane.x_dir * width / 2 - plane.y_dir * height / 2), vert(plane.origin - plane.x_dir * width / 2 - plane.y_dir * height / 2),
vert(plane.origin + plane.x_dir * width / 2 - plane.y_dir * height / 2), vert(plane.origin + plane.x_dir * width / 2 - plane.y_dir * height / 2),
@@ -125,7 +136,7 @@ def image_to_gltf(source: str | bytes, center: any, width: Optional[float] = Non
(1, 0), (1, 0),
(1, 1), (1, 1),
(0, 1), (0, 1),
]) ], (1, 1, 1, 1))
# Return the GLTF binary blob and the suggested name of the image # Return the GLTF binary blob and the suggested name of the image
return b''.join(mgr.gltf.save_to_bytes()), name return b''.join(mgr.build().save_to_bytes()), name

View File

@@ -12,45 +12,95 @@ _checkerboard_image_bytes = base64.decodebytes(
class GLTFMgr: class GLTFMgr:
"""A utility class to build our GLTF2 objects easily and incrementally""" """A utility class to build our GLTF2 objects easily and incrementally"""
def __init__(self, image: Tuple[bytes, str] = (_checkerboard_image_bytes, 'image/png')): gltf: GLTF2
# Intermediate data to be filled by the add_* methods and merged into the GLTF object
# - Face data
face_indices: List[int] # 3 indices per triangle
face_positions: List[float] # x, y, z
face_tex_coords: List[float] # u, v
face_colors: List[float] # r, g, b, a
image: Optional[Tuple[bytes, str]] # image/png
# - Edge data
edge_indices: List[int] # 2 indices per edge
edge_positions: List[float] # x, y, z
edge_colors: List[float] # r, g, b, a
# - Vertex data
vertex_indices: List[int] # 1 index per vertex
vertex_positions: List[float] # x, y, z
vertex_colors: List[float] # r, g, b, a
def __init__(self, image: Optional[Tuple[bytes, str]] = (_checkerboard_image_bytes, 'image/png')):
self.gltf = GLTF2( self.gltf = GLTF2(
asset=Asset(generator=f"yacv_server@{importlib.metadata.version('yacv_server')}"), asset=Asset(generator=f"yacv_server@{importlib.metadata.version('yacv_server')}"),
scene=0, scene=0,
scenes=[Scene(nodes=[0])], scenes=[Scene(nodes=[0])],
nodes=[Node(mesh=0)], nodes=[Node(mesh=0)], # TODO: Server-side detection of shallow copies --> nodes
meshes=[Mesh(primitives=[])], meshes=[Mesh(primitives=[
accessors=[], Primitive(indices=-1, attributes=Attributes(), mode=TRIANGLES, material=0,
bufferViews=[BufferView(buffer=0, byteLength=len(image[0]), byteOffset=0)], extras={"face_triangles_end": []}),
buffers=[Buffer(byteLength=len(image[0]))], Primitive(indices=-1, attributes=Attributes(), mode=LINES, material=0,
samplers=[Sampler(magFilter=NEAREST)], extras={"edge_points_end": []}),
textures=[Texture(source=0, sampler=0)], Primitive(indices=-1, attributes=Attributes(), mode=POINTS, material=0),
images=[Image(bufferView=0, mimeType=image[1])], ])],
materials=[Material(pbrMetallicRoughness=PbrMetallicRoughness(metallicFactor=0.1, roughnessFactor=1.0),
alphaCutoff=None)],
) )
# TODO: Reduce the number of draw calls by merging all faces into a single primitive, and using self.face_indices = []
# color attributes + extension? to differentiate them (same for edges and vertices) self.face_positions = []
self.gltf.set_binary_blob(image[0]) self.face_tex_coords = []
self.face_colors = []
self.image = image
self.edge_indices = []
self.edge_positions = []
self.edge_colors = []
self.vertex_indices = []
self.vertex_positions = []
self.vertex_colors = []
@property
def _faces_primitive(self) -> Primitive:
return [p for p in self.gltf.meshes[0].primitives if p.mode == TRIANGLES][0]
@property
def _edges_primitive(self) -> Primitive:
return [p for p in self.gltf.meshes[0].primitives if p.mode == LINES][0]
@property
def _vertices_primitive(self) -> Primitive:
return [p for p in self.gltf.meshes[0].primitives if p.mode == POINTS][0]
def add_face(self, vertices_raw: List[Tuple[float, float, float]], indices_raw: List[Tuple[int, int, int]], def add_face(self, vertices_raw: List[Tuple[float, float, float]], indices_raw: List[Tuple[int, int, int]],
tex_coord_raw: List[Tuple[float, float]]): tex_coord_raw: List[Tuple[float, float]],
"""Add a face to the GLTF as a new primitive of the unique mesh""" color: Tuple[float, float, float, float] = (1.0, 0.75, 0.0, 1.0)):
vertices = np.array([[v[0], v[1], v[2]] for v in vertices_raw], dtype=np.float32) """Add a face to the GLTF mesh"""
indices = np.array([[i[0], i[1], i[2]] for i in indices_raw], dtype=np.uint32) # assert len(vertices_raw) == len(tex_coord_raw), f"Vertices and texture coordinates have different lengths"
tex_coord = np.array([[t[0], t[1]] for t in tex_coord_raw], dtype=np.float32) # assert min([i for t in indices_raw for i in t]) == 0, f"Face indices start at {min(indices_raw)}"
self._add_any(vertices, indices, tex_coord, mode=TRIANGLES, material="face") # assert max([e for t in indices_raw for e in t]) < len(vertices_raw), f"Indices have non-existing vertices"
base_index = len(self.face_positions) // 3 # All the new indices reference the new vertices
self.face_indices.extend([base_index + i for t in indices_raw for i in t])
self.face_positions.extend([v for t in vertices_raw for v in t])
self.face_tex_coords.extend([c for t in tex_coord_raw for c in t])
self.face_colors.extend([col for _ in range(len(vertices_raw)) for col in color])
self._faces_primitive.extras["face_triangles_end"].append(len(self.face_indices))
def add_edge(self, vertices_raw: List[Tuple[float, float, float]], mat: str = None): def add_edge(self, vertices_raw: List[Tuple[Tuple[float, float, float], Tuple[float, float, float]]],
"""Add an edge to the GLTF as a new primitive of the unique mesh""" color: Tuple[float, float, float, float] = (0.1, 0.1, 0.4, 1.0)):
vertices = np.array([[v[0], v[1], v[2]] for v in vertices_raw], dtype=np.float32) """Add an edge to the GLTF mesh"""
indices = np.array(list(map(lambda i: [i, i + 1], range(len(vertices) - 1))), dtype=np.uint32) vertices_flat = [v for t in vertices_raw for v in t] # Line from 0 to 1, 2 to 3, 4 to 5, etc.
tex_coord = np.array([]) base_index = len(self.edge_positions) // 3
self._add_any(vertices, indices, tex_coord, mode=LINE_STRIP, material=mat or "edge") self.edge_indices.extend([base_index + i for i in range(len(vertices_flat))])
self.edge_positions.extend([v for t in vertices_flat for v in t])
self.edge_colors.extend([col for _ in range(len(vertices_flat)) for col in color])
self._edges_primitive.extras["edge_points_end"].append(len(self.edge_indices))
def add_vertex(self, vertex: Tuple[float, float, float]): def add_vertex(self, vertex: Tuple[float, float, float],
"""Add a vertex to the GLTF as a new primitive of the unique mesh""" color: Tuple[float, float, float, float] = (0.1, 0.4, 0.1, 1.0)):
vertices = np.array([[vertex[0], vertex[1], vertex[2]]]) """Add a vertex to the GLTF mesh"""
indices = np.array([[0]], dtype=np.uint32) base_index = len(self.vertex_positions) // 3
tex_coord = np.array([], dtype=np.float32) self.vertex_indices.append(base_index)
self._add_any(vertices, indices, tex_coord, mode=POINTS, material="vertex") self.vertex_positions.extend(vertex)
self.vertex_colors.extend(color)
def add_location(self, loc: Location): def add_location(self, loc: Location):
"""Add a location to the GLTF as a new primitive of the unique mesh""" """Add a location to the GLTF as a new primitive of the unique mesh"""
@@ -61,120 +111,91 @@ class GLTFMgr:
# Add 1 origin vertex and 3 edges with custom colors to identify the X, Y and Z axis # Add 1 origin vertex and 3 edges with custom colors to identify the X, Y and Z axis
self.add_vertex(vert(pl.origin)) self.add_vertex(vert(pl.origin))
self.add_edge([vert(pl.origin), vert(pl.origin + pl.x_dir)], mat="locX") self.add_edge([(vert(pl.origin), vert(pl.origin + pl.x_dir))], color=(0.97, 0.24, 0.24, 1))
self.add_edge([vert(pl.origin), vert(pl.origin + pl.y_dir)], mat="locY") self.add_edge([(vert(pl.origin), vert(pl.origin + pl.y_dir))], color=(0.42, 0.8, 0.15, 1))
self.add_edge([vert(pl.origin), vert(pl.origin + pl.z_dir)], mat="locZ") self.add_edge([(vert(pl.origin), vert(pl.origin + pl.z_dir))], color=(0.09, 0.55, 0.94, 1))
def add_material(self, kind: str) -> int: def build(self) -> GLTF2:
"""It is important to use a different material for each primitive to be able to change them at runtime""" """Merge the intermediate data into the GLTF object and return it"""
new_material: Material buffers_list: List[Tuple[Accessor, BufferView, bytes]] = []
if kind == "face":
new_material = Material(name="face", alphaCutoff=None, pbrMetallicRoughness=PbrMetallicRoughness( if len(self.face_indices) > 0:
baseColorTexture=TextureInfo(index=0), baseColorFactor=[1, 1, 0.5, 1]), doubleSided=True) self._faces_primitive.indices = len(buffers_list)
elif kind == "edge": buffers_list.append(_gen_buffer_metadata(self.face_indices, 1))
new_material = Material(name="edge", alphaCutoff=None, pbrMetallicRoughness=PbrMetallicRoughness( self._faces_primitive.attributes.POSITION = len(buffers_list)
baseColorFactor=[0, 0, 0.5, 1])) buffers_list.append(_gen_buffer_metadata(self.face_positions, 3))
elif kind == "vertex": self._faces_primitive.attributes.TEXCOORD_0 = len(buffers_list)
new_material = Material(name="vertex", alphaCutoff=None, pbrMetallicRoughness=PbrMetallicRoughness( buffers_list.append(_gen_buffer_metadata(self.face_tex_coords, 2))
baseColorFactor=[0, 0.3, 0.3, 1])) self._faces_primitive.attributes.COLOR_0 = len(buffers_list)
elif kind == "locX": buffers_list.append(_gen_buffer_metadata(self.face_colors, 4))
new_material = Material(name="locX", alphaCutoff=None, pbrMetallicRoughness=PbrMetallicRoughness(
baseColorFactor=[0.97, 0.24, 0.24, 1]))
elif kind == "locY":
new_material = Material(name="locY", alphaCutoff=None, pbrMetallicRoughness=PbrMetallicRoughness(
baseColorFactor=[0.42, 0.8, 0.15, 1]))
elif kind == "locZ":
new_material = Material(name="locZ", alphaCutoff=None, pbrMetallicRoughness=PbrMetallicRoughness(
baseColorFactor=[0.09, 0.55, 0.94, 1]))
else: else:
raise ValueError(f"Unknown material kind {kind}") self.image = None # Unused image
self.gltf.materials.append(new_material) self.gltf.meshes[0].primitives = list( # Remove unused faces primitive
return len(self.gltf.materials) - 1 filter(lambda p: p.mode != TRIANGLES, self.gltf.meshes[0].primitives))
def _add_any(self, vertices: np.ndarray, indices: np.ndarray, tex_coord: np.ndarray, mode: int = TRIANGLES, edges_and_vertices_mat = 0
material: str = "face"): if self.image is not None and (len(self.edge_indices) > 0 or len(self.vertex_indices) > 0):
assert vertices.ndim == 2 # Create a material without texture for edges and vertices
assert vertices.shape[1] == 3 edges_and_vertices_mat = len(self.gltf.materials)
vertices = vertices.astype(np.float32) new_mat = copy.deepcopy(self.gltf.materials[0])
vertices_blob = vertices.tobytes() new_mat.pbrMetallicRoughness.baseColorTexture = None
self.gltf.materials.append(new_mat)
assert indices.ndim == 2 # Treat edges and vertices the same way
assert indices.shape[1] == 3 and mode == TRIANGLES or indices.shape[1] == 2 and mode == LINE_STRIP or \ for (indices, positions, colors, primitive, kind) in [
indices.shape[1] == 1 and mode == POINTS (self.edge_indices, self.edge_positions, self.edge_colors, self._edges_primitive, LINES),
indices = indices.astype(np.uint32) (self.vertex_indices, self.vertex_positions, self.vertex_colors, self._vertices_primitive, POINTS)
indices_blob = indices.flatten().tobytes() ]:
if len(indices) > 0:
primitive.material = edges_and_vertices_mat
primitive.indices = len(buffers_list)
buffers_list.append(_gen_buffer_metadata(indices, 1))
primitive.attributes.POSITION = len(buffers_list)
buffers_list.append(_gen_buffer_metadata(positions, 3))
primitive.attributes.COLOR_0 = len(buffers_list)
buffers_list.append(_gen_buffer_metadata(colors, 4))
else:
self.gltf.meshes[0].primitives = list( # Remove unused edges primitive
filter(lambda p: p.mode != kind, self.gltf.meshes[0].primitives))
# Check that all vertices are referenced by the indices if self.image is not None: # Add texture last as it creates a fake accessor that is not added!
# This can happen on broken faces like on some fonts self.gltf.images = [Image(bufferView=len(buffers_list), mimeType=self.image[1])]
# assert indices.max() == len(vertices) - 1, f"{indices.max()} != {len(vertices) - 1}" self.gltf.textures = [Texture(source=0, sampler=0)]
# assert indices.min() == 0, f"min({indices}) != 0" self.gltf.samplers = [Sampler(magFilter=NEAREST)]
# assert np.unique(indices.flatten()).size == len(vertices) self.gltf.materials[0].pbrMetallicRoughness.baseColorTexture = TextureInfo(index=0)
buffers_list.append((Accessor(), BufferView(), self.image[0]))
assert len(tex_coord) == 0 or tex_coord.ndim == 2 # Once all the data is ready, we can concatenate the buffers updating the accessors and views
assert len(tex_coord) == 0 or tex_coord.shape[1] == 2 prev_binary_blob = self.gltf.binary_blob() or b''
tex_coord = tex_coord.astype(np.float32)
tex_coord_blob = tex_coord.tobytes()
accessor_base = len(self.gltf.accessors)
self.gltf.meshes[0].primitives.append(
Primitive(
attributes=Attributes(POSITION=accessor_base + 1, TEXCOORD_0=accessor_base + 2)
if len(tex_coord) > 0 else Attributes(POSITION=accessor_base + 1),
indices=accessor_base,
mode=mode,
material=self.add_material(material),
)
)
buffer_view_base = len(self.gltf.bufferViews)
self.gltf.accessors.extend([it for it in [
Accessor(
bufferView=buffer_view_base,
componentType=UNSIGNED_INT,
count=indices.size,
type=SCALAR,
max=[int(indices.max())],
min=[int(indices.min())],
),
Accessor(
bufferView=buffer_view_base + 1,
componentType=FLOAT,
count=len(vertices),
type=VEC3,
max=vertices.max(axis=0).tolist(),
min=vertices.min(axis=0).tolist(),
),
Accessor(
bufferView=buffer_view_base + 2,
componentType=FLOAT,
count=len(tex_coord),
type=VEC2,
max=tex_coord.max(axis=0).tolist(),
min=tex_coord.min(axis=0).tolist(),
) if len(tex_coord) > 0 else None
] if it is not None])
prev_binary_blob = self.gltf.binary_blob()
byte_offset_base = len(prev_binary_blob) byte_offset_base = len(prev_binary_blob)
self.gltf.bufferViews.extend([bv for bv in [ for accessor, bufferView, blob in buffers_list:
BufferView(
buffer=0,
byteOffset=byte_offset_base,
byteLength=len(indices_blob),
target=ELEMENT_ARRAY_BUFFER,
),
BufferView(
buffer=0,
byteOffset=byte_offset_base + len(indices_blob),
byteLength=len(vertices_blob),
target=ARRAY_BUFFER,
),
BufferView(
buffer=0,
byteOffset=byte_offset_base + len(indices_blob) + len(vertices_blob),
byteLength=len(tex_coord_blob),
target=ARRAY_BUFFER,
)
] if bv.byteLength > 0])
self.gltf.set_binary_blob(prev_binary_blob + indices_blob + vertices_blob + tex_coord_blob) if accessor.componentType is not None: # Remove accessor of texture
buffer_view_base = len(self.gltf.bufferViews)
accessor.bufferView = buffer_view_base
self.gltf.accessors.append(accessor)
bufferView.buffer = 0
bufferView.byteOffset = byte_offset_base
bufferView.byteLength = len(blob)
self.gltf.bufferViews.append(bufferView)
byte_offset_base += len(blob)
prev_binary_blob += blob
self.gltf.buffers.append(Buffer(byteLength=byte_offset_base))
self.gltf.set_binary_blob(prev_binary_blob)
return self.gltf
def _gen_buffer_metadata(data: List[any], chunk: int) -> Tuple[Accessor, BufferView, bytes]:
return Accessor(
componentType={1: UNSIGNED_INT, 2: FLOAT, 3: FLOAT, 4: FLOAT}[chunk],
count=len(data) // chunk,
type={1: SCALAR, 2: VEC2, 3: VEC3, 4: VEC4}[chunk],
max=[max(data[i::chunk]) for i in range(chunk)],
min=[min(data[i::chunk]) for i in range(chunk)],
), BufferView(
target={1: ELEMENT_ARRAY_BUFFER, 2: ARRAY_BUFFER, 3: ARRAY_BUFFER, 4: ARRAY_BUFFER}[chunk],
), np.array(data, dtype={1: np.uint32, 2: np.float32, 3: np.float32, 4: np.float32}[chunk]).tobytes()

View File

@@ -24,11 +24,11 @@ def build_logo(text: bool = True) -> Dict[str, Union[Part, Location, str]]:
logo_img_location.position.Z) logo_img_location.position.Z)
logo_img_path = os.path.join(ASSETS_DIR, 'img.jpg') logo_img_path = os.path.join(ASSETS_DIR, 'img.jpg')
img_bytes, img_name = prepare_image(logo_img_path, logo_img_location, height=18) img_glb_bytes, img_name = image_to_gltf(logo_img_path, logo_img_location, height=18)
fox_glb_bytes = open(os.path.join(ASSETS_DIR, 'fox.glb'), 'rb').read() fox_glb_bytes = open(os.path.join(ASSETS_DIR, 'fox.glb'), 'rb').read()
return {'fox': fox_glb_bytes, 'logo': logo_obj, 'location': logo_img_location, img_name: img_bytes} return {'fox': fox_glb_bytes, 'logo': logo_obj, 'location': logo_img_location, img_name: img_glb_bytes}
if __name__ == "__main__": if __name__ == "__main__":
@@ -43,7 +43,7 @@ if __name__ == "__main__":
# If this is not set, the server will auto-start on import and show_* calls will provide live updates # If this is not set, the server will auto-start on import and show_* calls will provide live updates
os.environ['YACV_DISABLE_SERVER'] = 'True' os.environ['YACV_DISABLE_SERVER'] = 'True'
from yacv_server import export_all, remove, prepare_image, show from yacv_server import export_all, show, image_to_gltf
# Build the CAD part of the logo # Build the CAD part of the logo
logo = build_logo() logo = build_logo()
@@ -52,7 +52,8 @@ if __name__ == "__main__":
show(*[obj for obj in logo.values()], names=[name for name in logo.keys()]) show(*[obj for obj in logo.values()], names=[name for name in logo.keys()])
if testing_server: if testing_server:
remove('location') # Test removing a part # remove('location') # Test removing a part
pass
else: else:
# Save the complete logo to multiple GLB files # Save the complete logo to multiple GLB files
export_all(os.path.join(ASSETS_DIR, 'logo_build')) export_all(os.path.join(ASSETS_DIR, 'logo_build'))

View File

@@ -36,26 +36,26 @@ def tessellate(
shape = Shape(cad_like) shape = Shape(cad_like)
# Perform tessellation tasks # Perform tessellation tasks
edge_to_faces: Dict[TopoDS_Edge, List[TopoDS_Face]] = {} edge_to_faces: Dict[str, List[TopoDS_Face]] = {}
vertex_to_faces: Dict[TopoDS_Vertex, List[TopoDS_Face]] = {} vertex_to_faces: Dict[str, List[TopoDS_Face]] = {}
if faces: if faces:
for face in shape.faces(): for face in shape.faces():
_tessellate_face(mgr, face.wrapped, tolerance, angular_tolerance) _tessellate_face(mgr, face.wrapped, tolerance, angular_tolerance)
if edges: if edges:
for edge in face.edges(): for edge in face.edges():
edge_to_faces[edge.wrapped] = edge_to_faces.get(edge.wrapped, []) + [face.wrapped] edge_to_faces[_hashcode(edge.wrapped)] = edge_to_faces.get(_hashcode(edge.wrapped), []) + [face.wrapped]
if vertices: if vertices:
for vertex in face.vertices(): for vertex in face.vertices():
vertex_to_faces[vertex.wrapped] = vertex_to_faces.get(vertex.wrapped, []) + [face.wrapped] vertex_to_faces[_hashcode(vertex.wrapped)] = vertex_to_faces.get(_hashcode(vertex.wrapped), []) + [face.wrapped]
if edges: if edges:
for edge in shape.edges(): for edge in shape.edges():
_tessellate_edge(mgr, edge.wrapped, edge_to_faces.get(edge.wrapped, []), angular_tolerance, _tessellate_edge(mgr, edge.wrapped, edge_to_faces.get(_hashcode(edge.wrapped), []), angular_tolerance,
angular_tolerance) angular_tolerance)
if vertices: if vertices:
for vertex in shape.vertices(): for vertex in shape.vertices():
_tessellate_vertex(mgr, vertex.wrapped, vertex_to_faces.get(vertex.wrapped, [])) _tessellate_vertex(mgr, vertex.wrapped, vertex_to_faces.get(_hashcode(vertex.wrapped), []))
return mgr.gltf return mgr.build()
def _tessellate_face( def _tessellate_face(
@@ -91,9 +91,9 @@ def _push_point(v: Tuple[float, float, float], faces: List[TopoDS_Face]) -> Tupl
push_dir = (push_dir[0] + normal.X, push_dir[1] + normal.Y, push_dir[2] + normal.Z) push_dir = (push_dir[0] + normal.X, push_dir[1] + normal.Y, push_dir[2] + normal.Z)
if push_dir != (0, 0, 0): if push_dir != (0, 0, 0):
# Normalize the push direction by the number of faces and a constant factor # Normalize the push direction by the number of faces and a constant factor
# NOTE: Don't overdo it, or metrics will be wrong # NOTE: Don't overdo it, or metrics will be (more) wrong
n = len(faces) / 1e-3 n = 1e-3 / len(faces)
push_dir = (push_dir[0] / n, push_dir[1] / n, push_dir[2] / n) push_dir = (push_dir[0] * n, push_dir[1] * n, push_dir[2] * n)
# Push the vertex by the normal # Push the vertex by the normal
v = (v[0] + push_dir[0], v[1] + push_dir[1], v[2] + push_dir[2]) v = (v[0] + push_dir[0], v[1] + push_dir[1], v[2] + push_dir[2])
return v return v
@@ -119,6 +119,9 @@ def _tessellate_edge(
for i in range(1, discretizer.NbPoints() + 1) for i in range(1, discretizer.NbPoints() + 1)
) )
] ]
# Convert strip of vertices to a list of pairs of vertices
vertices = [(vertices[i], vertices[i + 1]) for i in range(len(vertices) - 1)]
mgr.add_edge(vertices) mgr.add_edge(vertices)

View File

@@ -777,10 +777,10 @@
"@sigstore/core" "^1.0.0" "@sigstore/core" "^1.0.0"
"@sigstore/protobuf-specs" "^0.3.0" "@sigstore/protobuf-specs" "^0.3.0"
"@tsconfig/node20@^20.1.2": "@tsconfig/node20@^20.1.3":
version "20.1.2" version "20.1.3"
resolved "https://registry.yarnpkg.com/@tsconfig/node20/-/node20-20.1.2.tgz#b93128c411d38e9507035255195bc8a6718541e3" resolved "https://registry.yarnpkg.com/@tsconfig/node20/-/node20-20.1.3.tgz#b3b4cf785e1b390a6ab48a68aa594a25960d2fe8"
integrity sha512-madaWq2k+LYMEhmcp0fs+OGaLFk0OenpHa4gmI4VEmCKX4PJntQ6fnnGADVFrVkBj0wIdAlQnK/MrlYTHsa1gQ== integrity sha512-XeWn6Gms5MaQWdj+C4fuxuo/Icy8ckh+BwAIijhX2LKRHHt1OuctLLLlB0F4EPi55m2IUJNTnv8FH9kSBI7Ogw==
"@tufjs/canonical-json@2.0.0": "@tufjs/canonical-json@2.0.0":
version "2.0.0" version "2.0.0"
@@ -795,6 +795,11 @@
"@tufjs/canonical-json" "2.0.0" "@tufjs/canonical-json" "2.0.0"
minimatch "^9.0.3" minimatch "^9.0.3"
"@tweenjs/tween.js@~23.1.1":
version "23.1.1"
resolved "https://registry.yarnpkg.com/@tweenjs/tween.js/-/tween.js-23.1.1.tgz#0ae28ed9c635805557f78c2626464018d5f1b5e2"
integrity sha512-ZpboH7pCPPeyBWKf8c7TJswtCEQObFo3bOBYalm99NzZarATALYCo5OhbCa/n4RQyJyHfhkdx+hNrdL5ByFYDw==
"@types/estree@1.0.5": "@types/estree@1.0.5":
version "1.0.5" version "1.0.5"
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4"
@@ -817,11 +822,12 @@
resolved "https://registry.yarnpkg.com/@types/stats.js/-/stats.js-0.17.3.tgz#705446e12ce0fad618557dd88236f51148b7a935" resolved "https://registry.yarnpkg.com/@types/stats.js/-/stats.js-0.17.3.tgz#705446e12ce0fad618557dd88236f51148b7a935"
integrity sha512-pXNfAD3KHOdif9EQXZ9deK82HVNaXP5ZIF5RP2QG6OQFNTaY2YIetfrE9t528vEreGQvEPRDDc8muaoYeK0SxQ== integrity sha512-pXNfAD3KHOdif9EQXZ9deK82HVNaXP5ZIF5RP2QG6OQFNTaY2YIetfrE9t528vEreGQvEPRDDc8muaoYeK0SxQ==
"@types/three@^0.160.0": "@types/three@^0.162.0":
version "0.160.0" version "0.162.0"
resolved "https://registry.yarnpkg.com/@types/three/-/three-0.160.0.tgz#7915a97e0a14ccaa9ccbb9f190c5730b04a23075" resolved "https://registry.yarnpkg.com/@types/three/-/three-0.162.0.tgz#79d170c88f14b2eaee6b76af00fc4016a533e586"
integrity sha512-jWlbUBovicUKaOYxzgkLlhkiEQJkhCVvg4W2IYD2trqD2om3VK4DGLpHH5zQHNr7RweZK/5re/4IVhbhvxbV9w== integrity sha512-0j5yZcVukVIhrhSIC7+LmBPkkMoMuEJ1AfYBZfgNytdYqYREMuiyXWhYOMeZLBElTEAlJIZn7r2W3vqTIgjWlg==
dependencies: dependencies:
"@tweenjs/tween.js" "~23.1.1"
"@types/stats.js" "*" "@types/stats.js" "*"
"@types/webxr" "*" "@types/webxr" "*"
fflate "~0.6.10" fflate "~0.6.10"
@@ -2893,6 +2899,11 @@ terser@^5.29.2:
commander "^2.20.0" commander "^2.20.0"
source-map-support "~0.5.20" source-map-support "~0.5.20"
three-mesh-bvh@^0.7.3:
version "0.7.3"
resolved "https://registry.yarnpkg.com/three-mesh-bvh/-/three-mesh-bvh-0.7.3.tgz#91f2d7e26f230288b5b0a6bdf41bdd9620348945"
integrity sha512-3W6KjzmupjfE89GuHPT31kxKWZ4YGZPEZJNysJpiOZfQRsBQQgmK7v/VJPpjG6syhAvTnY+5Fr77EvIkTLpGSw==
"three-orientation-gizmo@https://github.com/jrj2211/three-orientation-gizmo": "three-orientation-gizmo@https://github.com/jrj2211/three-orientation-gizmo":
version "1.1.0" version "1.1.0"
resolved "https://github.com/jrj2211/three-orientation-gizmo#000281f0559c316f72cdd23a1885d63ae6901095" resolved "https://github.com/jrj2211/three-orientation-gizmo#000281f0559c316f72cdd23a1885d63ae6901095"
@@ -2904,10 +2915,10 @@ three@^0.125.0:
resolved "https://registry.yarnpkg.com/three/-/three-0.125.2.tgz#dcba12749a2eb41522e15212b919cd3fbf729b12" resolved "https://registry.yarnpkg.com/three/-/three-0.125.2.tgz#dcba12749a2eb41522e15212b919cd3fbf729b12"
integrity sha512-7rIRO23jVKWcAPFdW/HREU2NZMGWPBZ4XwEMt0Ak0jwLUKVJhcKM55eCBWyGZq/KiQbeo1IeuAoo/9l2dzhTXA== integrity sha512-7rIRO23jVKWcAPFdW/HREU2NZMGWPBZ4XwEMt0Ak0jwLUKVJhcKM55eCBWyGZq/KiQbeo1IeuAoo/9l2dzhTXA==
three@^0.160.1: three@^0.162.0:
version "0.160.1" version "0.162.0"
resolved "https://registry.yarnpkg.com/three/-/three-0.160.1.tgz#61fe2907312e8604b1f64187f58e047503847413" resolved "https://registry.yarnpkg.com/three/-/three-0.162.0.tgz#b15a511f1498e0c42d4d00bbb411c7527b06097e"
integrity sha512-Bgl2wPJypDOZ1stAxwfWAcJ0WQf7QzlptsxkjYiURPz+n5k4RBDLsq+6f9Y75TYxn6aHLcWz+JNmwTOXWrQTBQ== integrity sha512-xfCYj4RnlozReCmUd+XQzj6/5OjDNHBy5nT6rVwrOKGENAvpXe2z1jL+DZYaMu4/9pNsjH/4Os/VvS9IrH7IOQ==
to-fast-properties@^2.0.0: to-fast-properties@^2.0.0:
version "2.0.0" version "2.0.0"
@@ -2997,10 +3008,10 @@ validate-npm-package-name@^5.0.0:
dependencies: dependencies:
builtins "^5.0.0" builtins "^5.0.0"
vite@^5.2.3: vite@^5.2.6:
version "5.2.3" version "5.2.6"
resolved "https://registry.yarnpkg.com/vite/-/vite-5.2.3.tgz#198efc2fd4d80eac813b146a68a4b0dbde884fc2" resolved "https://registry.yarnpkg.com/vite/-/vite-5.2.6.tgz#fc2ce309e0b4871e938cb0aca3b96c422c01f222"
integrity sha512-+i1oagbvkVIhEy9TnEV+fgXsng13nZM90JQbrcPrf6DvW2mXARlz+DK7DLiDP+qeKoD1FCVx/1SpFL1CLq9Mhw== integrity sha512-FPtnxFlSIKYjZ2eosBQamz4CbyrTizbZ3hnGJlh/wMtCrlp1Hah6AzBLjGI5I2urTfNnpovpHdrL6YRuBOPnCA==
dependencies: dependencies:
esbuild "^0.20.1" esbuild "^0.20.1"
postcss "^8.4.36" postcss "^8.4.36"