Ability to explode models and minor improvements

This commit is contained in:
Yeicor
2025-08-04 17:47:22 +02:00
parent 021cfd89a1
commit e0503983f1
5 changed files with 115 additions and 15 deletions

View File

@@ -10,7 +10,7 @@ in a web browser.
- All [GLTF 2.0](https://www.khronos.org/gltf/) features (textures, PBR materials, animations...). - All [GLTF 2.0](https://www.khronos.org/gltf/) features (textures, PBR materials, animations...).
- All [model-viewer](https://modelviewer.dev/) features (smooth controls, augmented reality...). - All [model-viewer](https://modelviewer.dev/) features (smooth controls, augmented reality...).
- Load multiple models at once, load external models and even images as quads. - Load multiple models at once, load external models and even images as quads.
- Control clipping planes and transparency of each model. - Control clipping planes, transparency, edge/vertex sizes and explode each model.
- View and interact with topological entities: faces, edges, vertices and locations. - View and interact with topological entities: faces, edges, vertices and locations.
- Select any entity and measure bounding box size and distances. - Select any entity and measure bounding box size and distances.
- Hot reloading while editing the CAD model (using the `yacv-server` package). - Hot reloading while editing the CAD model (using the `yacv-server` package).
@@ -23,9 +23,9 @@ in a web browser.
The [example](example) is a fully working project that shows how to use the viewer. The [example](example) is a fully working project that shows how to use the viewer.
You can play with the latest You can play with the latest
demo [here](https://yeicor-3d.github.io/yet-another-cad-viewer/?preload=logo.glb&preload=logo_hl.glb&preload=logo_hl_tex.glb&preload=fox.glb&preload=img.jpg.glb&preload=location.glb) demo [here](https://yeicor-3d.github.io/yet-another-cad-viewer/?preload=logo.glb&preload=logo_hl.glb&preload=logo_hl_tex.glb&preload=fox.glb&preload=img.glb&preload=location.glb)
(or (or
[without animation](https://yeicor-3d.github.io/yet-another-cad-viewer/?autoplay=false&preload=logo.glb&preload=logo_hl.glb&preload=logo_hl_tex.glb&preload=fox.glb&preload=img.jpg.glb&preload=location.glb)). [without animation](https://yeicor-3d.github.io/yet-another-cad-viewer/?autoplay=false&preload=logo.glb&preload=logo_hl.glb&preload=logo_hl_tex.glb&preload=fox.glb&preload=img.glb&preload=location.glb)).
![Demo](assets/screenshot.png) ![Demo](assets/screenshot.png)
@@ -36,3 +36,6 @@ demo [here](https://yeicor-3d.github.io/yet-another-cad-viewer/?preload=logo.glb
Uses the same backend and frontend behind the scenes. Uses the same backend and frontend behind the scenes.
- [build123d-docker](https://github.com/derhuerst/build123d-docker/pkgs/container/build123d) provides docker images for - [build123d-docker](https://github.com/derhuerst/build123d-docker/pkgs/container/build123d) provides docker images for
Yet Another CAD Viewer and other projects, with automatic updates. Yet Another CAD Viewer and other projects, with automatic updates.
- [OCP.wasm](https://github.com/yeicor/OCP.wasm/) ports OCP (OpenCASCADE for Python) and supporting libraries to
WebAssembly, enabling full in-browser CAD model generation and manipulation. This powers the build123d playground
provided by this viewer.

View File

@@ -109,6 +109,12 @@ async function loadModelManual() {
if (modelUrl) await networkMgr.load(modelUrl); if (modelUrl) await networkMgr.load(modelUrl);
} }
function loadDemoModels() {
for (let name of ['fox.glb', 'img.glb', 'location.glb', 'logo.glb', 'logo_hl.glb', 'logo_hl_tex.glb']) {
networkMgr.load(`https://yeicor-3d.github.io/yet-another-cad-viewer/${name}`)
}
}
// Detect dropped .glb files and load them manually // Detect dropped .glb files and load them manually
document.body.addEventListener("dragover", e => { document.body.addEventListener("dragover", e => {
e.preventDefault(); // Allow drop e.preventDefault(); // Allow drop
@@ -124,7 +130,6 @@ document.body.addEventListener("drop", async e => {
await networkMgr.load(file); await networkMgr.load(file);
} }
}); });
</script> </script>
<template> <template>
@@ -139,9 +144,8 @@ document.body.addEventListener("drop", async e => {
<v-btn @click="() => tools?.openPlayground()" class="mx-auto d-block my-4"> <v-btn @click="() => tools?.openPlayground()" class="mx-auto d-block my-4">
<svg-icon :path="mdiScriptTextPlay" type="mdi"/>&nbsp; Open playground... <svg-icon :path="mdiScriptTextPlay" type="mdi"/>&nbsp; Open playground...
</v-btn> </v-btn>
<v-btn @click="networkMgr.load('https://yeicor-3d.github.io/yet-another-cad-viewer/logo.glb')" <v-btn @click="loadDemoModels" class="mx-auto d-block my-4">
class="mx-auto d-block my-4"> <svg-icon :path="mdiCube" type="mdi"/>&nbsp; Load demo models...
<svg-icon :path="mdiCube" type="mdi"/>&nbsp; Load demo model...
</v-btn> </v-btn>
<v-btn @click="loadModelManual" class="mx-auto d-block my-4"> <v-btn @click="loadModelManual" class="mx-auto d-block my-4">
<svg-icon :path="mdiPlus" type="mdi"/>&nbsp; Load model manually... <svg-icon :path="mdiPlus" type="mdi"/>&nbsp; Load model manually...

View File

@@ -14,7 +14,7 @@ export const settings = (async () => {
// @ts-ignore // @ts-ignore
// new URL('../../assets/logo_build/location.glb', import.meta.url).href, // new URL('../../assets/logo_build/location.glb', import.meta.url).href,
// @ts-ignore // @ts-ignore
// new URL('../../assets/logo_build/img.jpg.glb', import.meta.url).href, // new URL('../../assets/logo_build/img.glb', import.meta.url).href,
// Websocket URLs automatically listen for new models from the python backend // Websocket URLs automatically listen for new models from the python backend
'<auto>', // Get the default preload URL if not overridden '<auto>', // Get the default preload URL if not overridden
], ],

View File

@@ -13,9 +13,10 @@ import {
} from "vuetify/lib/components/index.mjs"; } from "vuetify/lib/components/index.mjs";
import {extrasNameKey, extrasNameValueHelpers} from "../misc/gltf"; import {extrasNameKey, extrasNameValueHelpers} from "../misc/gltf";
import {Mesh} from "@gltf-transform/core"; import {Mesh} from "@gltf-transform/core";
import {ref, watch} from "vue"; import {nextTick, ref, watch} from "vue";
import type ModelViewerWrapper from "../viewer/ModelViewerWrapper.vue"; import type ModelViewerWrapper from "../viewer/ModelViewerWrapper.vue";
import { import {
mdiArrowExpand,
mdiCircleOpacity, mdiCircleOpacity,
mdiCube, mdiCube,
mdiDelete, mdiDelete,
@@ -58,7 +59,12 @@ const clipPlaneY = ref(1);
const clipPlaneSwappedY = ref(false); const clipPlaneSwappedY = ref(false);
const clipPlaneZ = ref(1); const clipPlaneZ = ref(1);
const clipPlaneSwappedZ = ref(false); const clipPlaneSwappedZ = ref(false);
const edgeWidth = ref(0); const edgeWidth = ref(0);
const explodeStrength = ref(0);
const explodeSwapped = ref(false);
// Load the settings for the default edge width
(async () => { (async () => {
let s = await settings; let s = await settings;
edgeWidth.value = s.edgeWidth; edgeWidth.value = s.edgeWidth;
@@ -246,6 +252,76 @@ function onEdgeWidthChange(newEdgeWidth: number) {
watch(edgeWidth, onEdgeWidthChange); watch(edgeWidth, onEdgeWidthChange);
// Explode the model
function onExplodeChange(newExplodeStrength: number) {
let scene = props.viewer?.scene;
let sceneModel = (scene as any)?._model;
if (!scene || !sceneModel) return;
// Get direction and size of the explosion in a first pass
const meBbox = new Box3();
const othersBbox = new Box3();
sceneModel.traverse((child: MObject3D) => {
if (child == sceneModel) return; // Skip the scene itself
const isMe = child.userData[extrasNameKey] === modelName;
if ((child.type === 'Mesh' || child.type === 'SkinnedMesh' ||
child.type === 'Line' || child.type === 'LineSegments' ||
child.type === 'Points') && !child.userData.noHit) {
if (isMe) {
meBbox.expandByObject(child);
} else if (!isMe && child.userData[extrasNameKey]) {
othersBbox.expandByObject(child);
}
}
});
const modelSize = new Vector3();
meBbox.getSize(modelSize);
const maxDimension = Math.max(modelSize.x, modelSize.y, modelSize.z);
const pushDirection = new Vector3().subVectors(meBbox.getCenter(new Vector3()), othersBbox.getCenter(new Vector3())).normalize();
// Use absolute value for strength calculation
let strength = Math.abs(newExplodeStrength);
if (explodeSwapped.value) strength = -strength;
// Apply explosion
sceneModel.traverse((child: MObject3D) => {
if (child.userData[extrasNameKey] === modelName) {
if ((child.type === 'Mesh' || child.type === 'SkinnedMesh' ||
child.type === 'Line' || child.type === 'LineSegments' ||
child.type === 'Points')) {
// Handle zero vector case (if object is at origin)
const direction = pushDirection.clone();
if (direction.lengthSq() < 0.0001) {
direction.set(0, 1, 0);
console.warn("Explode direction was zero, using (0, 1, 0) instead");
}
// Calculate new position based on model size
const factor = strength * maxDimension;
const newPosition = new Vector3().add(direction.multiplyScalar(factor));
// Apply new position
child.position.copy(newPosition);
// Update related objects (back is automatically updated)
if (child.userData.niceLine) {
child.userData.niceLine.position.copy(newPosition);
}
}
}
});
scene.queueRender();
onClipPlanesChange();
}
// Add watchers for explode variables
watch(explodeStrength, (newVal) => onExplodeChange(newVal));
watch(explodeSwapped, () => onExplodeChange(explodeStrength.value));
function onModelLoad() { function onModelLoad() {
let scene = props.viewer?.scene; let scene = props.viewer?.scene;
let sceneModel = (scene as any)?._model; let sceneModel = (scene as any)?._model;
@@ -312,15 +388,17 @@ function onModelLoad() {
// 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 // Wireframe may have been reset after a reload
onWireframeChange(wireframe.value) onWireframeChange(wireframe.value);
// Clip planes may have been reset after a reload // Clip planes may have been reset after a reload
onClipPlanesChange() onClipPlanesChange();
// Edge width may have been reset after a reload // Edge width may have been reset after a reload
onEdgeWidthChange(edgeWidth.value) onEdgeWidthChange(edgeWidth.value);
// Explode may have been reset after a reload
if (explodeStrength.value > 0) nextTick(() => onExplodeChange(explodeStrength.value));
scene.queueRender() scene.queueRender()
} }
@@ -370,6 +448,21 @@ if (props.viewer) onViewerReady(props.viewer); else watch((() => props.viewer) a
<v-checkbox-btn v-model="wireframe" falseIcon="mdi-triangle" trueIcon="mdi-triangle-outline"></v-checkbox-btn> <v-checkbox-btn v-model="wireframe" falseIcon="mdi-triangle" trueIcon="mdi-triangle-outline"></v-checkbox-btn>
</template> </template>
</v-slider> </v-slider>
<v-slider v-model="explodeStrength" hide-details max="1" min="0">
<template v-slot:prepend>
<v-tooltip activator="parent">Explode model</v-tooltip>
<svg-icon :path="mdiArrowExpand" type="mdi"></svg-icon>
</template>
<template v-slot:append>
<v-tooltip activator="parent">Swap explode direction (may go crazy)</v-tooltip>
<v-checkbox-btn v-model="explodeSwapped" falseIcon="mdi-checkbox-blank-outline"
trueIcon="mdi-checkbox-marked-outline">
<template v-slot:label>
<svg-icon :path="mdiSwapHorizontal" type="mdi"></svg-icon>
</template>
</v-checkbox-btn>
</template>
</v-slider>
<v-slider v-if="edgeCount > 0 || vertexCount > 0" v-model="edgeWidth" hide-details max="1" min="0"> <v-slider v-if="edgeCount > 0 || vertexCount > 0" v-model="edgeWidth" hide-details max="1" min="0">
<template v-slot:prepend> <template v-slot:prepend>
<v-tooltip activator="parent">Edge and vertex sizes</v-tooltip> <v-tooltip activator="parent">Edge and vertex sizes</v-tooltip>

View File

@@ -117,7 +117,7 @@ def image_to_gltf(source: str | bytes, center: any, width: Optional[float] = Non
# Handle arguments # Handle arguments
if name is None: if name is None:
if isinstance(source, str): if isinstance(source, str):
name = os.path.basename(source) name, _ = os.path.splitext(os.path.basename(source))
else: else:
hasher = hashlib.md5() hasher = hashlib.md5()
hasher.update(source) hasher.update(source)