From e0503983f1d6c0e0de79051c11960befe985d232 Mon Sep 17 00:00:00 2001
From: Yeicor <4929005+Yeicor@users.noreply.github.com>
Date: Mon, 4 Aug 2025 17:47:22 +0200
Subject: [PATCH] Ability to explode models and minor improvements
---
README.md | 9 ++--
frontend/App.vue | 12 +++--
frontend/misc/settings.ts | 2 +-
frontend/models/Model.vue | 105 +++++++++++++++++++++++++++++++++++---
yacv_server/cad.py | 2 +-
5 files changed, 115 insertions(+), 15 deletions(-)
diff --git a/README.md b/README.md
index 2ce07d3..189778a 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@ in a web browser.
- 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...).
- 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.
- Select any entity and measure bounding box size and distances.
- 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.
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
-[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)).

@@ -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.
- [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.
+- [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.
\ No newline at end of file
diff --git a/frontend/App.vue b/frontend/App.vue
index a2d560f..81775ac 100644
--- a/frontend/App.vue
+++ b/frontend/App.vue
@@ -109,6 +109,12 @@ async function loadModelManual() {
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
document.body.addEventListener("dragover", e => {
e.preventDefault(); // Allow drop
@@ -124,7 +130,6 @@ document.body.addEventListener("drop", async e => {
await networkMgr.load(file);
}
});
-
@@ -139,9 +144,8 @@ document.body.addEventListener("drop", async e => {
tools?.openPlayground()" class="mx-auto d-block my-4">
Open playground...
-
- Load demo model...
+
+ Load demo models...
Load model manually...
diff --git a/frontend/misc/settings.ts b/frontend/misc/settings.ts
index 161e0bd..903b7d6 100644
--- a/frontend/misc/settings.ts
+++ b/frontend/misc/settings.ts
@@ -14,7 +14,7 @@ export const settings = (async () => {
// @ts-ignore
// new URL('../../assets/logo_build/location.glb', import.meta.url).href,
// @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
'', // Get the default preload URL if not overridden
],
diff --git a/frontend/models/Model.vue b/frontend/models/Model.vue
index 0bba3f2..a115090 100644
--- a/frontend/models/Model.vue
+++ b/frontend/models/Model.vue
@@ -13,9 +13,10 @@ import {
} from "vuetify/lib/components/index.mjs";
import {extrasNameKey, extrasNameValueHelpers} from "../misc/gltf";
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 {
+ mdiArrowExpand,
mdiCircleOpacity,
mdiCube,
mdiDelete,
@@ -58,7 +59,12 @@ const clipPlaneY = ref(1);
const clipPlaneSwappedY = ref(false);
const clipPlaneZ = ref(1);
const clipPlaneSwappedZ = ref(false);
+
const edgeWidth = ref(0);
+const explodeStrength = ref(0);
+const explodeSwapped = ref(false);
+
+// Load the settings for the default edge width
(async () => {
let s = await settings;
edgeWidth.value = s.edgeWidth;
@@ -246,6 +252,76 @@ function onEdgeWidthChange(newEdgeWidth: number) {
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() {
let scene = props.viewer?.scene;
let sceneModel = (scene as any)?._model;
@@ -312,15 +388,17 @@ function onModelLoad() {
// Furthermore...
// Enabled features may have been reset after a reload
- onEnabledFeaturesChange(enabledFeatures.value)
+ onEnabledFeaturesChange(enabledFeatures.value);
// Opacity may have been reset after a reload
- onOpacityChange(opacity.value)
+ onOpacityChange(opacity.value);
// Wireframe may have been reset after a reload
- onWireframeChange(wireframe.value)
+ onWireframeChange(wireframe.value);
// Clip planes may have been reset after a reload
- onClipPlanesChange()
+ onClipPlanesChange();
// 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()
}
@@ -370,6 +448,21 @@ if (props.viewer) onViewerReady(props.viewer); else watch((() => props.viewer) a
+
+
+ Explode model
+
+
+
+ Swap explode direction (may go crazy)
+
+
+
+
+
+
+
Edge and vertex sizes
diff --git a/yacv_server/cad.py b/yacv_server/cad.py
index 1a464ba..81e7f60 100644
--- a/yacv_server/cad.py
+++ b/yacv_server/cad.py
@@ -117,7 +117,7 @@ def image_to_gltf(source: str | bytes, center: any, width: Optional[float] = Non
# Handle arguments
if name is None:
if isinstance(source, str):
- name = os.path.basename(source)
+ name, _ = os.path.splitext(os.path.basename(source))
else:
hasher = hashlib.md5()
hasher.update(source)