fix step save flow and add portable backend runtime support
Some checks failed
Build / build (18.x, macos-latest) (push) Has been cancelled
Build / build (18.x, ubuntu-latest) (push) Has been cancelled
Build / build (18.x, windows-latest) (push) Has been cancelled
Build / build (20.x, macos-latest) (push) Has been cancelled
Build / build (20.x, ubuntu-latest) (push) Has been cancelled
Build / build (20.x, windows-latest) (push) Has been cancelled

This commit is contained in:
sladro 2026-04-13 15:16:30 +08:00
parent b447fc7864
commit 16a2e43649
13 changed files with 275 additions and 55 deletions

4
.gitignore vendored
View File

@ -3,3 +3,7 @@ plugins/*
node_modules
__pycache__
.worktrees
.codex/
runtime/python/
runtime/freecad/
runtime/FreeCAD*/

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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__":