fix: stabilize step tree deletion save flow

This commit is contained in:
sladro 2026-04-13 12:06:37 +08:00
parent a87affac10
commit 03497f97a7
9 changed files with 351 additions and 43 deletions

View File

@ -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 ();
}

View File

@ -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 ();
}

View File

@ -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

View File

@ -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 <a target="_blank" href="info/cookies.html">Cookies Policy</a> 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 ();

View File

@ -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()

View File

@ -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):

View File

@ -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")

View File

@ -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()

View File

@ -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()