mirror of
https://github.com/yeicor-3d/yet-another-cad-viewer.git
synced 2025-12-21 23:14:27 +01:00
lots of performance improvements, bug fixes and some new features
This commit is contained in:
@@ -8,7 +8,7 @@ import type {ModelViewerElement} from '@google/model-viewer';
|
||||
import {Vector3} from "three/src/math/Vector3.js";
|
||||
import {Matrix4} from "three/src/math/Matrix4.js";
|
||||
|
||||
globalThis.THREE = {Vector3, Matrix4} as any // HACK: Required for the gizmo to work
|
||||
(globalThis as any).THREE = {Vector3, Matrix4} as any // HACK: Required for the gizmo to work
|
||||
|
||||
const OrientationGizmo = OrientationGizmoRaw.default;
|
||||
|
||||
|
||||
@@ -6,24 +6,28 @@ import type {ModelViewerElement} from '@google/model-viewer';
|
||||
import type {ModelScene} from "@google/model-viewer/lib/three-components/ModelScene";
|
||||
import {mdiCubeOutline, mdiCursorDefaultClick, mdiFeatureSearch, mdiRuler} from '@mdi/js';
|
||||
import type {Intersection, Material, Mesh, Object3D} from "three";
|
||||
import {Box3, Matrix4, Raycaster, Vector3} from "three";
|
||||
import {Box3, Color, Raycaster, Vector3} from "three";
|
||||
import type ModelViewerWrapperT from "../viewer/ModelViewerWrapper.vue";
|
||||
import {extrasNameKey} from "../misc/gltf";
|
||||
import {SceneMgr} from "../misc/scene";
|
||||
import {Document} from "@gltf-transform/core";
|
||||
import {AxesColors} from "../misc/helpers";
|
||||
import {distances} from "../misc/distances";
|
||||
import {highlight, highlightUndo, hitToSelectionInfo, type SelectionInfo} from "./selection";
|
||||
|
||||
export type MObject3D = Mesh & {
|
||||
userData: { noHit?: boolean },
|
||||
material: Material & { color: { r: number, g: number, b: number }, __prevBaseColorFactor?: [number, number, number] }
|
||||
material: Material & {
|
||||
color: Color,
|
||||
wireframe?: boolean
|
||||
}
|
||||
};
|
||||
|
||||
let props = defineProps<{ viewer: typeof ModelViewerWrapperT | null }>();
|
||||
let emit = defineEmits<{ findModel: [string] }>();
|
||||
let {setDisableTap} = inject<{ setDisableTap: (arg0: boolean) => void }>('disableTap')!!;
|
||||
let selectionEnabled = ref(false);
|
||||
let selected = defineModel<Array<Intersection<MObject3D>>>({default: []});
|
||||
let selected = defineModel<Array<SelectionInfo>>({default: []});
|
||||
let highlightNextSelection = ref([false, false]); // Second is whether selection was enabled before
|
||||
let showBoundingBox = ref<Boolean>(false); // Enabled automatically on start
|
||||
let showDistances = ref<Boolean>(true);
|
||||
@@ -92,8 +96,27 @@ let selectionListener = (event: MouseEvent) => {
|
||||
// let lineHandle = props.viewer?.addLine3D(actualFrom, actualTo, "Ray")
|
||||
// setTimeout(() => props.viewer?.removeLine3D(lineHandle), 30000)
|
||||
|
||||
// Find all hit objects and select the wanted one based on the filter
|
||||
const hits = raycaster.intersectObject(scene, true);
|
||||
// Find all hit objects and raycast the wanted ones based on the filter
|
||||
let objects: Array<any> = [];
|
||||
scene.traverse((obj) => {
|
||||
const kind = obj.type
|
||||
let isFace = kind === 'Mesh' || kind === 'SkinnedMesh';
|
||||
let isEdge = kind === 'Line' || kind === 'LineSegments';
|
||||
let isVertex = kind === 'Points';
|
||||
if (obj.userData.noHit !== true &&
|
||||
((selectFilter.value === 'Any (S)' && (isFace || isEdge || isVertex)) ||
|
||||
(selectFilter.value === '(F)aces' && isFace) ||
|
||||
(selectFilter.value === '(E)dges' && isEdge) ||
|
||||
(selectFilter.value === '(V)ertices' && isVertex))) {
|
||||
objects.push(obj);
|
||||
}
|
||||
});
|
||||
//console.log("Raycasting objects", objects)
|
||||
|
||||
// Run the raycaster on the selected objects only searching for the first hit
|
||||
// @ts-ignore
|
||||
raycaster.firstHitOnly = true;
|
||||
const hits = raycaster.intersectObjects(objects, false);
|
||||
let hit = hits
|
||||
// Check feasibility
|
||||
.filter((hit: Intersection<Object3D>) => {
|
||||
@@ -106,7 +129,7 @@ let selectionListener = (event: MouseEvent) => {
|
||||
(isFace && selectFilter.value === '(F)aces') ||
|
||||
(isEdge && selectFilter.value === '(E)dges') ||
|
||||
(isVertex && selectFilter.value === '(V)ertices');
|
||||
return (!isFace || hit.object.visible) && !hit.object.userData.noHit && kindOk;
|
||||
return (!isFace || hit.object.visible) && kindOk;
|
||||
})
|
||||
// Sort for highlighting partially hidden edges/vertices
|
||||
.sort((a, b) => {
|
||||
@@ -123,17 +146,19 @@ let selectionListener = (event: MouseEvent) => {
|
||||
})
|
||||
// Return the best hit
|
||||
[0] as Intersection<MObject3D> | undefined;
|
||||
// console.log('Hit', hit)
|
||||
|
||||
if (!highlightNextSelection.value[0]) {
|
||||
// If we are selecting, toggle the selection or deselect all if no hit
|
||||
if (hit) {
|
||||
let selInfo: SelectionInfo | null = null;
|
||||
if (hit) selInfo = hitToSelectionInfo(hit);
|
||||
//console.log('Hit', hit, 'SelInfo', selInfo);
|
||||
if (hit && selInfo !== null) {
|
||||
// Toggle selection
|
||||
const wasSelected = selected.value.find((m) => m.object.name === hit?.object?.name) !== undefined;
|
||||
const wasSelected = selected.value.find((m) => m.getKey() === selInfo.getKey()) !== undefined;
|
||||
if (wasSelected) {
|
||||
deselect(hit)
|
||||
deselect(selInfo)
|
||||
} else {
|
||||
select(hit)
|
||||
select(selInfo)
|
||||
}
|
||||
} else {
|
||||
deselectAll();
|
||||
@@ -149,34 +174,22 @@ let selectionListener = (event: MouseEvent) => {
|
||||
scene.queueRender() // Force rerender of model-viewer
|
||||
}
|
||||
|
||||
function select(hit: Intersection<MObject3D>) {
|
||||
// console.log('Selecting', hit.object.name)
|
||||
if (selected.value.find((m) => m.object.name === hit.object.name) === undefined) {
|
||||
selected.value.push(hit);
|
||||
function select(selInfo: SelectionInfo) {
|
||||
// console.log('Selecting', selInfo.object.name)
|
||||
if (selected.value.find((m) => m.getKey() === selInfo.getKey()) === undefined) {
|
||||
selected.value.push(selInfo);
|
||||
}
|
||||
hit.object.material.__prevBaseColorFactor = [
|
||||
hit.object.material.color.r,
|
||||
hit.object.material.color.g,
|
||||
hit.object.material.color.b,
|
||||
];
|
||||
hit.object.material.color.r = 1;
|
||||
hit.object.material.color.g = 0;
|
||||
hit.object.material.color.b = 0;
|
||||
highlight(selInfo);
|
||||
}
|
||||
|
||||
function deselect(hit: Intersection<MObject3D>, alsoRemove = true) {
|
||||
// console.log('Deselecting', hit.object.name)
|
||||
function deselect(selInfo: SelectionInfo, alsoRemove = true) {
|
||||
// console.log('Deselecting', selInfo.object.name)
|
||||
if (alsoRemove) {
|
||||
// Remove the matching object from the selection
|
||||
let toRemove = selected.value.findIndex((m) => m.object.name === hit.object.name);
|
||||
let toRemove = selected.value.findIndex((m) => m.getKey() === selInfo.getKey());
|
||||
selected.value.splice(toRemove, 1);
|
||||
}
|
||||
if (hit.object.material.__prevBaseColorFactor) {
|
||||
hit.object.material.color.r = hit.object.material.__prevBaseColorFactor[0]
|
||||
hit.object.material.color.g = hit.object.material.__prevBaseColorFactor[1]
|
||||
hit.object.material.color.b = hit.object.material.__prevBaseColorFactor[2]
|
||||
delete hit.object.material.__prevBaseColorFactor;
|
||||
}
|
||||
highlightUndo(selInfo);
|
||||
}
|
||||
|
||||
function deselectAll(alsoRemove = true) {
|
||||
@@ -273,9 +286,8 @@ function updateBoundingBox() {
|
||||
if (selected.value.length > 0) {
|
||||
bb = new Box3();
|
||||
for (let hit of selected.value) {
|
||||
bb.expandByObject(hit.object);
|
||||
bb.union(hit.getBox())
|
||||
}
|
||||
bb.applyMatrix4(new Matrix4().makeTranslation(props.viewer?.scene.getTarget()));
|
||||
} else {
|
||||
let boundingBox = SceneMgr.getBoundingBox(sceneDocument.value);
|
||||
if (!boundingBox) return; // No models. Should not happen.
|
||||
@@ -380,9 +392,7 @@ function updateDistances() {
|
||||
}
|
||||
|
||||
// Add lines (if not already added)
|
||||
let objA = selected.value[0].object;
|
||||
let objB = selected.value[1].object;
|
||||
let {min, center, max} = distances(objA, objB, props.viewer?.scene);
|
||||
let {min, center, max} = distances(selected.value[0], selected.value[1], props.viewer?.scene);
|
||||
ensureLine(max[0], max[1], max[1].distanceTo(max[0]).toFixed(1) + "mm", "orange");
|
||||
ensureLine(center[0], center[1], center[1].distanceTo(center[0]).toFixed(1) + "mm", "green");
|
||||
ensureLine(min[0], min[1], min[1].distanceTo(min[0]).toFixed(1) + "mm", "cyan");
|
||||
|
||||
@@ -16,11 +16,11 @@ import {OrthographicCamera} from "three/src/cameras/OrthographicCamera.js";
|
||||
import {mdiClose, mdiCrosshairsGps, mdiDownload, mdiGithub, mdiLicense, mdiProjector} from '@mdi/js'
|
||||
import SvgIcon from '@jamescoyle/vue-icon';
|
||||
import type {ModelViewerElement} from '@google/model-viewer';
|
||||
import type {Intersection} from "three";
|
||||
import type {MObject3D} from "./Selection.vue";
|
||||
import Loading from "../misc/Loading.vue";
|
||||
import type ModelViewerWrapper from "../viewer/ModelViewerWrapper.vue";
|
||||
import {defineAsyncComponent, type Ref, ref} from "vue";
|
||||
import type {SelectionInfo} from "./selection";
|
||||
|
||||
const SelectionComponent = defineAsyncComponent({
|
||||
loader: () => import("./Selection.vue"),
|
||||
@@ -39,10 +39,10 @@ const LicensesDialogContent = defineAsyncComponent({
|
||||
let props = defineProps<{ viewer: InstanceType<typeof ModelViewerWrapper> | null }>();
|
||||
const emit = defineEmits<{ findModel: [string] }>()
|
||||
|
||||
let selection: Ref<Array<Intersection<MObject3D>>> = ref([]);
|
||||
let selectionFaceCount = () => selection.value.filter((s) => s.object.type == "Mesh" || s.object.type == "SkinnedMesh").length
|
||||
let selectionEdgeCount = () => selection.value.filter((s) => s.object.type == "Line").length
|
||||
let selectionVertexCount = () => selection.value.filter((s) => s.object.type == "Points").length
|
||||
let selection: Ref<Array<SelectionInfo>> = ref([]);
|
||||
let selectionFaceCount = () => selection.value.filter((s) => s.kind == 'face').length
|
||||
let selectionEdgeCount = () => selection.value.filter((s) => s.kind == 'edge').length
|
||||
let selectionVertexCount = () => selection.value.filter((s) => s.kind == "vertex").length
|
||||
|
||||
function syncOrthoCamera(force: boolean) {
|
||||
let scene = props.viewer?.scene;
|
||||
|
||||
151
frontend/tools/selection.ts
Normal file
151
frontend/tools/selection.ts
Normal 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()];
|
||||
}
|
||||
Reference in New Issue
Block a user