mirror of
https://github.com/yeicor-3d/yet-another-cad-viewer.git
synced 2025-12-19 22:24:17 +01:00
add support for dynamic fake 3D lines attached to the world!
This commit is contained in:
@@ -34,19 +34,21 @@ let selectionMoveListener = (event: MouseEvent) => {
|
|||||||
|
|
||||||
let selectionListener = (event: MouseEvent) => {
|
let selectionListener = (event: MouseEvent) => {
|
||||||
if (!selectionEnabled.value) {
|
if (!selectionEnabled.value) {
|
||||||
mouseDownAt = undefined;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the mouse moved while clicked (dragging), avoid selection logic
|
||||||
if (mouseDownAt) {
|
if (mouseDownAt) {
|
||||||
let [x, y] = mouseDownAt;
|
let [x, y] = mouseDownAt;
|
||||||
|
mouseDownAt = undefined;
|
||||||
if (Math.abs(event.clientX - x) > 5 || Math.abs(event.clientY - y) > 5) {
|
if (Math.abs(event.clientX - x) > 5 || Math.abs(event.clientY - y) > 5) {
|
||||||
mouseDownAt = undefined;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
mouseDownAt = undefined;
|
|
||||||
}
|
}
|
||||||
let scene: ModelScene = props.viewer?.scene;
|
|
||||||
|
// Define the 3D ray from the camera to the mouse
|
||||||
// NOTE: Need to access internal as the API has issues with small faces surrounded by edges
|
// NOTE: Need to access internal as the API has issues with small faces surrounded by edges
|
||||||
|
let scene: ModelScene = props.viewer?.scene;
|
||||||
const ndcCoords = scene.getNDC(event.clientX, event.clientY);
|
const ndcCoords = scene.getNDC(event.clientX, event.clientY);
|
||||||
//const hit = scene.hitFromPoint(ndcCoords) as Intersection<MObject3D> | undefined;
|
//const hit = scene.hitFromPoint(ndcCoords) as Intersection<MObject3D> | undefined;
|
||||||
raycaster.setFromCamera(ndcCoords, (scene as any).camera);
|
raycaster.setFromCamera(ndcCoords, (scene as any).camera);
|
||||||
@@ -56,7 +58,9 @@ let selectionListener = (event: MouseEvent) => {
|
|||||||
raycaster.ray.direction.copy(
|
raycaster.ray.direction.copy(
|
||||||
scene.getTarget().clone().add(scene.target.position).sub((scene as any).camera.position).normalize());
|
scene.getTarget().clone().add(scene.target.position).sub((scene as any).camera.position).normalize());
|
||||||
}
|
}
|
||||||
// console.log('NDC', ndcCoords, 'Camera', (scene as any).camera, 'Ray', ray_caster.ray);
|
console.log('Ray', raycaster.ray);
|
||||||
|
|
||||||
|
// Find all hit objects and select the wanted one based on the filter
|
||||||
const hits = raycaster.intersectObject(scene, true);
|
const hits = raycaster.intersectObject(scene, true);
|
||||||
let hit = hits.find((hit) => {
|
let hit = hits.find((hit) => {
|
||||||
const kind = hit.object.type
|
const kind = hit.object.type
|
||||||
@@ -66,11 +70,11 @@ let selectionListener = (event: MouseEvent) => {
|
|||||||
(kind === 'Points' && selectFilter.value === 'Vertices');
|
(kind === 'Points' && selectFilter.value === 'Vertices');
|
||||||
return hit.object.visible && !hit.object.userData.noHit && kindOk;
|
return hit.object.visible && !hit.object.userData.noHit && kindOk;
|
||||||
}) as Intersection<MObject3D> | undefined;
|
}) as Intersection<MObject3D> | undefined;
|
||||||
console.log('Hit', hit)
|
//console.log('Hit', hit)
|
||||||
|
|
||||||
if (!highlightNextSelection.value[0]) {
|
if (!highlightNextSelection.value[0]) {
|
||||||
if (!hit) {
|
// If we are selecting, toggle the selection or deselect all if no hit
|
||||||
deselectAll();
|
if (hit) {
|
||||||
} else {
|
|
||||||
// Toggle selection
|
// Toggle selection
|
||||||
const wasSelected = selected.value.find((m) => m.object.name === hit.object.name) !== undefined;
|
const wasSelected = selected.value.find((m) => m.object.name === hit.object.name) !== undefined;
|
||||||
if (wasSelected) {
|
if (wasSelected) {
|
||||||
@@ -78,6 +82,8 @@ let selectionListener = (event: MouseEvent) => {
|
|||||||
} else {
|
} else {
|
||||||
select(hit)
|
select(hit)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
deselectAll();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Otherwise, highlight the model that owns the hit
|
// Otherwise, highlight the model that owns the hit
|
||||||
|
|||||||
@@ -9,15 +9,15 @@ import Loading from "../misc/Loading.vue";
|
|||||||
import {ref} from "vue";
|
import {ref} from "vue";
|
||||||
import {ModelViewerElement} from '@google/model-viewer';
|
import {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 {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 {Vector3} from "three";
|
||||||
|
|
||||||
ModelViewerElement.modelCacheSize = 0; // Also needed to avoid tree shaking
|
ModelViewerElement.modelCacheSize = 0; // Also needed to avoid tree shaking
|
||||||
|
|
||||||
const emit = defineEmits<{ load: [] }>()
|
const emit = defineEmits<{ load: [] }>()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps<{ src: string }>();
|
||||||
src: String
|
|
||||||
});
|
|
||||||
|
|
||||||
const elem = ref<ModelViewerElement | null>(null);
|
const elem = ref<ModelViewerElement | null>(null);
|
||||||
const scene = ref<ModelScene | null>(null);
|
const scene = ref<ModelScene | null>(null);
|
||||||
@@ -25,7 +25,7 @@ const renderer = ref<Renderer | null>(null);
|
|||||||
defineExpose({elem, scene, renderer});
|
defineExpose({elem, scene, renderer});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
elem.value.addEventListener('load', () => {
|
elem.value.addEventListener('load', async () => {
|
||||||
if (elem.value) {
|
if (elem.value) {
|
||||||
// Delete the initial load banner
|
// Delete the initial load banner
|
||||||
let banner = elem.value.querySelector('.initial-load-banner');
|
let banner = elem.value.querySelector('.initial-load-banner');
|
||||||
@@ -35,13 +35,102 @@ onMounted(() => {
|
|||||||
renderer.value = elem.value[$renderer] as Renderer;
|
renderer.value = elem.value[$renderer] as Renderer;
|
||||||
// Emit the load event
|
// Emit the load event
|
||||||
emit('load')
|
emit('load')
|
||||||
|
|
||||||
|
// Test adding a fake 3D line
|
||||||
|
let lineHandle = await addLine3D(new Vector3(0, 0, 0), new Vector3(0, 100, 0), "Hello!", {
|
||||||
|
"stroke": "green",
|
||||||
|
"stroke-width": "2",
|
||||||
|
});
|
||||||
|
setTimeout(() => removeLine3D(lineHandle), 10000);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
elem.value.addEventListener('camera-change', onCameraChange);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
class Line3DData {
|
||||||
|
startHotspot: HTMLElement;
|
||||||
|
endHotspot: HTMLElement;
|
||||||
|
start2D: [number, number];
|
||||||
|
end2D: [number, number];
|
||||||
|
lineAttrs: { [key: string]: string };
|
||||||
|
centerText?: string;
|
||||||
|
centerTextSize?: [number, number]
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextLineId = 0;
|
||||||
|
let lines = ref<{ [id: number]: Line3DData }>({});
|
||||||
|
|
||||||
|
function positionToHotspot(position: Vector3): string {
|
||||||
|
return position.x + ' ' + position.y + ' ' + position.z;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addLine3D(p1: Vector3, p2: Vector3, centerText: string = "", lineAttrs: {
|
||||||
|
[key: string]: string
|
||||||
|
} = {}): Promise<number> {
|
||||||
|
if (!scene.value) return -1;
|
||||||
|
let id = nextLineId++;
|
||||||
|
let hotspotName1 = 'line' + id + '_start';
|
||||||
|
let hotspotName2 = 'line' + id + '_end';
|
||||||
|
scene.value.addHotspot(new Hotspot({name: hotspotName1, position: positionToHotspot(p1)}));
|
||||||
|
scene.value.addHotspot(new Hotspot({name: hotspotName2, position: positionToHotspot(p2)}));
|
||||||
|
lines.value[id] = {
|
||||||
|
startHotspot: elem.value.shadowRoot.querySelector('slot[name="' + hotspotName1 + '"]').parentElement,
|
||||||
|
endHotspot: elem.value.shadowRoot.querySelector('slot[name="' + hotspotName2 + '"]').parentElement,
|
||||||
|
start2D: [0, 0],
|
||||||
|
end2D: [20, 20],
|
||||||
|
centerText: centerText,
|
||||||
|
centerTextSize: [0, 0],
|
||||||
|
lineAttrs: lineAttrs
|
||||||
|
};
|
||||||
|
requestIdleCallback(() => onCameraChangeLine(id));
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLine3D(id: number) {
|
||||||
|
if (!scene.value) return;
|
||||||
|
if (lines.value[id].startHotspot) {
|
||||||
|
scene.value.removeHotspot(new Hotspot({name: 'line' + id + '_start'}));
|
||||||
|
lines.value[id].startHotspot.parentElement.remove()
|
||||||
|
}
|
||||||
|
if (lines.value[id].endHotspot) {
|
||||||
|
scene.value.removeHotspot(new Hotspot({name: 'line' + id + '_end'}));
|
||||||
|
lines.value[id].endHotspot.parentElement.remove()
|
||||||
|
}
|
||||||
|
delete lines.value[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCameraChange() {
|
||||||
|
// Need to update the SVG overlay
|
||||||
|
for (let lineId in lines.value) {
|
||||||
|
onCameraChangeLine(lineId as any);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let svg = ref<SVGElement | null>(null);
|
||||||
|
|
||||||
|
function onCameraChangeLine(lineId: number) {
|
||||||
|
// 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];
|
||||||
|
|
||||||
|
// Update the center text size if needed
|
||||||
|
if (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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- The main 3D model viewer -->
|
||||||
<model-viewer ref="elem" style="width: 100%; height: 100%" :src="props.src" alt="The 3D model(s)" camera-controls
|
<model-viewer ref="elem" style="width: 100%; height: 100%" :src="props.src" alt="The 3D model(s)" camera-controls
|
||||||
camera-orbit="30deg 75deg auto" max-camera-orbit="Infinity 180deg auto"
|
camera-orbit="30deg 75deg auto" max-camera-orbit="Infinity 180deg auto"
|
||||||
min-camera-orbit="-Infinity 0deg 5%" disable-tap :exposure="settings.exposure"
|
min-camera-orbit="-Infinity 0deg 5%" disable-tap :exposure="settings.exposure"
|
||||||
@@ -51,11 +140,23 @@ onMounted(() => {
|
|||||||
<slot></slot> <!-- Controls, annotations, etc. -->
|
<slot></slot> <!-- Controls, annotations, etc. -->
|
||||||
<loading class="annotation initial-load-banner"></loading>
|
<loading class="annotation initial-load-banner"></loading>
|
||||||
</model-viewer>
|
</model-viewer>
|
||||||
<!-- TODO: Transparent SVG overlay that can draw 2D lines attached to the 3D model(s) -->
|
|
||||||
<!-- https://modelviewer.dev/examples/annotations/index.html -->
|
<!-- The SVG overlay for fake 3D lines attached to the model -->
|
||||||
<div class="overlay-svg-wrapper">
|
<div class="overlay-svg-wrapper">
|
||||||
<svg class="overlay-svg" width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
|
<svg ref="svg" class="overlay-svg" width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
|
||||||
<!--<line x1="0" y1="0" x2="100%" y2="100%" stroke="black" stroke-width="2"/>-->
|
<g v-for="[lineId, line] in Object.entries(lines)" :key="lineId">
|
||||||
|
<line :x1="line.start2D[0]" :y1="line.start2D[1]" :x2="line.end2D[0]"
|
||||||
|
:y2="line.end2D[1]" v-bind="line.lineAttrs"/>
|
||||||
|
<rect :x="(line.start2D[0] + line.end2D[0]) / 2 - line.centerTextSize[0]/2 - 4"
|
||||||
|
:y="(line.start2D[1] + line.end2D[1]) / 2 - line.centerTextSize[1]/2 - 4"
|
||||||
|
:width="line.centerTextSize[0] + 8" :height="line.centerTextSize[1] - 4"
|
||||||
|
fill="gray" fill-opacity="0.75" rx="4" ry="4" stroke="black" v-if="line.centerText"/>
|
||||||
|
<text :x="(line.start2D[0] + line.end2D[0]) / 2" :y="(line.start2D[1] + line.end2D[1]) / 2"
|
||||||
|
text-anchor="middle" alignment-baseline="middle" font-size="16" fill="black"
|
||||||
|
:class="'line' + lineId + '_text'" v-if="line.centerText">
|
||||||
|
{{ line.centerText }}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user