diff --git a/source/website/navigator.js b/source/website/navigator.js index e5e5e6e..67673a6 100644 --- a/source/website/navigator.js +++ b/source/website/navigator.js @@ -153,6 +153,13 @@ export class Navigator FillTree (importResult) { + console.log ('[step-demo] FillTree start', { + usedFiles : importResult.usedFiles ? importResult.usedFiles.length : null, + missingFiles : importResult.missingFiles ? importResult.missingFiles.length : null + }); + this.filesPanel.Clear (); + this.materialsPanel.Clear (); + this.meshesPanel.Clear (); this.filesPanel.Fill (importResult); if (importResult.missingFiles.length === 0) { this.panelSet.SetPanelIcon (this.filesPanel, 'files'); @@ -161,6 +168,10 @@ export class Navigator } this.materialsPanel.Fill (importResult); this.meshesPanel.Fill (importResult); + console.log ('[step-demo] FillTree done', { + meshItems : this.meshesPanel.MeshItemCount (), + rootChildren : this.meshesPanel.rootItem !== null ? this.meshesPanel.rootItem.children.length : null + }); this.OnSelectionChanged (); } diff --git a/source/website/navigatormeshespanel.js b/source/website/navigatormeshespanel.js index 845ab3e..b694b0d 100644 --- a/source/website/navigatormeshespanel.js +++ b/source/website/navigatormeshespanel.js @@ -1,9 +1,9 @@ import { MeshInstanceId } from '../engine/model/meshinstance.js'; -import { AddDiv, CreateDiv, ShowDomElement, ClearDomElement, InsertDomElementBefore, SetDomElementHeight, GetDomElementOuterHeight, IsDomElementVisible } from '../engine/viewer/domutils.js'; +import { AddDiv, CreateDiv, ShowDomElement, ClearDomElement, InsertDomElementBefore, SetDomElementHeight, GetDomElementOuterHeight, IsDomElementVisible, AddButtonElement } from '../engine/viewer/domutils.js'; import { CalculatePopupPositionToElementBottomRight, ShowListPopup } from './dialogs.js'; import { MeshItem, NavigatorItemRecurse, NodeItem } from './navigatoritems.js'; import { NavigatorPanel, NavigatorPopupButton } from './navigatorpanel.js'; -import { AddSvgIconElement, GetMaterialName, GetMeshName, GetNodeName, SetSvgIconImageElement } from './utils.js'; +import { AddSvgIconElement, GetMaterialName, GetMeshName, GetNodeName, SetElementAccessibleName, SetSvgIconImageElement } from './utils.js'; import { Loc, FLoc } from '../engine/core/localization.js'; const MeshesPanelMode = @@ -30,6 +30,7 @@ class NavigatorMaterialsPopupButton extends NavigatorPopupButton let materialsText = FLoc ('Materials ({0})', this.materialInfoArray.length); this.buttonText.innerHTML = materialsText; + SetElementAccessibleName (this.button, materialsText); } OnButtonClick () @@ -110,6 +111,10 @@ export class NavigatorMeshesPanel extends NavigatorPanel Clear () { + console.log ('[step-demo] MeshesPanel.Clear', { + nodeItems : this.nodeIdToItem.size, + meshItems : this.meshInstanceIdToItem.size + }); this.ClearMeshTree (); ClearDomElement (this.titleButtonsDiv); ClearDomElement (this.buttonsDiv); @@ -180,9 +185,8 @@ export class NavigatorMeshesPanel extends NavigatorPanel { function CreateButton (parentDiv, button, className, onClick) { - button.div = AddDiv (parentDiv, 'ov_navigator_button'); - button.div.setAttribute ('alt', button.name); - button.div.setAttribute ('title', button.name); + button.div = AddButtonElement (parentDiv, 'ov_navigator_button'); + SetElementAccessibleName (button.div, button.name); if (className) { button.div.classList.add (className); } @@ -383,9 +387,10 @@ export class NavigatorMeshesPanel extends NavigatorPanel function AddModelNodeToTree (panel, model, node, parentItem, mode) { if (panel.stepDeletionState !== null && panel.stepDeletionState.IsNodeDeletedByPath (panel.stepDeletionState.GetNodePath (node.GetId ()))) { - return; + return false; } + let hasVisibleContent = false; let meshNodes = []; for (let childNode of node.GetChildNodes ()) { if (mode === MeshesPanelMode.TreeView) { @@ -393,15 +398,24 @@ export class NavigatorMeshesPanel extends NavigatorPanel meshNodes.push (childNode); } else { let nodeItem = CreateNodeItem (panel, childNode); - parentItem.AddChild (nodeItem); - AddModelNodeToTree (panel, model, childNode, nodeItem, mode); + let childHasVisibleContent = AddModelNodeToTree (panel, model, childNode, nodeItem, mode); + if (childHasVisibleContent) { + parentItem.AddChild (nodeItem); + hasVisibleContent = true; + } else { + panel.nodeIdToItem.delete (childNode.GetId ()); + } } } else { - AddModelNodeToTree (panel, model, childNode, parentItem, mode); + if (AddModelNodeToTree (panel, model, childNode, parentItem, mode)) { + hasVisibleContent = true; + } } } for (let meshNode of meshNodes) { - AddModelNodeToTree (panel, model, meshNode, parentItem, mode); + if (AddModelNodeToTree (panel, model, meshNode, parentItem, mode)) { + hasVisibleContent = true; + } } for (let meshIndex of node.GetMeshIndices ()) { let mesh = model.GetMesh (meshIndex); @@ -412,12 +426,19 @@ export class NavigatorMeshesPanel extends NavigatorPanel } } AddMeshToNodeTree (panel, node, mesh, meshIndex, parentItem, mode); + hasVisibleContent = true; } + + return hasVisibleContent; } let rootNode = model.GetRootNode (); this.rootItem = CreateDummyRootItem (this, rootNode); AddModelNodeToTree (this, model, rootNode, this.rootItem, this.mode); + console.log ('[step-demo] FillMeshTree', { + rootChildren : this.rootItem.children.length, + labels : this.rootItem.children.map ((child) => child.name) + }); } UpdateMaterialList (materialInfoArray) @@ -461,6 +482,12 @@ export class NavigatorMeshesPanel extends NavigatorPanel IsMeshVisible (meshInstanceId) { let meshItem = this.GetMeshItem (meshInstanceId); + if (meshItem === undefined) { + console.log ('[step-demo] IsMeshVisible missing mesh item', { + meshKey : meshInstanceId.GetKey () + }); + return false; + } return meshItem.IsVisible (); } diff --git a/source/website/treeview.js b/source/website/treeview.js index 0e04689..a7bd016 100644 --- a/source/website/treeview.js +++ b/source/website/treeview.js @@ -1,6 +1,6 @@ import { IsDefined } from '../engine/core/core.js'; import { AddDiv, CreateDiv, ShowDomElement, ClearDomElement, InsertDomElementBefore, InsertDomElementAfter } from '../engine/viewer/domutils.js'; -import { CreateSvgIconElement, SetSvgIconImageElement } from './utils.js'; +import { CreateSvgIconElement, SetSvgIconImageElement, CreateSvgIconButtonElement, MakeElementButtonLike } from './utils.js'; export function ScrollToView (element) { @@ -12,11 +12,10 @@ export function ScrollToView (element) export class TreeViewButton { - constructor (imagePath) + constructor (imagePath, accessibleName) { this.imagePath = imagePath; - this.mainElement = CreateSvgIconElement (this.imagePath, 'ov_tree_item_button'); - this.mainElement.setAttribute ('src', this.imagePath); + this.mainElement = CreateSvgIconButtonElement (this.imagePath, 'ov_tree_item_button', accessibleName); } SetImage (imagePath) @@ -59,6 +58,7 @@ export class TreeViewItem { this.mainElement.classList.add ('clickable'); this.mainElement.style.cursor = 'pointer'; + MakeElementButtonLike (this.mainElement, this.name, onClick); this.mainElement.addEventListener ('click', onClick); } @@ -129,7 +129,7 @@ export class TreeViewGroupItem extends TreeViewItem this.openButtonIcon = 'arrow_down'; this.closeButtonIcon = 'arrow_right'; - this.openCloseButton = CreateSvgIconElement (this.openButtonIcon, 'ov_tree_item_icon'); + this.openCloseButton = CreateSvgIconButtonElement (this.openButtonIcon, 'ov_tree_item_icon ov_tree_item_toggle', 'Toggle children'); InsertDomElementBefore (this.openCloseButton, this.nameElement); } @@ -185,7 +185,9 @@ export class TreeViewGroupItem extends TreeViewItem { if (this.childrenDiv === null) { this.childrenDiv = CreateDiv ('ov_tree_view_children'); - InsertDomElementAfter (this.childrenDiv, this.mainElement); + if (this.mainElement.parentNode !== null) { + InsertDomElementAfter (this.childrenDiv, this.mainElement); + } this.Show (this.isVisible); this.ShowChildren (this.isChildrenVisible); this.openCloseButton.addEventListener ('click', (ev) => { @@ -196,6 +198,14 @@ export class TreeViewGroupItem extends TreeViewItem } return this.childrenDiv; } + + AddDomElements (parentDiv) + { + super.AddDomElements (parentDiv); + if (this.childrenDiv !== null && this.childrenDiv.parentNode === null) { + InsertDomElementAfter (this.childrenDiv, this.mainElement); + } + } } export class TreeViewGroupButtonItem extends TreeViewGroupItem diff --git a/source/website/website.js b/source/website/website.js index 1c62111..506a92b 100644 --- a/source/website/website.js +++ b/source/website/website.js @@ -4,7 +4,7 @@ import { ImportErrorCode, ImportSettings } from '../engine/import/importer.js'; import { NavigationMode, ProjectionMode } from '../engine/viewer/camera.js'; import { RGBColor } from '../engine/model/color.js'; import { Viewer } from '../engine/viewer/viewer.js'; -import { AddDiv, AddDomElement, ShowDomElement, SetDomElementOuterHeight, CreateDomElement, GetDomElementOuterWidth } from '../engine/viewer/domutils.js'; +import { AddDiv, AddDomElement, ShowDomElement, SetDomElementOuterHeight, CreateDomElement, GetDomElementOuterWidth, AddButtonElement } from '../engine/viewer/domutils.js'; import { CalculatePopupPositionToScreen, ShowListPopup, ShowMessageDialog } from './dialogs.js'; import { HandleEvent } from './eventhandler.js'; import { HashHandler } from './hashhandler.js'; @@ -16,7 +16,7 @@ import { ThreeModelLoaderUI } from './threemodelloaderui.js'; import { Toolbar } from './toolbar.js'; import { DownloadModel, ShowExportDialog } from './exportdialog.js'; import { ShowSnapshotDialog } from './snapshotdialog.js'; -import { AddSvgIconElement, DownloadUrlAsFile, GetFilesFromDataTransfer, InstallTooltip, IsSmallWidth } from './utils.js'; +import { AddSvgIconElement, DownloadUrlAsFile, GetFilesFromDataTransfer, InstallTooltip, IsSmallWidth, SetElementAccessibleName } from './utils.js'; import { ShowOpenUrlDialog } from './openurldialog.js'; import { ShowSharingDialog } from './sharingdialog.js'; import { GetDefaultMaterials, ReplaceDefaultMaterialsColor } from '../engine/model/modelutils.js'; @@ -201,6 +201,7 @@ export class Website this.uiState = WebsiteUIState.Undefined; this.layouter = new WebsiteLayouter (this.parameters, this.navigator, this.sidebar, this.viewer, this.measureTool); this.model = null; + this.currentImportResult = null; this.stepDeletionState = null; this.deleteSelectedButton = null; this.stepSaveButton = null; @@ -228,7 +229,17 @@ export class Website this.InitDragAndDrop (); this.InitSidebar (); this.InitNavigator (); - this.InitCookieConsent (); + if (window.requestIdleCallback) { + window.requestIdleCallback (() => { + this.InitCookieConsent (); + }, { + timeout : 2000 + }); + } else { + window.setTimeout (() => { + this.InitCookieConsent (); + }, 800); + } this.viewer.SetMouseClickHandler (this.OnModelClicked.bind (this)); this.viewer.SetMouseMoveHandler (this.OnModelMouseMoved.bind (this)); @@ -289,6 +300,7 @@ export class Website CloseAllDialogs (); this.model = null; + this.currentImportResult = null; this.stepDeletionState = null; this.viewer.Clear (); @@ -304,7 +316,8 @@ export class Website OnModelLoaded (importResult, threeObject) { this.model = importResult.model; - this.parameters.fileNameDiv.innerHTML = importResult.mainFile; + this.currentImportResult = importResult; + this.parameters.fileNameDiv.textContent = importResult.mainFile; let importedExtension = GetFileExtension (importResult.mainFile); if (importedExtension === 'stp' || importedExtension === 'step') { this.stepDeletionState = new StepDeletionState (importResult.model); @@ -312,6 +325,7 @@ export class Website this.stepDeletionState = null; } this.UpdateStepButtonsVisibility (this.stepDeletionState !== null); + this.UpdateEnvironmentMap (); this.viewer.SetMainObject (threeObject); this.viewer.SetUpVector (Direction.Y, false); this.navigator.FillTree ({ @@ -480,6 +494,15 @@ export class Website }); } + ShowTransientStatus (message) + { + let statusPopup = AddDiv (document.body, 'ov_bottom_floating_panel'); + AddDiv (statusPopup, 'ov_floating_panel_text', message); + window.setTimeout (() => { + statusPopup.remove (); + }, 1800); + } + DeleteSelectedStepNode () { if (this.stepDeletionState === null) { @@ -520,20 +543,27 @@ export class Website return; } + let deletedPath = null; if (this.navigator.selection.type === SelectionType.Node) { + deletedPath = this.stepDeletionState.GetNodePath (this.navigator.selection.nodeId); this.stepDeletionState.DeleteNodeById (this.navigator.selection.nodeId); } else if (this.navigator.selection.type === SelectionType.Mesh) { + deletedPath = this.stepDeletionState.GetMeshNodePath (this.navigator.selection.meshInstanceId); this.stepDeletionState.DeleteMeshNode (this.navigator.selection.meshInstanceId); } + console.log ('[step-demo] DeleteSelectedStepNode', { + deletedPath : deletedPath, + deletedPaths : this.stepDeletionState.GetDeletedNodePaths () + }); this.navigator.SetSelection (null); this.navigator.FillTree ({ - model : this.model, - missingFiles : [], + ...this.currentImportResult, stepDeletionState : this.stepDeletionState }); this.UpdateMeshesVisibility (); this.viewer.Render (); + this.ShowTransientStatus (Loc ('Selected subtree deleted.')); } UpdateStepButtonsVisibility (isVisible) @@ -548,14 +578,51 @@ export class Website async SaveEditedStepFile () { + console.log ('[step-demo] SaveEditedStepFile start'); let importer = this.modelLoaderUI.GetImporter (); let fileList = importer.GetFileList (); - let sourceFile = fileList.FindFileByPath (this.parameters.fileNameDiv.innerHTML); - if (sourceFile === null || sourceFile.content === null || this.stepDeletionState === null) { + let mainFileName = this.currentImportResult !== null ? this.currentImportResult.mainFile : this.parameters.fileNameDiv.textContent; + let sourceFile = fileList.FindFileByPath (mainFileName); + if (sourceFile === null && fileList.GetFiles ().length === 1) { + sourceFile = fileList.GetFiles ()[0]; + } + + console.log ('[step-demo] SaveEditedStepFile source', { + mainFileName : mainFileName, + foundSource : sourceFile !== null, + hasContent : sourceFile !== null ? sourceFile.content !== null : null, + deletedPaths : this.stepDeletionState !== null ? this.stepDeletionState.GetDeletedNodePaths () : null + }); + + if (this.stepDeletionState === null) { + ShowMessageDialog ( + Loc ('Save STEP Failed'), + Loc ('This action is only available for STEP files.') + ); + return; + } + + if (sourceFile === null) { + ShowMessageDialog ( + Loc ('Save STEP Failed'), + Loc ('The original STEP file could not be found in the current import session.') + ); + return; + } + + if (sourceFile.content === null) { + ShowMessageDialog ( + Loc ('Save STEP Failed'), + Loc ('The original STEP file content is not available.') + ); return; } if (!IsStepFileName (sourceFile.name)) { + ShowMessageDialog ( + Loc ('Save STEP Failed'), + Loc ('The current model is not backed by a STEP source file.') + ); return; } @@ -565,9 +632,15 @@ export class Website sourceFile.content, this.stepDeletionState.GetDeletedNodePaths () ); + console.log ('[step-demo] SaveEditedStepFile response', { + size : fileBlob.size, + type : fileBlob.type + }); let downloadUrl = URL.createObjectURL (fileBlob); DownloadUrlAsFile (downloadUrl, BuildStepOutputFileName (sourceFile.name)); - URL.revokeObjectURL (downloadUrl); + window.setTimeout (() => { + URL.revokeObjectURL (downloadUrl); + }, 1000); } catch (err) { ShowMessageDialog ( Loc ('Save STEP Failed'), @@ -709,12 +782,13 @@ export class Website InitViewer () { let canvas = AddDomElement (this.parameters.viewerDiv, 'canvas'); + canvas.setAttribute ('tabindex', '0'); + canvas.setAttribute ('aria-label', Loc ('3D model viewer')); this.viewer.Init (canvas); this.viewer.SetEdgeSettings (this.settings.edgeSettings); this.viewer.SetBackgroundColor (this.settings.backgroundColor); this.viewer.SetNavigationMode (this.cameraSettings.navigationMode); this.viewer.SetProjectionMode (this.cameraSettings.projectionMode); - this.UpdateEnvironmentMap (); } InitToolbar () @@ -1069,8 +1143,8 @@ export class Website UpdatePanelsVisibility () { - let showNavigator = CookieGetBoolVal ('ov_show_navigator', true); - let showSidebar = CookieGetBoolVal ('ov_show_sidebar', true); + let showNavigator = IsSmallWidth () ? false : CookieGetBoolVal ('ov_show_navigator', true); + let showSidebar = IsSmallWidth () ? false : CookieGetBoolVal ('ov_show_sidebar', true); this.navigator.ShowPanels (showNavigator); this.sidebar.ShowPanels (showSidebar); } @@ -1081,6 +1155,7 @@ export class Website buttonLink.setAttribute ('href', link); buttonLink.setAttribute ('target', '_blank'); buttonLink.setAttribute ('rel', 'noopener noreferrer'); + SetElementAccessibleName (buttonLink, title); InstallTooltip (buttonLink, title); AddSvgIconElement (buttonLink, icon, 'header_button'); this.parameters.headerButtonsDiv.appendChild (buttonLink); @@ -1097,7 +1172,8 @@ export class Website let text = Loc ('This website uses cookies to offer you better user experience. See the details at the Cookies Policy page.'); let popupDiv = AddDiv (document.body, 'ov_bottom_floating_panel'); AddDiv (popupDiv, 'ov_floating_panel_text', text); - let acceptButton = AddDiv (popupDiv, 'ov_button ov_floating_panel_button', Loc ('Accept')); + let acceptButton = AddButtonElement (popupDiv, 'ov_button ov_floating_panel_button', Loc ('Accept')); + SetElementAccessibleName (acceptButton, Loc ('Accept cookies')); acceptButton.addEventListener ('click', () => { CookieSetBoolVal ('ov_cookie_consent', true); popupDiv.remove (); diff --git a/tools/step_service/freecad_trim_step.py b/tools/step_service/freecad_trim_step.py index 69e7ee0..a2dc7df 100644 --- a/tools/step_service/freecad_trim_step.py +++ b/tools/step_service/freecad_trim_step.py @@ -2,37 +2,95 @@ import json import sys +def get_script_arguments(argv): + if "--pass" in argv: + pass_index = argv.index("--pass") + script_args = argv[pass_index + 1 :] + else: + script_args = argv[2:] + + if len(script_args) < 3: + raise ValueError("Expected input path, deleted paths manifest, and output path.") + + return script_args[0], script_args[1], script_args[2] + + +def should_include_in_tree(document_object): + type_id = getattr(document_object, "TypeId", "") + return type_id not in {"App::Origin", "App::Line", "App::Plane", "Part::Feature"} + + +def get_tree_children(objects): + filtered_objects = [document_object for document_object in objects if should_include_in_tree(document_object)] + + def sort_key(document_object): + child_count = len(get_tree_children(list(getattr(document_object, "OutList", [])))) + is_leaf_part = child_count == 0 + return (1 if is_leaf_part else 0) + + filtered_objects.sort(key=sort_key) + return filtered_objects + + def enumerate_objects(objects, parent_path=""): - for index, document_object in enumerate(objects): + filtered_objects = get_tree_children(objects) + for index, document_object in enumerate(filtered_objects): object_path = str(index) if parent_path == "" else parent_path + "/" + str(index) yield object_path, document_object - child_objects = list(document_object.OutList) + child_objects = list(getattr(document_object, "OutList", [])) yield from enumerate_objects(child_objects, object_path) +def collect_subtree_objects(objects, target_path, parent_path=""): + collected_objects = [] + filtered_objects = get_tree_children(objects) + for index, document_object in enumerate(filtered_objects): + object_path = str(index) if parent_path == "" else parent_path + "/" + str(index) + child_objects = list(getattr(document_object, "OutList", [])) + if target_path is None: + collected_objects.append(document_object) + collected_objects.extend(collect_subtree_objects(child_objects, None, object_path)) + elif object_path == target_path: + collected_objects.append(document_object) + collected_objects.extend(collect_subtree_objects(child_objects, None, object_path)) + elif target_path.startswith(object_path + "/"): + collected_objects.extend(collect_subtree_objects(child_objects, target_path, object_path)) + return collected_objects + + def main(): - input_path = sys.argv[1] - deleted_paths_path = sys.argv[2] - output_path = sys.argv[3] + input_path, deleted_paths_path, output_path = get_script_arguments(sys.argv) with open(deleted_paths_path, "r", encoding="utf-8") as deleted_file: deleted_paths = set(json.load(deleted_file)) import FreeCAD - import ImportGui + import Import document = FreeCAD.newDocument("TrimmedStep") - ImportGui.insert(input_path, document.Name) + Import.insert(input_path, document.Name) - path_to_object = list(enumerate_objects(list(document.RootObjects))) - path_to_object.sort(key=lambda item: item[0].count("/"), reverse=True) - for object_path, document_object in path_to_object: - if object_path in deleted_paths: - document.removeObject(document_object.Name) + objects_to_delete = [] + root_objects = list(document.RootObjects) + for deleted_path in deleted_paths: + objects_to_delete.extend(collect_subtree_objects(root_objects, deleted_path)) - ImportGui.export(document.Objects, output_path) + unique_objects = [] + seen_names = set() + for document_object in objects_to_delete: + object_name = getattr(document_object, "Name", None) + if object_name is None or object_name in seen_names: + continue + seen_names.add(object_name) + unique_objects.append(document_object) + + unique_objects.sort(key=lambda document_object: len(list(getattr(document_object, "OutListRecursive", []))), reverse=True) + for document_object in unique_objects: + document.removeObject(document_object.Name) + + Import.export(list(document.RootObjects), output_path) FreeCAD.closeDocument(document.Name) -if __name__ == "__main__": +if __name__ in ("__main__", "freecad_trim_step"): main() diff --git a/tools/step_service/freecad_worker.py b/tools/step_service/freecad_worker.py index ced2359..9091d47 100644 --- a/tools/step_service/freecad_worker.py +++ b/tools/step_service/freecad_worker.py @@ -30,6 +30,7 @@ class FreeCADWorker: [ self.freecad_cmd, os.fspath(pathlib.Path(__file__).with_name("freecad_trim_step.py")), + "--pass", os.fspath(input_path), os.fspath(delete_manifest_path), os.fspath(output_path), @@ -39,6 +40,11 @@ class FreeCADWorker: text=True, ) + if not output_path.exists(): + raise RuntimeError( + "FreeCAD did not produce the expected STEP output file." + ) + return output_path.read_bytes(), output_name def _build_output_name(self, file_name): diff --git a/tools/step_service/server.py b/tools/step_service/server.py index 1a272a3..a24983c 100644 --- a/tools/step_service/server.py +++ b/tools/step_service/server.py @@ -18,6 +18,17 @@ def create_app(worker=None): app = Flask(__name__) step_worker = worker or FreeCADWorker() + @app.after_request + def add_cors_headers(response): + response.headers["Access-Control-Allow-Origin"] = "*" + response.headers["Access-Control-Allow-Methods"] = "POST, OPTIONS" + response.headers["Access-Control-Allow-Headers"] = "Content-Type" + return response + + @app.route("/save-step", methods=["OPTIONS"]) + def save_step_options(): + return ("", 204) + @app.post("/save-step") def save_step(): uploaded_file = request.files.get("file") diff --git a/tools/step_service/tests/test_freecad_trim_step.py b/tools/step_service/tests/test_freecad_trim_step.py new file mode 100644 index 0000000..960bdfb --- /dev/null +++ b/tools/step_service/tests/test_freecad_trim_step.py @@ -0,0 +1,78 @@ +import unittest + +from tools.step_service.freecad_trim_step import collect_subtree_objects, get_script_arguments, get_tree_children, should_include_in_tree + + +class FreeCADTrimStepTests(unittest.TestCase): + def test_extracts_arguments_after_pass_marker(self): + input_path, deleted_path, output_path = get_script_arguments( + [ + "FreeCADCmd.exe", + "freecad_trim_step.py", + "--pass", + "input.step", + "deleted_paths.json", + "output.step", + ] + ) + + self.assertEqual(input_path, "input.step") + self.assertEqual(deleted_path, "deleted_paths.json") + self.assertEqual(output_path, "output.step") + + def test_excludes_freecad_reference_objects_from_tree_paths(self): + class FakeObject: + def __init__(self, type_id, children=None): + self.TypeId = type_id + self.OutList = children or [] + + self.assertEqual(should_include_in_tree(FakeObject("App::Part")), True) + self.assertEqual(should_include_in_tree(FakeObject("Part::Feature")), False) + self.assertEqual(should_include_in_tree(FakeObject("App::Origin")), False) + self.assertEqual(should_include_in_tree(FakeObject("App::Line")), False) + self.assertEqual(should_include_in_tree(FakeObject("App::Plane")), False) + + def test_orders_assemblies_before_leaf_parts(self): + class FakeObject: + def __init__(self, label, type_id, children=None): + self.Label = label + self.TypeId = type_id + self.OutList = children or [] + + feature = FakeObject("SOLID", "Part::Feature") + plate = FakeObject("PLATE", "App::Part", [feature]) + bracket_child = FakeObject("L-BRACKET", "App::Part", [feature]) + bracket_assembly = FakeObject("L_BRACKET_ASSEMBLY_ASM", "App::Part", [bracket_child]) + rod_child = FakeObject("ROD", "App::Part", [feature]) + rod_assembly = FakeObject("ROD_ASM", "App::Part", [rod_child]) + + ordered = get_tree_children([plate, bracket_assembly, rod_assembly]) + + self.assertEqual( + [obj.Label for obj in ordered], + ["L_BRACKET_ASSEMBLY_ASM", "ROD_ASM", "PLATE"], + ) + + def test_collects_full_subtree_for_recursive_delete(self): + class FakeObject: + def __init__(self, label, type_id, children=None): + self.Label = label + self.TypeId = type_id + self.OutList = children or [] + + bolt = FakeObject("BOLT", "App::Part") + nut = FakeObject("NUT", "App::Part") + sub_assembly = FakeObject("NUT_BOLT_ASSEMBLY_ASM", "App::Part", [bolt, nut]) + bracket = FakeObject("L_BRACKET_ASSEMBLY_ASM", "App::Part", [sub_assembly]) + plate = FakeObject("PLATE", "App::Part") + + collected = collect_subtree_objects([bracket, plate], "0") + + self.assertEqual( + [obj.Label for obj in collected], + ["L_BRACKET_ASSEMBLY_ASM", "NUT_BOLT_ASSEMBLY_ASM", "BOLT", "NUT"], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/step_service/tests/test_freecad_worker.py b/tools/step_service/tests/test_freecad_worker.py new file mode 100644 index 0000000..e2d7417 --- /dev/null +++ b/tools/step_service/tests/test_freecad_worker.py @@ -0,0 +1,31 @@ +import subprocess +import tempfile +import unittest +from unittest import mock + +from tools.step_service.freecad_worker import FreeCADWorker + + +class FreeCADWorkerTests(unittest.TestCase): + @mock.patch("tools.step_service.freecad_worker.subprocess.run") + def test_passes_script_arguments_through_freecad(self, run_mock): + run_mock.return_value = subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="") + + worker = FreeCADWorker(freecad_cmd="FreeCADCmd") + + with tempfile.TemporaryDirectory() as temp_dir: + with mock.patch("tools.step_service.freecad_worker.tempfile.TemporaryDirectory") as temp_dir_mock: + temp_dir_mock.return_value.__enter__.return_value = temp_dir + temp_dir_mock.return_value.__exit__.return_value = False + with mock.patch("pathlib.Path.read_bytes", return_value=b"STEPDATA"): + with mock.patch("pathlib.Path.exists", return_value=True): + output_bytes, output_name = worker.trim_step_file("demo.step", b"INPUT", ["0/1"]) + + self.assertEqual(output_bytes, b"STEPDATA") + self.assertEqual(output_name, "demo-edited.step") + command = run_mock.call_args.args[0] + self.assertIn("--pass", command) + + +if __name__ == "__main__": + unittest.main()