From 6a0aa265b6ad73ebd5066cb9f89df36887183cdc Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 31 Aug 2025 15:15:10 +0000 Subject: [PATCH] chore(deps): update dependency @vue/tsconfig to ^0.8.0 (#251) * chore(deps): update dependency @vue/tsconfig to ^0.8.0 * Fix new ts issues * Add null checks for selection and model objects throughout frontend This improves robustness by handling cases where selection or model objects may be missing or undefined, preventing runtime errors. --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Yeicor <4929005+yeicor@users.noreply.github.com> --- frontend/App.vue | 7 +- frontend/misc/distances.ts | 135 ++++++------ frontend/misc/gltf.ts | 217 ++++++++++--------- frontend/misc/helpers.ts | 286 +++++++++++++++---------- frontend/misc/settings.ts | 2 +- frontend/models/Model.vue | 10 +- frontend/models/Models.vue | 6 +- frontend/tools/Selection.vue | 46 ++-- frontend/tools/selection.ts | 283 ++++++++++++------------ frontend/viewer/ModelViewerWrapper.vue | 16 +- frontend/viewer/lighting.ts | 140 ++++++------ package.json | 2 +- yarn.lock | 8 +- 13 files changed, 650 insertions(+), 508 deletions(-) diff --git a/frontend/App.vue b/frontend/App.vue index 81775ac..eb73efc 100644 --- a/frontend/App.vue +++ b/frontend/App.vue @@ -51,6 +51,7 @@ async function onModelUpdateRequest(event: NetworkUpdateEvent) { for (let modelIndex in event.models) { let isLast = parseInt(modelIndex) === event.models.length - 1; let model = event.models[modelIndex]; + if (!model) continue; tools.value?.removeObjectSelections(model.name); try { let loadHelpers = (await settings).loadHelpers; @@ -153,7 +154,9 @@ document.body.addEventListener("drop", async e => { Still trying to load the following: - {{ model }}, + @@ -190,4 +193,4 @@ html, body { height: 100%; overflow: hidden !important; } - \ No newline at end of file + diff --git a/frontend/misc/distances.ts b/frontend/misc/distances.ts index 96f815f..c5cf16f 100644 --- a/frontend/misc/distances.ts +++ b/frontend/misc/distances.ts @@ -1,75 +1,88 @@ -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 {SelectionInfo} from "../tools/selection"; +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 { SelectionInfo } from "../tools/selection"; - -function getCenterAndVertexList(selInfo: SelectionInfo, scene: ModelScene): { - center: Vector3, - vertices: Array +function getCenterAndVertexList( + selInfo: SelectionInfo, + scene: ModelScene, +): { + center: Vector3; + vertices: Array; } { - 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; - } + if (!selInfo.object?.geometry) { + throw new Error("selInfo.object or geometry is undefined"); + } + let pos = selInfo.object.geometry.getAttribute("position"); + let ind = 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; } - let center = new Vector3(); - let vertices = []; - 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(selInfo.object.localToWorld(vertex)); - center.add(vertex); - vertices.push(vertex); - } - center = center.divideScalar(selInfo.indices[1] - selInfo.indices[0]); - return {center, vertices}; + } + let center = new Vector3(); + let vertices = []; + 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(selInfo.object.localToWorld(vertex)); + center.add(vertex); + vertices.push(vertex); + } + center = center.divideScalar(selInfo.indices[1] - selInfo.indices[0]); + return { center, vertices }; } /** * 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: SelectionInfo, b: SelectionInfo, scene: ModelScene): { - min: Array, - center: Array, - max: Array +export function distances( + a: SelectionInfo, + b: SelectionInfo, + scene: ModelScene, +): { + min: Array; + center: Array; + max: Array; } { - // Simplify this problem (approximate) by using the distance between each of their vertices. - // Find the center of each object. - let {center: aCenter, vertices: aVertices} = getCenterAndVertexList(a, scene); - let {center: bCenter, vertices: bVertices} = getCenterAndVertexList(b, scene); + // Simplify this problem (approximate) by using the distance between each of their vertices. + // Find the center of each object. + let { center: aCenter, vertices: aVertices } = getCenterAndVertexList(a, scene); + let { center: bCenter, vertices: bVertices } = getCenterAndVertexList(b, scene); - // Find the closest and farthest vertices. - // TODO: Compute actual min and max distances between the two objects. - // FIXME: Really slow... (use a BVH or something) - let minDistance = Infinity; - let minDistanceVertices = [new Vector3(), new Vector3()]; - let maxDistance = -Infinity; - let maxDistanceVertices = [new Vector3(), new Vector3()]; - for (let i = 0; i < aVertices.length; i++) { - for (let j = 0; j < bVertices.length; j++) { - let distance = aVertices[i].distanceTo(bVertices[j]); - if (distance < minDistance) { - minDistance = distance; - minDistanceVertices[0] = aVertices[i]; - minDistanceVertices[1] = bVertices[j]; - } - if (distance > maxDistance) { - maxDistance = distance; - maxDistanceVertices[0] = aVertices[i]; - maxDistanceVertices[1] = bVertices[j]; - } + // Find the closest and farthest vertices. + // TODO: Compute actual min and max distances between the two objects. + // FIXME: Really slow... (use a BVH or something) + let minDistance = Infinity; + let minDistanceVertices = [new Vector3(), new Vector3()]; + let maxDistance = -Infinity; + let maxDistanceVertices = [new Vector3(), new Vector3()]; + for (let i = 0; i < aVertices.length; i++) { + for (let j = 0; j < bVertices.length; j++) { + const aVertex = aVertices[i]; + const bVertex = bVertices[j]; + if (aVertex && bVertex) { + let distance = aVertex.distanceTo(bVertex); + if (distance < minDistance) { + minDistance = distance; + minDistanceVertices[0] = aVertex; + minDistanceVertices[1] = bVertex; } + if (distance > maxDistance) { + maxDistance = distance; + maxDistanceVertices[0] = aVertex; + maxDistanceVertices[1] = bVertex; + } + } } + } - // Return the results. - return { - min: minDistanceVertices, - center: [aCenter, bCenter], - max: maxDistanceVertices - }; -} \ No newline at end of file + // Return the results. + return { + min: minDistanceVertices, + center: [aCenter, bCenter], + max: maxDistanceVertices, + }; +} diff --git a/frontend/misc/gltf.ts b/frontend/misc/gltf.ts index 81516a4..1555922 100644 --- a/frontend/misc/gltf.ts +++ b/frontend/misc/gltf.ts @@ -1,13 +1,13 @@ -import {Buffer, Document, Scene, type Transform, WebIO} from "@gltf-transform/core"; -import {mergeDocuments, unpartition} from "@gltf-transform/functions"; -import {retrieveFile} from "../tools/upload-file.ts"; +import { Buffer, Document, Scene, type Transform, WebIO } from "@gltf-transform/core"; +import { mergeDocuments, unpartition } from "@gltf-transform/functions"; +import { retrieveFile } from "../tools/upload-file.ts"; let io = new WebIO(); export let extrasNameKey = "__yacv_name"; export let extrasNameValueHelpers = "__helpers"; // @ts-expect-error -let isSmallBuild = typeof __YACV_SMALL_BUILD__ !== 'undefined' && __YACV_SMALL_BUILD__; +let isSmallBuild = typeof __YACV_SMALL_BUILD__ !== "undefined" && __YACV_SMALL_BUILD__; /** * Loads a GLB model from a URL and adds it to the document or replaces it if the names match. @@ -16,133 +16,148 @@ let isSmallBuild = typeof __YACV_SMALL_BUILD__ !== 'undefined' && __YACV_SMALL_B * * Remember to call mergeFinalize after all models have been merged (slower required operations). */ -export async function mergePartial(url: string | Blob, name: string, document: Document, networkFinished: () => void = () => { -}): Promise { - // 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 fetchOrRead(url); - let buffer = await response.arrayBuffer(); - networkFinished(); +export async function mergePartial( + url: string | Blob, + name: string, + document: Document, + networkFinished: () => void = () => {}, +): Promise { + // 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 fetchOrRead(url); + let buffer = await response.arrayBuffer(); + networkFinished(); - // Load the new document - let newDoc = null; - let alreadyTried: { [name: string]: boolean } = {} - while (newDoc == null) { // Retry adding extensions as required until the document is loaded - try { // Try to load fast if no extensions are used - newDoc = await io.readBinary(new Uint8Array(buffer)); - } catch (e) { // Fallback to wait for download and register big extensions - if (!isSmallBuild && e instanceof Error && e.message.toLowerCase().includes("khr_draco_mesh_compression")) { - if (alreadyTried["draco"]) throw e; else alreadyTried["draco"] = true; - // WARNING: Draco decompression on web is really slow for non-trivial models! (it should work?) - let {KHRDracoMeshCompression} = await import("@gltf-transform/extensions") - // @ts-expect-error - let dracoDecoderWeb = await import("three/examples/jsm/libs/draco/draco_decoder.js"); - // @ts-expect-error - let dracoEncoderWeb = await import("three/examples/jsm/libs/draco/draco_encoder.js"); - io.registerExtensions([KHRDracoMeshCompression]) - .registerDependencies({ - 'draco3d.decoder': await dracoDecoderWeb.default({}), - 'draco3d.encoder': await dracoEncoderWeb.default({}) - }); - } else if (!isSmallBuild && e instanceof Error && e.message.toLowerCase().includes("ext_texture_webp")) { - if (alreadyTried["webp"]) throw e; else alreadyTried["webp"] = true; - let {EXTTextureWebP} = await import("@gltf-transform/extensions") - io.registerExtensions([EXTTextureWebP]); - } else { // TODO: Add more extensions as required - throw e; - } - } + // Load the new document + let newDoc = null; + let alreadyTried: { [name: string]: boolean } = {}; + while (newDoc == null) { + // Retry adding extensions as required until the document is loaded + try { + // Try to load fast if no extensions are used + newDoc = await io.readBinary(new Uint8Array(buffer)); + } catch (e) { + // Fallback to wait for download and register big extensions + if (!isSmallBuild && e instanceof Error && e.message.toLowerCase().includes("khr_draco_mesh_compression")) { + if (alreadyTried["draco"]) throw e; + else alreadyTried["draco"] = true; + // WARNING: Draco decompression on web is really slow for non-trivial models! (it should work?) + let { KHRDracoMeshCompression } = await import("@gltf-transform/extensions"); + // @ts-expect-error + let dracoDecoderWeb = await import("three/examples/jsm/libs/draco/draco_decoder.js"); + // @ts-expect-error + let dracoEncoderWeb = await import("three/examples/jsm/libs/draco/draco_encoder.js"); + io.registerExtensions([KHRDracoMeshCompression]).registerDependencies({ + "draco3d.decoder": await dracoDecoderWeb.default({}), + "draco3d.encoder": await dracoEncoderWeb.default({}), + }); + } else if (!isSmallBuild && e instanceof Error && e.message.toLowerCase().includes("ext_texture_webp")) { + if (alreadyTried["webp"]) throw e; + else alreadyTried["webp"] = true; + let { EXTTextureWebP } = await import("@gltf-transform/extensions"); + io.registerExtensions([EXTTextureWebP]); + } else { + // TODO: Add more extensions as required + throw e; + } } + } - // Remove any previous model with the same name - await document.transform(dropByName(name)); + // Remove any previous model with the same name + await document.transform(dropByName(name)); - // Ensure consistent names - // noinspection TypeScriptValidateJSTypes - await newDoc.transform(setNames(name)); + // Ensure consistent names + // noinspection TypeScriptValidateJSTypes + await newDoc.transform(setNames(name)); - // Merge the new document into the current one - mergeDocuments(document, newDoc); - return document; + // Merge the new document into the current one + mergeDocuments(document, newDoc); + return document; } export async function mergeFinalize(document: Document): Promise { - // Single scene & buffer required before loading & rendering - return await document.transform(mergeScenes(), unpartition()); + // Single scene & buffer required before loading & rendering + return await document.transform(mergeScenes(), unpartition()); } export async function toBuffer(doc: Document): Promise { - return io.writeBinary(doc); + return io.writeBinary(doc); } export async function removeModel(name: string, document: Document): Promise { - return await document.transform(dropByName(name)); + return await document.transform(dropByName(name)); } /** Given a parsed GLTF document and a name, it forces the names of all elements to be identified by the name (or derivatives) */ function setNames(name: string): Transform { - return (doc: Document) => { - // Do this automatically for all elements changing any name - for (let elem of doc.getGraph().listEdges().map(e => e.getChild())) { - if (!elem.getExtras()) elem.setExtras({}); - elem.getExtras()[extrasNameKey] = name; - } - return doc; + return (doc: Document) => { + // Do this automatically for all elements changing any name + for (let elem of doc + .getGraph() + .listEdges() + .map((e) => e.getChild())) { + if (!elem.getExtras()) elem.setExtras({}); + elem.getExtras()[extrasNameKey] = name; } + return doc; + }; } /** Ensures that all elements with the given name are removed from the document */ function dropByName(name: string): Transform { - return (doc: Document) => { - for (let elem of doc.getGraph().listEdges().map(e => e.getChild())) { - if (elem.getExtras() == null || elem instanceof Scene || elem instanceof Buffer) continue; - if ((elem.getExtras()[extrasNameKey]?.toString() ?? "") == name) { - elem.dispose(); - } - } - return doc; - }; + return (doc: Document) => { + for (let elem of doc + .getGraph() + .listEdges() + .map((e) => e.getChild())) { + if (elem.getExtras() == null || elem instanceof Scene || elem instanceof Buffer) continue; + if ((elem.getExtras()[extrasNameKey]?.toString() ?? "") == name) { + elem.dispose(); + } + } + return doc; + }; } - /** Merges all scenes in the document into a single default scene */ function mergeScenes(): Transform { - return (doc: Document) => { - let root = doc.getRoot(); - let scene = root.getDefaultScene() ?? root.listScenes()[0]; - for (let dropScene of root.listScenes()) { - if (dropScene === scene) continue; - for (let node of dropScene.listChildren()) { - scene.addChild(node); - } - dropScene.dispose(); - } + return (doc: Document) => { + let root = doc.getRoot(); + let scene = root.getDefaultScene() ?? root.listScenes()[0]; + if (!scene) { + throw new Error("No scene found in GLTF document"); } + for (let dropScene of root.listScenes()) { + if (dropScene === scene) continue; + for (let node of dropScene.listChildren()) { + scene.addChild(node); + } + dropScene.dispose(); + } + }; } /** Fetches a URL or reads it if it is a Blob URL */ async function fetchOrRead(url: string | Blob) { - if (url instanceof Blob) { - // Use the FileReader API as fetch does not support Blob URLs - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onloadend = (event: ProgressEvent) => { - if (event.target && event.target.result) { - resolve(new Response(event.target.result)); - } else { - reject(new Error("Failed to read Blob URL: " + url)); - } - }; - reader.onerror = (error) => { - reject(new Error("Error reading Blob URL: " + url + " - " + error)); - }; - // Read the Blob URL as an ArrayBuffer - reader.readAsArrayBuffer(new Blob([url])); - }); - } else { - // Fetch the URL - return retrieveFile(url); - } - + if (url instanceof Blob) { + // Use the FileReader API as fetch does not support Blob URLs + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = (event: ProgressEvent) => { + if (event.target && event.target.result) { + resolve(new Response(event.target.result)); + } else { + reject(new Error("Failed to read Blob URL: " + url)); + } + }; + reader.onerror = (error) => { + reject(new Error("Error reading Blob URL: " + url + " - " + error)); + }; + // Read the Blob URL as an ArrayBuffer + reader.readAsArrayBuffer(new Blob([url])); + }); + } else { + // Fetch the URL + return retrieveFile(url); + } } - diff --git a/frontend/misc/helpers.ts b/frontend/misc/helpers.ts index 1f8f526..5c4be36 100644 --- a/frontend/misc/helpers.ts +++ b/frontend/misc/helpers.ts @@ -1,79 +1,125 @@ // 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" -import {Matrix4} from "three/src/math/Matrix4.js" - +import { Document, type TypedArray } from "@gltf-transform/core"; +import { Vector2 } from "three/src/math/Vector2.js"; +import { Vector3 } from "three/src/math/Vector3.js"; +import { Matrix4 } from "three/src/math/Matrix4.js"; /** Exports the colors used for the axes, primary and secondary. They match the orientation gizmo. */ export const AxesColors = { - x: [[247, 60, 60], [148, 36, 36]], - z: [[108, 203, 38], [65, 122, 23]], - y: [[23, 140, 240], [14, 84, 144]] -} + x: [ + [247, 60, 60], + [148, 36, 36], + ], + z: [ + [108, 203, 38], + [65, 122, 23], + ], + y: [ + [23, 140, 240], + [14, 84, 144], + ], +}; -function buildSimpleGltf(doc: Document, rawPositions: number[], rawIndices: number[], rawColors: number[] | null, transform: Matrix4, name: string = '__helper', mode: number = WebGL2RenderingContext.LINES) { - const buffer = doc.getRoot().listBuffers()[0] ?? doc.createBuffer(name + 'Buffer') - const scene = doc.getRoot().getDefaultScene() ?? doc.getRoot().listScenes()[0] ?? doc.createScene(name + 'Scene') - const positions = doc.createAccessor(name + 'Position') - .setArray(new Float32Array(rawPositions) as TypedArray) - .setType('VEC3') - .setBuffer(buffer) - const indices = doc.createAccessor(name + 'Indices') - .setArray(new Uint32Array(rawIndices) as TypedArray) - .setType('SCALAR') - .setBuffer(buffer) - let colors = null; - if (rawColors) { - colors = doc.createAccessor(name + 'Color') - .setArray(new Float32Array(rawColors) as TypedArray) - .setType('VEC4') - .setBuffer(buffer); - } - const material = doc.createMaterial(name + 'Material') - .setAlphaMode('OPAQUE') - const geometry = doc.createPrimitive() - .setIndices(indices) - .setAttribute('POSITION', positions) - .setMode(mode as any) - .setMaterial(material) - 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) +function buildSimpleGltf( + doc: Document, + rawPositions: number[], + rawIndices: number[], + rawColors: number[] | null, + transform: Matrix4, + name: string = "__helper", + mode: number = WebGL2RenderingContext.LINES, +) { + const buffer = doc.getRoot().listBuffers()[0] ?? doc.createBuffer(name + "Buffer"); + const scene = doc.getRoot().getDefaultScene() ?? doc.getRoot().listScenes()[0] ?? doc.createScene(name + "Scene"); + if (!scene) throw new Error("Scene is undefined"); + if (!rawPositions) throw new Error("rawPositions is undefined"); + const positions = doc + .createAccessor(name + "Position") + .setArray(new Float32Array(rawPositions) as TypedArray) + .setType("VEC3") + .setBuffer(buffer); + const indices = doc + .createAccessor(name + "Indices") + .setArray(new Uint32Array(rawIndices) as TypedArray) + .setType("SCALAR") + .setBuffer(buffer); + let colors = null; + if (rawColors) { + colors = doc + .createAccessor(name + "Color") + .setArray(new Float32Array(rawColors) as TypedArray) + .setType("VEC4") + .setBuffer(buffer); + } + const material = doc.createMaterial(name + "Material").setAlphaMode("OPAQUE"); + const geometry = doc + .createPrimitive() + .setIndices(indices) + .setAttribute("POSITION", positions) + .setMode(mode as any) + .setMaterial(material); + 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); } /** * 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, - ]; - let rawColors = [ - ...(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); - // Axes at (0, 0, 0) - buildSimpleGltf(doc, rawPositions, rawIndices, rawColors, new Matrix4(), '__helper_axes'); - buildSimpleGltf(doc, [0, 0, 0], [0], [1, 1, 1, 1], new Matrix4(), '__helper_axes', WebGL2RenderingContext.POINTS); - // Axes at center - if (new Matrix4() != transform) { - buildSimpleGltf(doc, rawPositions, rawIndices, rawColors, transform, '__helper_axes_center'); - buildSimpleGltf(doc, [0, 0, 0], [0], [1, 1, 1, 1], transform, '__helper_axes_center', WebGL2RenderingContext.POINTS); - } + 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]; + let rawColors = [ + ...(AxesColors.x[0] ?? [255, 0, 0]), + 255, + ...(AxesColors.x[1] ?? [255, 0, 0]), + 255, + ...(AxesColors.y[0] ?? [0, 255, 0]), + 255, + ...(AxesColors.y[1] ?? [0, 255, 0]), + 255, + ...(AxesColors.z[0] ?? [0, 0, 255]), + 255, + ...(AxesColors.z[1] ?? [0, 0, 255]), + 255, + ].map((x) => x / 255.0); + // Axes at (0, 0, 0) + buildSimpleGltf(doc, rawPositions, rawIndices, rawColors, new Matrix4(), "__helper_axes"); + buildSimpleGltf(doc, [0, 0, 0], [0], [1, 1, 1, 1], new Matrix4(), "__helper_axes", WebGL2RenderingContext.POINTS); + // Axes at center + if (new Matrix4() != transform) { + buildSimpleGltf(doc, rawPositions, rawIndices, rawColors, transform, "__helper_axes_center"); + buildSimpleGltf( + doc, + [0, 0, 0], + [0], + [1, 1, 1, 1], + transform, + "__helper_axes_center", + WebGL2RenderingContext.POINTS, + ); + } } /** @@ -83,61 +129,69 @@ export function newAxes(doc: Document, size: Vector3, transform: Matrix4) { * 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, 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)); - let translation = new Matrix4().makeTranslation(offset.x, offset.y, offset.z) - let rotation = new Matrix4().lookAt(new Vector3(), offset, new Vector3(0, 1, 0)) - let size2 = new Vector2(); - 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 = 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); - } - } + // 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)); + let translation = new Matrix4().makeTranslation(offset.x, offset.y, offset.z); + let rotation = new Matrix4().lookAt(new Vector3(), offset, new Vector3(0, 1, 0)); + let size2 = new Vector2(); + 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 = 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); + } + let colors = new Array((allPositions.length / 3) * 4).fill(1); + buildSimpleGltf( + doc, + allPositions, + allIndices, + colors, + baseTransform, + "__helper_grid", + WebGL2RenderingContext.TRIANGLES, + ); } export function newGridPlane(size: Vector2, divisions = 10, divisionWidth = 0.002): [number[], number[]] { - const rawPositions = []; - const rawIndices = []; - // Build the grid as triangles - for (let i = 0; i <= divisions; i++) { - const x = -size.x / 2 + size.x * i / divisions; - const y = -size.y / 2 + size.y * i / divisions; + const rawPositions = []; + const rawIndices = []; + // Build the grid as triangles + for (let i = 0; i <= divisions; i++) { + const x = -size.x / 2 + (size.x * i) / divisions; + const y = -size.y / 2 + (size.y * i) / divisions; - // Vertical quad (two triangles) - rawPositions.push(x - divisionWidth * size.x / 2, -size.y / 2, 0); - rawPositions.push(x + divisionWidth * size.x / 2, -size.y / 2, 0); - rawPositions.push(x + divisionWidth * size.x / 2, size.y / 2, 0); - rawPositions.push(x - divisionWidth * size.x / 2, size.y / 2, 0); - const baseIndex = i * 4; - rawIndices.push(baseIndex, baseIndex + 1, baseIndex + 2); - rawIndices.push(baseIndex, baseIndex + 2, baseIndex + 3); + // Vertical quad (two triangles) + rawPositions.push(x - (divisionWidth * size.x) / 2, -size.y / 2, 0); + rawPositions.push(x + (divisionWidth * size.x) / 2, -size.y / 2, 0); + rawPositions.push(x + (divisionWidth * size.x) / 2, size.y / 2, 0); + rawPositions.push(x - (divisionWidth * size.x) / 2, size.y / 2, 0); + const baseIndex = i * 4; + rawIndices.push(baseIndex, baseIndex + 1, baseIndex + 2); + rawIndices.push(baseIndex, baseIndex + 2, baseIndex + 3); - // Horizontal quad (two triangles) - rawPositions.push(-size.x / 2, y - divisionWidth * size.y / 2, 0); - rawPositions.push(size.x / 2, y - divisionWidth * size.y / 2, 0); - rawPositions.push(size.x / 2, y + divisionWidth * size.y / 2, 0); - rawPositions.push(-size.x / 2, y + divisionWidth * size.y / 2, 0); - const baseIndex2 = (divisions + 1 + i) * 4; - rawIndices.push(baseIndex2, baseIndex2 + 1, baseIndex2 + 2); - rawIndices.push(baseIndex2, baseIndex2 + 2, baseIndex2 + 3); - } - return [rawPositions, rawIndices]; -} \ No newline at end of file + // Horizontal quad (two triangles) + rawPositions.push(-size.x / 2, y - (divisionWidth * size.y) / 2, 0); + rawPositions.push(size.x / 2, y - (divisionWidth * size.y) / 2, 0); + rawPositions.push(size.x / 2, y + (divisionWidth * size.y) / 2, 0); + rawPositions.push(-size.x / 2, y + (divisionWidth * size.y) / 2, 0); + const baseIndex2 = (divisions + 1 + i) * 4; + rawIndices.push(baseIndex2, baseIndex2 + 1, baseIndex2 + 2); + rawIndices.push(baseIndex2, baseIndex2 + 2, baseIndex2 + 3); + } + return [rawPositions, rawIndices]; +} diff --git a/frontend/misc/settings.ts b/frontend/misc/settings.ts index 903b7d6..0c64cd8 100644 --- a/frontend/misc/settings.ts +++ b/frontend/misc/settings.ts @@ -85,7 +85,7 @@ export const settings = (async () => { url = "dev+http://localhost:32323"; } } - settings.preload[i] = url; + settings.preload[i] = url ?? ""; } // Auto-decompress the code and other playground settings diff --git a/frontend/models/Model.vue b/frontend/models/Model.vue index a115090..e230fdb 100644 --- a/frontend/models/Model.vue +++ b/frontend/models/Model.vue @@ -45,7 +45,7 @@ const props = defineProps<{ }>(); const emit = defineEmits<{ remove: [] }>() -let modelName = props.meshes[0].getExtras()[extrasNameKey] // + " blah blah blah blah blag blah blah blah" +let modelName = props.meshes[0]?.getExtras()?.[extrasNameKey] // + " blah blah blah blah blag blah blah blah" // Count the number of faces, edges and vertices let faceCount = ref(-1); @@ -169,9 +169,9 @@ function onClipPlanesChange() { new Plane(new Vector3(0, -1, 0), offsetY).applyMatrix4(rotSceneMatrix), new Plane(new Vector3(0, 0, 1), -offsetZ).applyMatrix4(rotSceneMatrix), ]; - if (clipPlaneSwappedX.value) planes[0].negate(); - if (clipPlaneSwappedY.value) planes[1].negate(); - if (clipPlaneSwappedZ.value) planes[2].negate(); + if (clipPlaneSwappedX.value) planes[0]?.negate(); + if (clipPlaneSwappedY.value) planes[1]?.negate(); + if (clipPlaneSwappedZ.value) planes[2]?.negate(); if (!enabledZ) planes.pop(); if (!enabledY) planes.splice(1, 1); if (!enabledX) planes.shift(); @@ -575,4 +575,4 @@ if (props.viewer) onViewerReady(props.viewer); else watch((() => props.viewer) a .mdi-triangle-outline { /* HACK: mdi is not fully imported, only required icons... */ background-image: url('data:image/svg+xml;charset=UTF-8,'); } - \ No newline at end of file + diff --git a/frontend/models/Models.vue b/frontend/models/Models.vue index e1e5bdc..13f6ef1 100644 --- a/frontend/models/Models.vue +++ b/frontend/models/Models.vue @@ -17,7 +17,7 @@ function meshesList(sceneDocument: Document): Array> { // Grouped by shared name return sceneDocument.getRoot().listMeshes().reduce((acc, mesh) => { let name = mesh.getExtras()[extrasNameKey]?.toString() ?? 'Unnamed'; - let group = acc.find((group) => meshName(group[0]) === name); + let group = acc.find((group) => group[0] && meshName(group[0]) === name); if (group) { group.push(mesh); } else { @@ -43,9 +43,9 @@ defineExpose({findModel}) diff --git a/frontend/tools/Selection.vue b/frontend/tools/Selection.vue index 3a22359..2cb17db 100644 --- a/frontend/tools/Selection.vue +++ b/frontend/tools/Selection.vue @@ -213,7 +213,7 @@ function toggleSelection() { function toggleOpenNextSelection() { openNextSelection.value = [ !openNextSelection.value[0], - openNextSelection.value[0] ? openNextSelection.value[1] : selectionEnabled.value + openNextSelection.value[0] ? (openNextSelection.value[1] ?? false) : selectionEnabled.value ]; if (openNextSelection.value[0]) { // Reuse selection code to identify the model @@ -330,14 +330,20 @@ function updateBoundingBox() { // Only draw one edge per axis, the 2nd closest one to the camera for (let edgeI in edgesByAxis) { let axisEdges = edgesByAxis[edgeI]; - let edge: Array = axisEdges[0]; + if (!axisEdges || axisEdges.length === 0) continue; + let edge: Array = axisEdges[0] ?? []; for (let i = 0; i < 2; i++) { // Find the 2nd closest one by running twice dropping the first - edge = axisEdges[0]; + if (!axisEdges || axisEdges.length === 0) break; + edge = axisEdges[0] ?? []; let edgeDist = Infinity; let cameraPos: Vector3 = props.viewer?.scene?.camera?.position ?? new Vector3(); for (let testEdge of axisEdges) { - let from = new Vector3(...corners[testEdge[0]]); - let to = new Vector3(...corners[testEdge[1]]); + if (!testEdge || testEdge.length < 2) continue; + let cornerA = corners[testEdge[0] ?? 0]; + let cornerB = corners[testEdge[1] ?? 0]; + if (!cornerA || !cornerB) continue; + let from = new Vector3(...cornerA); + let to = new Vector3(...cornerB); let mid = from.clone().add(to).multiplyScalar(0.5); let newDist = cameraPos.distanceTo(mid); if (newDist < edgeDist) { @@ -347,11 +353,16 @@ function updateBoundingBox() { } axisEdges = axisEdges.filter((e) => e !== edge); } - let from = new Vector3(...corners[edge[0]]); - let to = new Vector3(...corners[edge[1]]); + if (!edge || edge.length < 2) continue; + let cornerA = corners[edge[0] ?? 0]; + let cornerB = corners[edge[1] ?? 0]; + if (!cornerA || !cornerB) continue; + let from = new Vector3(...cornerA); + let to = new Vector3(...cornerB); let length = to.clone().sub(from).length(); if (length < 0.05) continue; // Skip very small edges (e.g. a single point) - let color = [AxesColors.x, AxesColors.y, AxesColors.z][edgeI][1]; // Secondary colors + let colorArray = [AxesColors.x, AxesColors.y, AxesColors.z][parseInt(edgeI)]; + let color = colorArray ? colorArray[1] : [255, 255, 255]; // Secondary colors let lineCacheKey = JSON.stringify([from, to]); let matchingLine = boundingBoxLines[lineCacheKey]; if (matchingLine) { @@ -359,7 +370,7 @@ function updateBoundingBox() { } else { let newLineId = props.viewer?.addLine3D(from, to, length.toFixed(1) + "mm", { - "stroke": "rgb(" + color.join(',') + ")", + "stroke": "rgb(" + (color ?? [255, 255, 255]).join(',') + ")", "stroke-width": "2" }); if (newLineId) { @@ -410,10 +421,17 @@ function updateDistances() { } // Add lines (if not already added) - 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"); + if (!selected.value[0] || !selected.value[1] || !props.viewer?.scene) return; + let {min, center, max} = distances(selected.value[0], selected.value[1], props.viewer.scene); + if (max[0] && max[1]) { + ensureLine(max[0], max[1], max[1].distanceTo(max[0]).toFixed(1) + "mm", "orange"); + } + if (center[0] && center[1]) { + ensureLine(center[0], center[1], center[1].distanceTo(center[0]).toFixed(1) + "mm", "green"); + } + if (min[0] && min[1]) { + ensureLine(min[0], min[1], min[1].distanceTo(min[0]).toFixed(1) + "mm", "cyan"); + } // Remove the lines that are no longer needed for (let lineLocator of distanceLinesToRemove) { @@ -504,4 +522,4 @@ window.addEventListener('keydown', (event) => { top: -12px; width: calc(100% - 48px); } - \ No newline at end of file + diff --git a/frontend/tools/selection.ts b/frontend/tools/selection.ts index 31f37a4..3c36442 100644 --- a/frontend/tools/selection.ts +++ b/frontend/tools/selection.ts @@ -1,163 +1,182 @@ // Model management from the graphics side -import type {MObject3D} from "./Selection.vue"; -import type {Intersection} from "three"; -import {Box3} from "three"; -import {extrasNameKey} from "../misc/gltf"; +import type { MObject3D } from "./Selection.vue"; +import type { Intersection } from "three"; +import { Box3 } from "three"; +import { extrasNameKey } from "../misc/gltf"; /** 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] + /** 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; - } + constructor(object: MObject3D, kind: "face" | "edge" | "vertex", indices: [number, number]) { + this.object = object; + this.kind = kind; + this.indices = indices; + } - public getObjectName() { - return this.object.userData[extrasNameKey]; - } + public getObjectName() { + return this.object.userData[extrasNameKey]; + } - public matches(object: MObject3D) { - return this.getObjectName() === object.userData[extrasNameKey] && - (this.kind === 'face' && (object.type === 'Mesh' || object.type === 'SkinnedMesh') || - this.kind === 'edge' && (object.type === 'Line' || object.type === 'LineSegments') || - this.kind === 'vertex' && object.type === 'Points') - } + public matches(object: MObject3D) { + return ( + this.getObjectName() === object.userData[extrasNameKey] && + ((this.kind === "face" && (object.type === "Mesh" || object.type === "SkinnedMesh")) || + (this.kind === "edge" && (object.type === "Line" || object.type === "LineSegments")) || + (this.kind === "vertex" && object.type === "Points")) + ); + } - public getKey() { - return this.object.uuid + this.kind + this.indices[0].toFixed() + this.indices[1].toFixed(); - } + 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]); + 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] ?? Infinity, x); + min[1] = Math.min(min[1] ?? Infinity, y); + min[2] = Math.min(min[2] ?? Infinity, z); + max[0] = Math.max(max[0] ?? -Infinity, x); + max[1] = Math.max(max[1] ?? -Infinity, y); + max[2] = Math.max(max[2] ?? -Infinity, z); } + return new Box3().setFromArray([...min, ...max]); + } } export function hitToSelectionInfo(hit: Intersection): 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; + 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): [number, number] | null { - let faceTrianglesEnd = hit?.object?.geometry?.userData?.face_triangles_end; - if (!hit.faceIndex) 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]]; - } - } + let faceTrianglesEnd = hit?.object?.geometry?.userData?.face_triangles_end; + if (!hit.faceIndex) 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 ?? 0]; + } 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; + } + return null; } function hitEdgePointIndices(hit: Intersection): [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]]; - } + 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; + } + 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; +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]); } - return prevColor!; + 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]); + // 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()]; -} \ No newline at end of file + // 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()]; +} diff --git a/frontend/viewer/ModelViewerWrapper.vue b/frontend/viewer/ModelViewerWrapper.vue index 008cc6b..ca95ea7 100644 --- a/frontend/viewer/ModelViewerWrapper.vue +++ b/frontend/viewer/ModelViewerWrapper.vue @@ -161,9 +161,9 @@ function addLine3D(p1: Vector3, p2: Vector3, centerText?: string, lineAttrs: { [ function removeLine3D(id: number): boolean { if (!scene.value || !(id in lines.value)) return false; scene.value.removeHotspot(new Hotspot({name: 'line' + id + '_start'})); - lines.value[id].startHotspot.parentElement?.remove() + lines.value[id]?.startHotspot.parentElement?.remove() scene.value.removeHotspot(new Hotspot({name: 'line' + id + '_end'})); - lines.value[id].endHotspot.parentElement?.remove() + lines.value[id]?.endHotspot.parentElement?.remove() delete lines.value[id]; scene.value.queueRender() // Needed to update the hotspots return true; @@ -175,17 +175,17 @@ function onCameraChangeLine(lineId: number) { if (!(lineId in lines.value) || !(elem.value)) return // Silently ignore (not updated yet) // Update start and end 2D positions let {x: xB, y: yB} = elem.value.getBoundingClientRect(); - let {x, y} = lines.value[lineId].startHotspot.getBoundingClientRect(); - lines.value[lineId].start2D = [x - xB, y - yB]; - let {x: x2, y: y2} = lines.value[lineId].endHotspot.getBoundingClientRect(); - lines.value[lineId].end2D = [x2 - xB, y2 - yB]; + let {x, y} = lines.value[lineId]?.startHotspot.getBoundingClientRect() ?? {x: 0, y: 0}; + if (lines.value[lineId]) lines.value[lineId].start2D = [x - xB, y - yB]; + let {x: x2, y: y2} = lines.value[lineId]?.endHotspot.getBoundingClientRect() ?? {x: 0, y: 0}; + if (lines.value[lineId]) lines.value[lineId].end2D = [x2 - xB, y2 - yB]; // Update the center text size if needed - if (svg.value && lines.value[lineId].centerText && lines.value[lineId].centerTextSize[0] === 0) { + if (svg.value && lines.value[lineId]?.centerText && lines.value[lineId]?.centerTextSize[0] === 0) { let text = svg.value.getElementsByClassName('line' + lineId + '_text')[0] as SVGTextElement | undefined; if (text) { let bbox = text.getBBox(); - lines.value[lineId].centerTextSize = [bbox.width, bbox.height]; + if (lines.value[lineId]) lines.value[lineId].centerTextSize = [bbox.width, bbox.height]; } } } diff --git a/frontend/viewer/lighting.ts b/frontend/viewer/lighting.ts index 34c48f4..e48f53b 100644 --- a/frontend/viewer/lighting.ts +++ b/frontend/viewer/lighting.ts @@ -1,76 +1,96 @@ -import {ModelViewerElement} from '@google/model-viewer'; -import {$scene} from "@google/model-viewer/lib/model-viewer-base"; -import {settings} from "../misc/settings.ts"; +import { ModelViewerElement } from "@google/model-viewer"; +import { $scene } from "@google/model-viewer/lib/model-viewer-base"; +import { settings } from "../misc/settings.ts"; export let currentSceneRotation = 0; // radians, 0 is the default rotation export async function setupLighting(modelViewer: ModelViewerElement) { - modelViewer[$scene].environmentIntensity = (await settings).environmentIntensity; - // Code is mostly copied from the example at: https://modelviewer.dev/examples/stagingandcameras/#turnSkybox - let lastX: number; - let panning = false; - let radiansPerPixel: number; + modelViewer[$scene].environmentIntensity = (await settings).environmentIntensity; + // Code is mostly copied from the example at: https://modelviewer.dev/examples/stagingandcameras/#turnSkybox + let lastX: number; + let panning = false; + let radiansPerPixel: number; - const startPan = () => { - const orbit = modelViewer.getCameraOrbit(); - const {radius} = orbit; - radiansPerPixel = -1 * radius / modelViewer.getBoundingClientRect().height; - modelViewer.interactionPrompt = 'none'; - }; + const startPan = () => { + const orbit = modelViewer.getCameraOrbit(); + const { radius } = orbit; + radiansPerPixel = (-1 * radius) / modelViewer.getBoundingClientRect().height; + modelViewer.interactionPrompt = "none"; + }; - const updatePan = (thisX: number) => { - const delta = (thisX - lastX) * radiansPerPixel; - lastX = thisX; - currentSceneRotation += delta; - const orbit = modelViewer.getCameraOrbit(); - orbit.theta += delta; - modelViewer.cameraOrbit = orbit.toString(); - modelViewer.resetTurntableRotation(currentSceneRotation); - modelViewer.jumpCameraToGoal(); - } + const updatePan = (thisX: number) => { + const delta = (thisX - lastX) * radiansPerPixel; + lastX = thisX; + currentSceneRotation += delta; + const orbit = modelViewer.getCameraOrbit(); + orbit.theta += delta; + modelViewer.cameraOrbit = orbit.toString(); + modelViewer.resetTurntableRotation(currentSceneRotation); + modelViewer.jumpCameraToGoal(); + }; - modelViewer.addEventListener('mousedown', (event) => { - panning = event.metaKey || event.shiftKey; - if (!panning) - return; + modelViewer.addEventListener( + "mousedown", + (event) => { + panning = event.metaKey || event.shiftKey; + if (!panning) return; - lastX = event.clientX; - startPan(); - event.stopPropagation(); - }, true); + lastX = event.clientX; + startPan(); + event.stopPropagation(); + }, + true, + ); - modelViewer.addEventListener('touchstart', (event) => { - const {targetTouches, touches} = event; - panning = targetTouches.length === 2 && targetTouches.length === touches.length; - if (!panning) - return; + modelViewer.addEventListener( + "touchstart", + (event) => { + const { targetTouches, touches } = event; + panning = targetTouches.length === 2 && targetTouches.length === touches.length; + if (!panning) return; - lastX = 0.5 * (targetTouches[0].clientX + targetTouches[1].clientX); - startPan(); - }, true); + lastX = 0.5 * ((targetTouches[0]?.clientX ?? 0) + (targetTouches[1]?.clientX ?? 0)); + startPan(); + }, + true, + ); - document.addEventListener('mousemove', (event) => { - if (!panning) - return; + document.addEventListener( + "mousemove", + (event) => { + if (!panning) return; - updatePan(event.clientX); - event.stopPropagation(); - }, true); + updatePan(event.clientX); + event.stopPropagation(); + }, + true, + ); - modelViewer.addEventListener('touchmove', (event) => { - if (!panning || event.targetTouches.length !== 2) - return; + modelViewer.addEventListener( + "touchmove", + (event) => { + if (!panning || event.targetTouches.length !== 2) return; - const {targetTouches} = event; - const thisX = 0.5 * (targetTouches[0].clientX + targetTouches[1].clientX); - updatePan(thisX); - }, true); + const { targetTouches } = event; + const thisX = 0.5 * ((targetTouches[0]?.clientX ?? 0) + (targetTouches[1]?.clientX ?? 0)); + updatePan(thisX); + }, + true, + ); - document.addEventListener('mouseup', (event) => { - panning = false; - }, true); + document.addEventListener( + "mouseup", + (event) => { + panning = false; + }, + true, + ); - modelViewer.addEventListener('touchend', (event) => { - panning = false; - }, true); -} \ No newline at end of file + modelViewer.addEventListener( + "touchend", + (event) => { + panning = false; + }, + true, + ); +} diff --git a/package.json b/package.json index 72f6354..14cd114 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "@types/three": "^0.179.0", "@vitejs/plugin-vue": "^6.0.0", "@vitejs/plugin-vue-jsx": "^5.0.0", - "@vue/tsconfig": "^0.7.0", + "@vue/tsconfig": "^0.8.0", "buffer": "^5.5.0||^6.0.0", "commander": "^14.0.0", "generate-license-file": "^4.0.0", diff --git a/yarn.lock b/yarn.lock index 10af0ae..5045405 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1269,10 +1269,10 @@ resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.5.20.tgz#8740b370738c8c7e29e02fa9051cfe6d20114cb4" integrity sha512-SoRGP596KU/ig6TfgkCMbXkr4YJ91n/QSdMuqeP5r3hVIYA3CPHUBCc7Skak0EAKV+5lL4KyIh61VA/pK1CIAA== -"@vue/tsconfig@^0.7.0": - version "0.7.0" - resolved "https://registry.yarnpkg.com/@vue/tsconfig/-/tsconfig-0.7.0.tgz#67044c847b7a137b8cbfd6b23104c36dbaf80d1d" - integrity sha512-ku2uNz5MaZ9IerPPUyOHzyjhXoX2kVJaVf7hL315DC17vS6IiZRmmCPfggNbU16QTvM80+uYYy3eYJB59WCtvg== +"@vue/tsconfig@^0.8.0": + version "0.8.1" + resolved "https://registry.yarnpkg.com/@vue/tsconfig/-/tsconfig-0.8.1.tgz#4732251fa58945024424385cf3be0b1708fad5fe" + integrity sha512-aK7feIWPXFSUhsCP9PFqPyFOcz4ENkb8hZ2pneL6m2UjCkccvaOhC/5KCKluuBufvp2KzkbdA2W2pk20vLzu3g== "@webgpu/types@*": version "0.1.64"