diff --git a/assets/licenses.txt b/assets/licenses.txt index 8f6b074..b98c68d 100644 --- a/assets/licenses.txt +++ b/assets/licenses.txt @@ -2439,7 +2439,7 @@ THE SOFTWARE. The following npm package may be included in this product: - - vuetify@3.5.11 + - vuetify@3.5.13 This package contains the following license and notice below: diff --git a/example/object.py b/example/object.py index b177132..90fc324 100644 --- a/example/object.py +++ b/example/object.py @@ -13,7 +13,7 @@ from yacv_server import show, export_all # Check out other exported methods for # Create a simple object with BuildPart() as example: Box(10, 10, 5) - Cylinder(4, 5, mode=Mode.SUBTRACT) + Cylinder(3, 5, mode=Mode.SUBTRACT) # Show it in the frontend with hot-reloading show(example) diff --git a/frontend/App.vue b/frontend/App.vue index b8c856b..b41e4ad 100644 --- a/frontend/App.vue +++ b/frontend/App.vue @@ -7,7 +7,7 @@ import Tools from "./tools/Tools.vue"; import Models from "./models/Models.vue"; import {VBtn, VLayout, VMain, VToolbarTitle} from "vuetify/lib/components/index.mjs"; import {settings} from "./misc/settings"; -import {NetworkManager, NetworkUpdateEvent} from "./misc/network"; +import {NetworkManager, NetworkUpdateEvent, NetworkUpdateEventModel} from "./misc/network"; import {SceneMgr} from "./misc/scene"; import {Document} from "@gltf-transform/core"; import type ModelViewerWrapperT from "./viewer/ModelViewerWrapper.vue"; @@ -28,6 +28,7 @@ const viewer: Ref | null> = ref(null); const sceneDocument = shallowRef(new Document()); provide('sceneDocument', {sceneDocument}); const models: Ref | null> = ref(null) +const tools: Ref | null> = ref(null) const disableTap = ref(false); const setDisableTap = (val: boolean) => disableTap.value = val; provide('disableTap', {disableTap, setDisableTap}); @@ -49,6 +50,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]; + tools.value?.removeObjectSelections(model.name); try { if (!model.isRemove) { doc = await SceneMgr.loadModel(sceneUrl, doc, model.name, model.url, isLast && settings.loadHelpers, isLast); @@ -68,8 +70,8 @@ async function onModelUpdateRequest(event: NetworkUpdateEvent) { } async function onModelRemoveRequest(name: string) { - sceneDocument.value = await SceneMgr.removeModel(sceneUrl, sceneDocument.value, name); - triggerRef(sceneDocument); // Why not triggered automatically? + await onModelUpdateRequest(new NetworkUpdateEvent([new NetworkUpdateEventModel(name, "", null, true)], () => { + })); } // Set up the load model event listener @@ -114,7 +116,7 @@ async function loadModelManual() { - + diff --git a/frontend/misc/distances.ts b/frontend/misc/distances.ts index 8af2e69..96f815f 100644 --- a/frontend/misc/distances.ts +++ b/frontend/misc/distances.ts @@ -8,7 +8,6 @@ function getCenterAndVertexList(selInfo: SelectionInfo, scene: ModelScene): { center: Vector3, vertices: Array } { - selInfo.object.updateMatrixWorld(); let pos: BufferAttribute | InterleavedBufferAttribute = selInfo.object.geometry.getAttribute('position'); let ind: BufferAttribute | null = selInfo.object.geometry.index; if (ind === null) { diff --git a/frontend/misc/network.ts b/frontend/misc/network.ts index 3b04e3a..78edde9 100644 --- a/frontend/misc/network.ts +++ b/frontend/misc/network.ts @@ -2,7 +2,7 @@ import {settings} from "./settings"; const batchTimeout = 250; // ms -class NetworkUpdateEventModel { +export class NetworkUpdateEventModel { name: string; url: string; // TODO: Detect and manage instances of the same object (same hash, different name) diff --git a/frontend/models/Model.vue b/frontend/models/Model.vue index 6d94114..bed4354 100644 --- a/frontend/models/Model.vue +++ b/frontend/models/Model.vue @@ -12,8 +12,8 @@ import { VTooltip, } from "vuetify/lib/components/index.mjs"; import {extrasNameKey, extrasNameValueHelpers} from "../misc/gltf"; -import {Document, Mesh} from "@gltf-transform/core"; -import {inject, ref, type ShallowRef, watch} from "vue"; +import {Mesh} from "@gltf-transform/core"; +import {ref, watch} from "vue"; import type ModelViewerWrapper from "../viewer/ModelViewerWrapper.vue"; import { mdiCircleOpacity, @@ -85,7 +85,7 @@ function onEnabledFeaturesChange(newEnabledFeatures: Array) { scene.queueRender() } -watch(enabledFeatures, onEnabledFeaturesChange); +watch(enabledFeatures, onEnabledFeaturesChange, {deep: true}); function onOpacityChange(newOpacity: number) { let scene = props.viewer?.scene; @@ -122,8 +122,6 @@ function onWireframeChange(newWireframe: boolean) { watch(wireframe, onWireframeChange); -let {sceneDocument} = inject<{ sceneDocument: ShallowRef }>('sceneDocument')!!; - function onClipPlanesChange() { let scene = props.viewer?.scene; let sceneModel = (scene as any)?._model; @@ -245,9 +243,35 @@ function onModelLoad() { let sceneModel = (scene as any)?._model; if (!scene || !sceneModel) return; + // Count the number of faces, edges and vertices + const isFirstLoad = faceCount.value === -1; + faceCount.value = props.meshes + .flatMap((m) => m.listPrimitives().filter(p => p.getMode() === WebGL2RenderingContext.TRIANGLES)) + .map(p => (p.getExtras()?.face_triangles_end as any)?.length ?? 1) + .reduce((a, b) => a + b, 0) + edgeCount.value = props.meshes + .flatMap((m) => m.listPrimitives().filter(p => p.getMode() in [WebGL2RenderingContext.LINE_STRIP, WebGL2RenderingContext.LINES])) + .map(p => (p.getExtras()?.edge_points_end as any)?.length ?? 0) + .reduce((a, b) => a + b, 0) + vertexCount.value = props.meshes + .flatMap((m) => m.listPrimitives().filter(p => p.getMode() === WebGL2RenderingContext.POINTS)) + .map(p => (p.getAttribute("POSITION")?.getCount() ?? 0)) + .reduce((a, b) => a + b, 0) + + // First time: set the enabled features to all provided features + if (isFirstLoad) { + if (faceCount.value === 0) enabledFeatures.value = enabledFeatures.value.filter((f) => f !== 0) + else if (!enabledFeatures.value.includes(0)) enabledFeatures.value.push(0) + if (edgeCount.value === 0) enabledFeatures.value = enabledFeatures.value.filter((f) => f !== 1) + else if (!enabledFeatures.value.includes(1)) enabledFeatures.value.push(1) + if (vertexCount.value === 0) enabledFeatures.value = enabledFeatures.value.filter((f) => f !== 2) + else if (!enabledFeatures.value.includes(2)) enabledFeatures.value.push(2) + } + // Add darkened back faces for all face objects to improve cutting planes let childrenToAdd: Array = []; sceneModel.traverse((child: MObject3D) => { + child.updateMatrixWorld(); // Objects are mostly static, so ensure updated matrices if (child.userData[extrasNameKey] === modelName) { if (child.type == 'Mesh' || child.type == 'SkinnedMesh') { // Compute a BVH for faster raycasting (MUCH faster selection) @@ -278,28 +302,6 @@ function onModelLoad() { }); childrenToAdd.forEach((child: MObject3D) => sceneModel.add(child)); - // Count the number of faces, edges and vertices - faceCount.value = props.meshes - .flatMap((m) => m.listPrimitives().filter(p => p.getMode() === WebGL2RenderingContext.TRIANGLES)) - .map(p => (p.getExtras()?.face_triangles_end as any)?.length ?? 1) - .reduce((a, b) => a + b, 0) - edgeCount.value = props.meshes - .flatMap((m) => m.listPrimitives().filter(p => p.getMode() in [WebGL2RenderingContext.LINE_STRIP, WebGL2RenderingContext.LINES])) - .map(p => (p.getExtras()?.edge_points_end as any)?.length ?? 0) - .reduce((a, b) => a + b, 0) - vertexCount.value = props.meshes - .flatMap((m) => m.listPrimitives().filter(p => p.getMode() === WebGL2RenderingContext.POINTS)) - .map(p => (p.getAttribute("POSITION")?.getCount() ?? 0)) - .reduce((a, b) => a + b, 0) - - // Set the enabled features to all provided features - if (faceCount.value === 0) enabledFeatures.value = enabledFeatures.value.filter((f) => f !== 0) - else if (!enabledFeatures.value.includes(0)) enabledFeatures.value.push(0) - if (edgeCount.value === 0) enabledFeatures.value = enabledFeatures.value.filter((f) => f !== 1) - else if (!enabledFeatures.value.includes(1)) enabledFeatures.value.push(1) - if (vertexCount.value === 0) enabledFeatures.value = enabledFeatures.value.filter((f) => f !== 2) - else if (!enabledFeatures.value.includes(2)) enabledFeatures.value.push(2) - // Furthermore... // Enabled features may have been reset after a reload onEnabledFeaturesChange(enabledFeatures.value) diff --git a/frontend/tools/Selection.vue b/frontend/tools/Selection.vue index af06cb7..c60ed0d 100644 --- a/frontend/tools/Selection.vue +++ b/frontend/tools/Selection.vue @@ -33,21 +33,23 @@ let showBoundingBox = ref(false); // Enabled automatically on start let showDistances = ref(true); let mouseDownAt: [number, number] | null = null; +let mouseDownTime = 0; let selectFilter = ref('Any (S)'); const raycaster = new Raycaster(); -let selectionMoveListener = (event: MouseEvent) => { +let mouseDownListener = (event: MouseEvent) => { mouseDownAt = [event.clientX, event.clientY]; + mouseDownTime = performance.now(); if (!selectionEnabled.value) return; }; -let selectionListener = (event: MouseEvent) => { +let mouseUpListener = (event: MouseEvent) => { // If the mouse moved while clicked (dragging), avoid selection logic if (mouseDownAt) { let [x, y] = mouseDownAt; mouseDownAt = null; - 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 || performance.now() - mouseDownTime > 500) { return; } } @@ -254,14 +256,29 @@ let onViewerReady = (viewer: typeof ModelViewerWrapperT) => { viewer.onElemReady((elem: ModelViewerElement) => { if (hasListeners) return; hasListeners = true; - elem.addEventListener('mouseup', selectionListener); - elem.addEventListener('mousedown', selectionMoveListener); // Avoid clicking when dragging + elem.addEventListener('mousedown', mouseDownListener); // Avoid clicking when dragging + elem.addEventListener('mouseup', mouseUpListener); elem.addEventListener('load', () => { + // After a reload of the scene, we need to recover object references and highlight them again + for (let sel of selected.value) { + let scene = props.viewer?.scene; + if (!scene) continue; + let foundObject = null; + scene.traverse((obj: MObject3D) => { + if (sel.matches(obj)) { + foundObject = obj as MObject3D; + } + }); + if (foundObject) { + sel.object = foundObject; + highlight(sel); + } else { + selected.value = selected.value.filter((m) => m.getKey() !== sel.getKey()); + } + } if (firstLoad) { toggleShowBoundingBox(); firstLoad = false; - } else { - updateBoundingBox(); } }); elem.addEventListener('camera-change', onCameraChange); @@ -406,6 +423,8 @@ function updateDistances() { return; } +defineExpose({deselect, updateBoundingBox, updateDistances}); + // Add keyboard shortcuts window.addEventListener('keydown', (event) => { if (event.key === 's') { diff --git a/frontend/tools/Tools.vue b/frontend/tools/Tools.vue index 48bf1b5..99fcf23 100644 --- a/frontend/tools/Tools.vue +++ b/frontend/tools/Tools.vue @@ -19,7 +19,7 @@ import type {ModelViewerElement} from '@google/model-viewer'; 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 {defineAsyncComponent, ref, type Ref} from "vue"; import type {SelectionInfo} from "./selection"; const SelectionComponent = defineAsyncComponent({ @@ -107,6 +107,15 @@ async function openGithub() { window.open('https://github.com/yeicor-3d/yet-another-cad-viewer', '_blank') } +function removeObjectSelections(objName: string) { + for (let selInfo of selection.value.filter((s) => s.getObjectName() === objName)) { + selectionComp.value?.deselect(selInfo); + } + selectionComp.value?.updateBoundingBox(); + selectionComp.value?.updateDistances(); +} + +defineExpose({removeObjectSelections}); // Add keyboard shortcuts window.addEventListener('keydown', (event) => { @@ -133,7 +142,7 @@ window.addEventListener('keydown', (event) => {
Selection ({{ selectionFaceCount() }}F {{ selectionEdgeCount() }}E {{ selectionVertexCount() }}V)
- @@ -187,4 +196,8 @@ window.addEventListener('keydown', (event) => { position: relative; top: 5px; } + +h5 { + font-size: 14px; +} \ No newline at end of file diff --git a/frontend/tools/selection.ts b/frontend/tools/selection.ts index fde1769..e86ac60 100644 --- a/frontend/tools/selection.ts +++ b/frontend/tools/selection.ts @@ -3,6 +3,7 @@ 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 { @@ -19,6 +20,17 @@ export class SelectionInfo { this.indices = indices; } + 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 getKey() { return this.object.uuid + this.kind + this.indices[0].toFixed() + this.indices[1].toFixed(); } diff --git a/vite.config.ts b/vite.config.ts index 940a46a..081facb 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -26,9 +26,10 @@ export default defineConfig({ } }, build: { - assetsDir: '.', + assetsDir: '.', // Support deploying to a subdirectory using relative URLs cssCodeSplit: false, // Small enough to inline chunkSizeWarningLimit: 550, // Three.js is big. Draco is even bigger but not likely to be used. + sourcemap: true, // For debugging production }, define: { __APP_NAME__: JSON.stringify(name), diff --git a/yacv_server/cad.py b/yacv_server/cad.py index 9529f2f..3a6a23b 100644 --- a/yacv_server/cad.py +++ b/yacv_server/cad.py @@ -2,9 +2,13 @@ Utilities to work with CAD objects """ import hashlib +import io +import re from typing import Optional, Union, Tuple +from OCP.TopExp import TopExp from OCP.TopLoc import TopLoc_Location +from OCP.TopTools import TopTools_IndexedMapOfShape from OCP.TopoDS import TopoDS_Shape from build123d import Compound, Shape @@ -14,7 +18,7 @@ CADCoreLike = Union[TopoDS_Shape, TopLoc_Location] # Faces, Edges, Vertices and CADLike = Union[CADCoreLike, any] # build123d and cadquery types -def get_shape(obj: CADLike, error: bool = True, in_iter: bool = False) -> Optional[CADCoreLike]: +def get_shape(obj: CADLike, error: bool = True) -> Optional[CADCoreLike]: """ Get the shape of a CAD-like object """ # Try to grab a shape if a different type of object was passed @@ -42,13 +46,22 @@ def get_shape(obj: CADLike, error: bool = True, in_iter: bool = False) -> Option return obj # Handle iterables like Build123d ShapeList by extracting all sub-shapes and making a compound - if not in_iter: + if isinstance(obj, list) or isinstance(obj, tuple) or isinstance(obj, set) or isinstance(obj, dict): try: - obj_iter = iter(obj) + if isinstance(obj, dict): + obj_iter = iter(obj.values()) + else: + obj_iter = iter(obj) # print(obj, ' -> ', obj_iter) - shapes_raw = [get_shape(sub_obj, error=False, in_iter=True) for sub_obj in obj_iter] - shapes_bd = [Shape(shape) for shape in shapes_raw if shape is not None] - return get_shape(Compound(shapes_bd), error) + shapes_raw = [get_shape(sub_obj, error=False) for sub_obj in obj_iter] + # Silently drop non-shapes + shapes_raw_filtered = [shape for shape in shapes_raw if shape is not None] + if len(shapes_raw_filtered) > 0: # Continue if we found at least one shape + # Sorting is required to improve hashcode consistency + shapes_raw_filtered_sorted = sorted(shapes_raw_filtered, key=lambda x: _hashcode(x)) + # Build a single compound shape + shapes_bd = [Shape(shape) for shape in shapes_raw_filtered_sorted if shape is not None] + return get_shape(Compound(shapes_bd), error) except TypeError: pass @@ -149,3 +162,32 @@ def image_to_gltf(source: str | bytes, center: any, width: Optional[float] = Non # Return the GLTF binary blob and the suggested name of the image return b''.join(mgr.build().save_to_bytes()), name + + +def _hashcode(obj: Union[bytes, CADCoreLike], **extras) -> str: + """Utility to compute the STABLE hash code of a shape""" + # NOTE: obj.HashCode(MAX_HASH_CODE) is not stable across different runs of the same program + # This is best-effort and not guaranteed to be unique + hasher = hashlib.md5(usedforsecurity=False) + for k, v in extras.items(): + hasher.update(str(k).encode()) + hasher.update(str(v).encode()) + if isinstance(obj, bytes): + hasher.update(obj) + elif isinstance(obj, TopLoc_Location): + sub_data = io.BytesIO() + obj.DumpJson(sub_data) + hasher.update(sub_data.getvalue()) + elif isinstance(obj, TopoDS_Shape): + map_of_shapes = TopTools_IndexedMapOfShape() + TopExp.MapShapes_s(obj, map_of_shapes) + for i in range(1, map_of_shapes.Extent() + 1): + sub_shape = map_of_shapes.FindKey(i) + sub_data = io.BytesIO() + TopoDS_Shape.DumpJson(sub_shape, sub_data) + val = sub_data.getvalue() + val = re.sub(b'"this": "[^"]*"', b'', val) # Remove memory address + hasher.update(val) + else: + raise ValueError(f'Cannot hash object of type {type(obj)}') + return hasher.hexdigest() diff --git a/yacv_server/tessellate.py b/yacv_server/tessellate.py index d9b0a92..03ffe23 100644 --- a/yacv_server/tessellate.py +++ b/yacv_server/tessellate.py @@ -1,14 +1,9 @@ -import hashlib -import io -import re -from typing import List, Dict, Tuple, Union +from typing import List, Dict, Tuple from OCP.BRep import BRep_Tool from OCP.BRepAdaptor import BRepAdaptor_Curve from OCP.GCPnts import GCPnts_TangentialDeflection -from OCP.TopExp import TopExp from OCP.TopLoc import TopLoc_Location -from OCP.TopTools import TopTools_IndexedMapOfShape from OCP.TopoDS import TopoDS_Face, TopoDS_Edge, TopoDS_Shape, TopoDS_Vertex from build123d import Shape, Vertex, Face, Location from pygltflib import GLTF2 @@ -130,30 +125,3 @@ def _tessellate_vertex(mgr: GLTFMgr, ocp_vertex: TopoDS_Vertex, faces: List[Topo mgr.add_vertex(_push_point((c.X, c.Y, c.Z), faces)) -def _hashcode(obj: Union[bytes, TopoDS_Shape], **extras) -> str: - """Utility to compute the hash code of a shape recursively without the need to tessellate it""" - # NOTE: obj.HashCode(MAX_HASH_CODE) is not stable across different runs of the same program - # This is best-effort and not guaranteed to be unique - hasher = hashlib.md5(usedforsecurity=False) - for k, v in extras.items(): - hasher.update(str(k).encode()) - hasher.update(str(v).encode()) - if isinstance(obj, bytes): - hasher.update(obj) - elif isinstance(obj, TopLoc_Location): - sub_data = io.BytesIO() - obj.DumpJson(sub_data) - hasher.update(sub_data.getvalue()) - elif isinstance(obj, TopoDS_Shape): - map_of_shapes = TopTools_IndexedMapOfShape() - TopExp.MapShapes_s(obj, map_of_shapes) - for i in range(1, map_of_shapes.Extent() + 1): - sub_shape = map_of_shapes.FindKey(i) - sub_data = io.BytesIO() - TopoDS_Shape.DumpJson(sub_shape, sub_data) - val = sub_data.getvalue() - val = re.sub(b'"this": "[^"]*"', b'', val) # Remove memory address - hasher.update(val) - else: - raise ValueError(f'Cannot hash object of type {type(obj)}') - return hasher.hexdigest() diff --git a/yacv_server/yacv.py b/yacv_server/yacv.py index 4a10405..2a1c784 100644 --- a/yacv_server/yacv.py +++ b/yacv_server/yacv.py @@ -23,7 +23,8 @@ from yacv_server.myhttp import HTTPHandler from yacv_server.mylogger import logger from yacv_server.pubsub import BufferedPubSub from yacv_server.rwlock import RWLock -from yacv_server.tessellate import _hashcode, tessellate +from yacv_server.tessellate import tessellate +from cad import _hashcode @dataclass_json