diff --git a/.gitignore b/.gitignore index 898473a..21a4ba4 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ plugins/* node_modules __pycache__ .worktrees +.codex/ +runtime/python/ +runtime/freecad/ +runtime/FreeCAD*/ diff --git a/source/website/navigator.js b/source/website/navigator.js index 67673a6..f998a99 100644 --- a/source/website/navigator.js +++ b/source/website/navigator.js @@ -153,10 +153,6 @@ 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 (); @@ -168,10 +164,6 @@ 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 b694b0d..e606794 100644 --- a/source/website/navigatormeshespanel.js +++ b/source/website/navigatormeshespanel.js @@ -111,10 +111,6 @@ 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); @@ -435,10 +431,6 @@ export class NavigatorMeshesPanel extends NavigatorPanel 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) @@ -483,9 +475,6 @@ export class NavigatorMeshesPanel extends NavigatorPanel { 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/stepdeletionstate.js b/source/website/stepdeletionstate.js index f9277dd..8bc2d32 100644 --- a/source/website/stepdeletionstate.js +++ b/source/website/stepdeletionstate.js @@ -33,7 +33,7 @@ export class StepDeletionState this.meshKeyToNodePath.set (meshInstanceId.GetKey (), nodePath); } - let childNodes = node.GetChildNodes (); + let childNodes = this.GetChildNodesInPathOrder (node); for (let childIndex = 0; childIndex < childNodes.length; childIndex++) { let childNode = childNodes[childIndex]; let childPath = nodePath.length === 0 ? childIndex.toString () : nodePath + '/' + childIndex.toString (); @@ -41,6 +41,20 @@ export class StepDeletionState } } + GetChildNodesInPathOrder (node) + { + let assemblyNodes = []; + let leafPartNodes = []; + for (let childNode of node.GetChildNodes ()) { + if (childNode.IsMeshNode ()) { + leafPartNodes.push (childNode); + } else { + assemblyNodes.push (childNode); + } + } + return assemblyNodes.concat (leafPartNodes); + } + GetNodePath (nodeId) { return this.nodeIdToPath.get (nodeId); diff --git a/source/website/website.js b/source/website/website.js index ccb2b05..69f8a82 100644 --- a/source/website/website.js +++ b/source/website/website.js @@ -539,10 +539,6 @@ export class Website 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 ({ @@ -566,7 +562,6 @@ export class Website async SaveEditedStepFile () { - console.log ('[step-demo] SaveEditedStepFile start'); let importer = this.modelLoaderUI.GetImporter (); let fileList = importer.GetFileList (); let mainFileName = this.currentImportResult !== null ? this.currentImportResult.mainFile : this.parameters.fileNameDiv.textContent; @@ -575,13 +570,6 @@ export class Website 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'), @@ -620,10 +608,6 @@ 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)); window.setTimeout (() => { diff --git a/test/tests/stepdeletionstate_test.js b/test/tests/stepdeletionstate_test.js index 99a4dc4..3cc825d 100644 --- a/test/tests/stepdeletionstate_test.js +++ b/test/tests/stepdeletionstate_test.js @@ -52,6 +52,34 @@ describe ('StepDeletionState', function () { assert.strictEqual (state.CanDeleteNode (1), true); assert.strictEqual (state.CanDeleteNode (3), true); }); + + it ('orders assembly nodes before leaf parts when building deletion paths', function () { + let model = new OV.Model (); + let root = model.GetRootNode (); + + let leafPartMeshIndex = model.AddMesh (new OV.Mesh ()); + let leafPart = new OV.Node (); + leafPart.SetName ('Leaf Part'); + root.AddChildNode (leafPart); + leafPart.AddMeshIndex (leafPartMeshIndex); + + let assembly = new OV.Node (); + assembly.SetName ('Assembly'); + root.AddChildNode (assembly); + + let assemblyLeafPartMeshIndex = model.AddMesh (new OV.Mesh ()); + let assemblyLeafPart = new OV.Node (); + assemblyLeafPart.SetName ('Assembly Leaf Part'); + assembly.AddChildNode (assemblyLeafPart); + assemblyLeafPart.AddMeshIndex (assemblyLeafPartMeshIndex); + + let state = new StepDeletionState (model); + + assert.strictEqual (state.GetNodePath (assembly.GetId ()), '0'); + assert.strictEqual (state.GetNodePath (assemblyLeafPart.GetId ()), '0/0'); + assert.strictEqual (state.GetNodePath (leafPart.GetId ()), '1'); + assert.strictEqual (state.GetMeshNodePath (new OV.MeshInstanceId (leafPart.GetId (), leafPartMeshIndex)), '1'); + }); }); } diff --git a/tools/step_service/PORTABLE_RUNTIME.md b/tools/step_service/PORTABLE_RUNTIME.md new file mode 100644 index 0000000..1fcc561 --- /dev/null +++ b/tools/step_service/PORTABLE_RUNTIME.md @@ -0,0 +1,56 @@ +# Portable STEP Service Runtime + +## Goal + +Run the STEP save backend from this project folder on another Windows machine without installing system Python or system FreeCAD. + +## Expected layout + +Put these runtimes inside the project root: + +```text +Online3DViewer/ + runtime/ + python/ + python.exe + ... + freecad/ + bin/ + FreeCADCmd.exe + ... +``` + +## One-time setup on the source machine + +1. Copy a full Windows Python runtime into `runtime/python`. +2. Copy the FreeCAD folder into `runtime/freecad`. +3. Install backend dependencies into the portable Python: + +```bat +tools\step_service\setup_portable_python.bat +``` + +## Start the backend + +Use the portable launcher: + +```bat +tools\step_service\start_portable_server.bat +``` + +This launcher only uses `runtime/python/python.exe`. It does not modify system PATH. + +## How FreeCAD is resolved + +The backend now checks FreeCAD in this order: + +1. `runtime/freecad/bin/FreeCADCmd.exe` +2. `FreeCADCmd` from the current environment + +So your existing local setup still works, but the portable folder takes priority when present. + +## Notes + +- Copy the whole project folder to the target machine. +- The target machine still needs normal Windows runtime support that FreeCAD and Python depend on. +- If you want zero manual commands on the target machine, use `start_portable_server.bat` instead of `python tools/step_service/server.py`. diff --git a/tools/step_service/freecad_trim_step.py b/tools/step_service/freecad_trim_step.py index a2dc7df..550b24c 100644 --- a/tools/step_service/freecad_trim_step.py +++ b/tools/step_service/freecad_trim_step.py @@ -41,6 +41,15 @@ def enumerate_objects(objects, parent_path=""): yield from enumerate_objects(child_objects, object_path) +def collect_all_subtree_objects(objects): + collected_objects = [] + for document_object in objects: + collected_objects.append(document_object) + child_objects = list(getattr(document_object, "OutList", [])) + collected_objects.extend(collect_all_subtree_objects(child_objects)) + return collected_objects + + def collect_subtree_objects(objects, target_path, parent_path=""): collected_objects = [] filtered_objects = get_tree_children(objects) @@ -49,15 +58,30 @@ def collect_subtree_objects(objects, target_path, parent_path=""): 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)) + collected_objects.extend(collect_all_subtree_objects(child_objects)) elif object_path == target_path: collected_objects.append(document_object) - collected_objects.extend(collect_subtree_objects(child_objects, None, object_path)) + collected_objects.extend(collect_all_subtree_objects(child_objects)) elif target_path.startswith(object_path + "/"): collected_objects.extend(collect_subtree_objects(child_objects, target_path, object_path)) return collected_objects +def build_removal_names(objects_to_delete): + removal_candidates = [] + 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) + subtree_depth = len(list(getattr(document_object, "OutListRecursive", []))) + removal_candidates.append((object_name, subtree_depth)) + + removal_candidates.sort(key=lambda candidate: candidate[1], reverse=True) + return [object_name for object_name, _depth in removal_candidates] + + def main(): input_path, deleted_paths_path, output_path = get_script_arguments(sys.argv) @@ -75,18 +99,10 @@ def main(): for deleted_path in deleted_paths: objects_to_delete.extend(collect_subtree_objects(root_objects, deleted_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: + for object_name in build_removal_names(objects_to_delete): + if document.getObject(object_name) is None: 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) + document.removeObject(object_name) Import.export(list(document.RootObjects), output_path) FreeCAD.closeDocument(document.Name) diff --git a/tools/step_service/freecad_worker.py b/tools/step_service/freecad_worker.py index 9091d47..2c5ceb4 100644 --- a/tools/step_service/freecad_worker.py +++ b/tools/step_service/freecad_worker.py @@ -8,8 +8,41 @@ from .step_tree import normalize_deleted_paths class FreeCADWorker: - def __init__(self, freecad_cmd="FreeCADCmd"): + def __init__(self, freecad_cmd="FreeCADCmd", project_root=None): self.freecad_cmd = freecad_cmd + if project_root is None: + self.project_root = pathlib.Path(__file__).resolve().parents[2] + else: + self.project_root = pathlib.Path(project_root) + + def get_freecad_cmd(self): + if isinstance(self.freecad_cmd, str) and self.freecad_cmd == "FreeCADCmd": + project_freecad_cmd = self._get_project_freecad_cmd() + if project_freecad_cmd is not None: + return project_freecad_cmd + return self.freecad_cmd + + def _get_project_freecad_cmd(self): + runtime_dir = self.project_root / "runtime" + candidate_commands = [ + runtime_dir / "freecad" / "bin" / "FreeCADCmd.exe", + runtime_dir / "FreeCAD" / "bin" / "FreeCADCmd.exe", + ] + for child in runtime_dir.iterdir() if runtime_dir.exists() else []: + child_name = child.name.lower() + if "freecad" not in child_name: + continue + candidate_commands.extend( + [ + child / "bin" / "FreeCADCmd.exe", + child / "bin" / "freecadcmd.exe", + ] + ) + + for project_freecad_cmd in candidate_commands: + if project_freecad_cmd.exists(): + return project_freecad_cmd + return None def trim_step_file(self, file_name, file_bytes, deleted_paths): normalized_paths = normalize_deleted_paths(deleted_paths) @@ -28,7 +61,7 @@ class FreeCADWorker: subprocess.run( [ - self.freecad_cmd, + os.fspath(self.get_freecad_cmd()), os.fspath(pathlib.Path(__file__).with_name("freecad_trim_step.py")), "--pass", os.fspath(input_path), diff --git a/tools/step_service/setup_portable_python.bat b/tools/step_service/setup_portable_python.bat new file mode 100644 index 0000000..c181fbc --- /dev/null +++ b/tools/step_service/setup_portable_python.bat @@ -0,0 +1,16 @@ +@echo off +setlocal + +set "SCRIPT_DIR=%~dp0" +set "PROJECT_ROOT=%SCRIPT_DIR%..\.." +set "PYTHON_EXE=%PROJECT_ROOT%\runtime\python\python.exe" +set "REQUIREMENTS_FILE=%SCRIPT_DIR%requirements.txt" +set "PYTHONNOUSERSITE=1" + +if not exist "%PYTHON_EXE%" ( + echo [step_service] Missing portable Python: "%PYTHON_EXE%" + echo [step_service] Copy a full Python runtime into "runtime\python" first. + exit /b 1 +) + +"%PYTHON_EXE%" -m pip install --isolated --no-warn-script-location -r "%REQUIREMENTS_FILE%" diff --git a/tools/step_service/start_portable_server.bat b/tools/step_service/start_portable_server.bat new file mode 100644 index 0000000..af34eca --- /dev/null +++ b/tools/step_service/start_portable_server.bat @@ -0,0 +1,15 @@ +@echo off +setlocal + +set "SCRIPT_DIR=%~dp0" +set "PROJECT_ROOT=%SCRIPT_DIR%..\.." +set "PYTHON_EXE=%PROJECT_ROOT%\runtime\python\python.exe" +set "PYTHONNOUSERSITE=1" + +if not exist "%PYTHON_EXE%" ( + echo [step_service] Missing portable Python: "%PYTHON_EXE%" + echo [step_service] Copy a full Python runtime into "runtime\python" first. + exit /b 1 +) + +"%PYTHON_EXE%" "%SCRIPT_DIR%server.py" diff --git a/tools/step_service/tests/test_freecad_trim_step.py b/tools/step_service/tests/test_freecad_trim_step.py index 960bdfb..ee65eb1 100644 --- a/tools/step_service/tests/test_freecad_trim_step.py +++ b/tools/step_service/tests/test_freecad_trim_step.py @@ -1,6 +1,6 @@ import unittest -from tools.step_service.freecad_trim_step import collect_subtree_objects, get_script_arguments, get_tree_children, should_include_in_tree +from tools.step_service.freecad_trim_step import build_removal_names, collect_subtree_objects, get_script_arguments, get_tree_children, should_include_in_tree class FreeCADTrimStepTests(unittest.TestCase): @@ -73,6 +73,46 @@ class FreeCADTrimStepTests(unittest.TestCase): ["L_BRACKET_ASSEMBLY_ASM", "NUT_BOLT_ASSEMBLY_ASM", "BOLT", "NUT"], ) + def test_collects_hidden_shape_children_when_deleting_visible_part(self): + class FakeObject: + def __init__(self, label, type_id, children=None): + self.Label = label + self.TypeId = type_id + self.OutList = children or [] + + shape = FakeObject("ROD_SHAPE", "Part::Feature") + rod = FakeObject("ROD", "App::Part", [shape]) + support_shape = FakeObject("SUPPORT_SHAPE", "Part::Feature") + support = FakeObject("SUPPORT", "App::Part", [support_shape]) + + collected = collect_subtree_objects([rod, support], "0") + + self.assertEqual( + [obj.Label for obj in collected], + ["ROD", "ROD_SHAPE"], + ) + + def test_builds_removal_names_before_objects_become_invalid(self): + class FakeObject: + def __init__(self, name, depth): + self.Name = name + self._depth = depth + + @property + def OutListRecursive(self): + return [object()] * self._depth + + removal_names = build_removal_names( + [ + FakeObject("ROD_ASM", 3), + FakeObject("ROD", 1), + FakeObject("ROD_SHAPE", 0), + FakeObject("ROD", 1), + ] + ) + + self.assertEqual(removal_names, ["ROD_ASM", "ROD", "ROD_SHAPE"]) + 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 index e2d7417..dde9658 100644 --- a/tools/step_service/tests/test_freecad_worker.py +++ b/tools/step_service/tests/test_freecad_worker.py @@ -1,3 +1,4 @@ +import pathlib import subprocess import tempfile import unittest @@ -7,6 +8,36 @@ from tools.step_service.freecad_worker import FreeCADWorker class FreeCADWorkerTests(unittest.TestCase): + def test_prefers_project_local_freecad_command(self): + with tempfile.TemporaryDirectory() as temp_dir: + project_root = temp_dir + freecad_cmd = mock.Mock() + freecad_cmd.exists.return_value = True + freecad_cmd.__fspath__ = mock.Mock(return_value="D:/portable/freecad/bin/FreeCADCmd.exe") + + worker = FreeCADWorker(project_root=project_root) + + with mock.patch.object(worker, "_get_project_freecad_cmd", return_value=freecad_cmd): + self.assertEqual(worker.get_freecad_cmd(), freecad_cmd) + + def test_falls_back_to_system_command_when_project_runtime_is_missing(self): + with tempfile.TemporaryDirectory() as temp_dir: + worker = FreeCADWorker(project_root=temp_dir) + + with mock.patch.object(worker, "_get_project_freecad_cmd", return_value=None): + self.assertEqual(worker.get_freecad_cmd(), "FreeCADCmd") + + def test_detects_freecad_runtime_with_versioned_folder_name(self): + with tempfile.TemporaryDirectory() as temp_dir: + project_root_path = pathlib.Path(temp_dir) + command_path = project_root_path / "runtime" / "FreeCAD 1.0" / "bin" / "freecadcmd.exe" + command_path.parent.mkdir(parents=True) + command_path.write_text("", encoding="utf-8") + + worker = FreeCADWorker(project_root=project_root_path) + + self.assertEqual(worker.get_freecad_cmd(), command_path) + @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="") @@ -17,14 +48,16 @@ class FreeCADWorkerTests(unittest.TestCase): 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"]) + with mock.patch.object(worker, "get_freecad_cmd", return_value="FreeCADCmd"): + 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) + self.assertEqual(command[0], "FreeCADCmd") if __name__ == "__main__":