feat: add step tree deletion demo

This commit is contained in:
sladro 2026-04-10 11:43:09 +08:00
parent 05bc283d59
commit 4e2e190268
22 changed files with 660 additions and 17 deletions

View File

@ -22,3 +22,31 @@ Online 3D Viewer (https://3dviewer.net) is a free and open source web solution t
## External Libraries
Online 3D Viewer uses these wonderful libraries: [three.js](https://github.com/mrdoob/three.js), [pickr](https://github.com/Simonwep/pickr), [fflate](https://github.com/101arrowz/fflate), [draco](https://github.com/google/draco), [rhino3dm](https://github.com/mcneel/rhino3dm), [web-ifc](https://github.com/tomvandig/web-ifc), [occt-import-js](https://github.com/kovacsv/occt-import-js).
## STEP Tree Deletion Demo
This demo adds a local-only STEP editing flow without changing the existing download and export features.
### Start the website
```bash
npm install
npm run build_website_dev
npx http-server .
```
### Start the local STEP save service
```bash
pip install -r tools/step_service/requirements.txt
python tools/step_service/server.py
```
The service listens on `http://127.0.0.1:17890`.
### Demo flow
1. Open a `.stp` or `.step` file.
2. Select an assembly node or leaf part in the tree.
3. Click `Delete Selected`.
4. Click `Save STEP`.
5. Reopen the downloaded file to verify the deleted subtree is gone.

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "online-3d-viewer",
"version": "0.16.0",
"version": "0.18.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "online-3d-viewer",
"version": "0.16.0",
"version": "0.18.0",
"license": "MIT",
"dependencies": {
"@simonwep/pickr": "1.9.0",

View File

@ -7,7 +7,8 @@ import { PanelSet } from './panelset.js';
export const SelectionType =
{
Material : 1,
Mesh : 2
Mesh : 2,
Node : 3
};
export class Selection
@ -17,10 +18,13 @@ export class Selection
this.type = type;
this.materialIndex = null;
this.meshInstanceId = null;
this.nodeId = null;
if (this.type === SelectionType.Material) {
this.materialIndex = data;
} else if (this.type === SelectionType.Mesh) {
this.meshInstanceId = data;
} else if (this.type === SelectionType.Node) {
this.nodeId = data;
}
}
@ -33,7 +37,10 @@ export class Selection
return this.materialIndex === rhs.materialIndex;
} else if (this.type === SelectionType.Mesh) {
return this.meshInstanceId.IsEqual (rhs.meshInstanceId);
} else if (this.type === SelectionType.Node) {
return this.nodeId === rhs.nodeId;
}
return false;
}
}
@ -101,6 +108,9 @@ export class Navigator
});
this.meshesPanel.Init ({
onNodeSelected : (nodeId) => {
this.SetSelection (new Selection (SelectionType.Node, nodeId));
},
onMeshSelected : (meshId) => {
this.SetSelection (new Selection (SelectionType.Mesh, meshId));
},
@ -223,6 +233,11 @@ export class Navigator
navigator.panelSet.ShowPanel (navigator.meshesPanel);
}
navigator.meshesPanel.GetMeshItem (selection.meshInstanceId).SetSelected (select);
} else if (selection.type === SelectionType.Node) {
if (select && navigator.panelSet.IsPanelsVisible ()) {
navigator.panelSet.ShowPanel (navigator.meshesPanel);
}
navigator.meshesPanel.GetNodeItem (selection.nodeId).SetSelected (select);
}
}
@ -261,6 +276,8 @@ export class Navigator
this.callbacks.onMaterialSelected (this.selection.materialIndex);
} else if (this.selection.type === SelectionType.Mesh) {
this.callbacks.onMeshSelected (this.selection.meshInstanceId);
} else if (this.selection.type === SelectionType.Node) {
this.callbacks.onNodeSelected (this.selection.nodeId);
}
}
this.UpdatePanels ();

View File

@ -96,6 +96,12 @@ export class NodeItem extends TreeViewGroupButtonItem
this.callbacks.onShowHide (nodeId);
});
this.AppendButton (this.showHideButton);
if (IsDefined (this.callbacks.onSelected)) {
this.OnClick (() => {
this.callbacks.onSelected (nodeId);
});
}
}
GetNodeId ()

View File

@ -70,6 +70,7 @@ export class NavigatorMeshesPanel extends NavigatorPanel
super (parentDiv);
this.callbacks = null;
this.stepDeletionState = null;
this.nodeIdToItem = new Map ();
this.meshInstanceIdToItem = new Map ();
this.rootItem = null;
@ -143,6 +144,7 @@ export class NavigatorMeshesPanel extends NavigatorPanel
Fill (importResult)
{
super.Fill (importResult);
this.stepDeletionState = importResult.stepDeletionState || null;
const rootNode = importResult.model.GetRootNode ();
let isHierarchical = false;
@ -345,6 +347,9 @@ export class NavigatorMeshesPanel extends NavigatorPanel
const nodeName = GetNodeName (node.GetName ());
const nodeId = node.GetId ();
let nodeItem = new NodeItem (nodeName, nodeId, {
onSelected : (selectedNodeId) => {
panel.callbacks.onNodeSelected (selectedNodeId);
},
onShowHide : (selectedNodeId) => {
panel.callbacks.onNodeShowHide (selectedNodeId);
},
@ -377,6 +382,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;
}
let meshNodes = [];
for (let childNode of node.GetChildNodes ()) {
if (mode === MeshesPanelMode.TreeView) {
@ -396,6 +405,12 @@ export class NavigatorMeshesPanel extends NavigatorPanel
}
for (let meshIndex of node.GetMeshIndices ()) {
let mesh = model.GetMesh (meshIndex);
if (panel.stepDeletionState !== null) {
let meshInstanceId = new MeshInstanceId (node.GetId (), meshIndex);
if (panel.stepDeletionState.IsNodeDeletedByPath (panel.stepDeletionState.GetMeshNodePath (meshInstanceId))) {
continue;
}
}
AddMeshToNodeTree (panel, node, mesh, meshIndex, parentItem, mode);
}
}

View File

@ -0,0 +1,104 @@
import { MeshInstanceId } from '../engine/model/meshinstance.js';
function IsPathPrefix (parentPath, childPath)
{
if (parentPath.length === 0) {
return childPath.length === 0;
}
return childPath === parentPath || childPath.startsWith (parentPath + '/');
}
export class StepDeletionState
{
constructor (model)
{
this.model = model;
this.nodeIdToPath = new Map ();
this.meshKeyToNodePath = new Map ();
this.deletedNodePaths = new Set ();
this.BuildIndex ();
}
BuildIndex ()
{
const rootNode = this.model.GetRootNode ();
this.VisitNode (rootNode, '');
}
VisitNode (node, nodePath)
{
this.nodeIdToPath.set (node.GetId (), nodePath);
for (let meshIndex of node.GetMeshIndices ()) {
let meshInstanceId = new MeshInstanceId (node.GetId (), meshIndex);
this.meshKeyToNodePath.set (meshInstanceId.GetKey (), nodePath);
}
let childNodes = node.GetChildNodes ();
for (let childIndex = 0; childIndex < childNodes.length; childIndex++) {
let childNode = childNodes[childIndex];
let childPath = nodePath.length === 0 ? childIndex.toString () : nodePath + '/' + childIndex.toString ();
this.VisitNode (childNode, childPath);
}
}
GetNodePath (nodeId)
{
return this.nodeIdToPath.get (nodeId);
}
GetMeshNodePath (meshInstanceId)
{
return this.meshKeyToNodePath.get (meshInstanceId.GetKey ());
}
CanDeleteNode (nodeId)
{
let nodePath = this.GetNodePath (nodeId);
return nodePath !== undefined && nodePath !== null && nodePath.length > 0;
}
DeleteNodePath (nodePath)
{
if (nodePath === undefined || nodePath === null || nodePath.length === 0) {
return;
}
let nextDeleted = new Set ();
for (let existingPath of this.deletedNodePaths) {
if (IsPathPrefix (existingPath, nodePath)) {
return;
}
if (IsPathPrefix (nodePath, existingPath)) {
continue;
}
nextDeleted.add (existingPath);
}
nextDeleted.add (nodePath);
this.deletedNodePaths = nextDeleted;
}
DeleteNodeById (nodeId)
{
this.DeleteNodePath (this.GetNodePath (nodeId));
}
DeleteMeshNode (meshInstanceId)
{
this.DeleteNodePath (this.GetMeshNodePath (meshInstanceId));
}
IsNodeDeletedByPath (nodePath)
{
for (let deletedPath of this.deletedNodePaths) {
if (IsPathPrefix (deletedPath, nodePath)) {
return true;
}
}
return false;
}
GetDeletedNodePaths ()
{
return Array.from (this.deletedNodePaths.values ()).sort ();
}
}

View File

@ -0,0 +1,16 @@
export function IsStepFileName (fileName)
{
let lowerCaseName = fileName.toLowerCase ();
return lowerCaseName.endsWith ('.step') || lowerCaseName.endsWith ('.stp');
}
export function BuildStepOutputFileName (fileName)
{
if (fileName.toLowerCase ().endsWith ('.step')) {
return fileName.substring (0, fileName.length - 5) + '-edited.step';
}
if (fileName.toLowerCase ().endsWith ('.stp')) {
return fileName.substring (0, fileName.length - 4) + '-edited.stp';
}
return fileName + '-edited.step';
}

View File

@ -0,0 +1,26 @@
export class StepSaveService
{
constructor (serviceUrl)
{
this.serviceUrl = serviceUrl;
}
async SaveStepFile (fileName, fileContent, deletedPaths)
{
let formData = new FormData ();
let fileBlob = new Blob ([fileContent], {
type : 'application/step'
});
formData.append ('file', fileBlob, fileName);
formData.append ('deletedPaths', JSON.stringify (deletedPaths));
let response = await fetch (this.serviceUrl + '/save-step', {
method : 'POST',
body : formData
});
if (!response.ok) {
throw new Error ('STEP save request failed.');
}
return await response.blob ();
}
}

View File

@ -45,6 +45,7 @@ export class TreeViewItem
{
this.name = name;
this.parent = null;
this.selected = false;
this.mainElement = CreateDiv ('ov_tree_item');
this.mainElement.setAttribute ('title', this.name);
this.nameElement = AddDiv (this.mainElement, 'ov_tree_item_name', this.name);
@ -70,15 +71,6 @@ export class TreeViewItem
{
parentDiv.appendChild (this.mainElement);
}
}
export class TreeViewSingleItem extends TreeViewItem
{
constructor (name, icon)
{
super (name, icon);
this.selected = false;
}
SetSelected (selected)
{
@ -101,6 +93,14 @@ export class TreeViewSingleItem extends TreeViewItem
}
}
export class TreeViewSingleItem extends TreeViewItem
{
constructor (name, icon)
{
super (name, icon);
}
}
export class TreeViewButtonItem extends TreeViewSingleItem
{
constructor (name, icon)
@ -188,7 +188,8 @@ export class TreeViewGroupItem extends TreeViewItem
InsertDomElementAfter (this.childrenDiv, this.mainElement);
this.Show (this.isVisible);
this.ShowChildren (this.isChildrenVisible);
this.OnClick ((ev) => {
this.openCloseButton.addEventListener ('click', (ev) => {
ev.stopPropagation ();
this.isChildrenVisible = !this.isChildrenVisible;
this.ShowChildren (this.isChildrenVisible);
});

View File

@ -5,7 +5,7 @@ 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 { CalculatePopupPositionToScreen, ShowListPopup } from './dialogs.js';
import { CalculatePopupPositionToScreen, ShowListPopup, ShowMessageDialog } from './dialogs.js';
import { HandleEvent } from './eventhandler.js';
import { HashHandler } from './hashhandler.js';
import { Navigator, Selection, SelectionType } from './navigator.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, GetFilesFromDataTransfer, InstallTooltip, IsSmallWidth } from './utils.js';
import { AddSvgIconElement, DownloadUrlAsFile, GetFilesFromDataTransfer, InstallTooltip, IsSmallWidth } from './utils.js';
import { ShowOpenUrlDialog } from './openurldialog.js';
import { ShowSharingDialog } from './sharingdialog.js';
import { GetDefaultMaterials, ReplaceDefaultMaterialsColor } from '../engine/model/modelutils.js';
@ -29,6 +29,9 @@ import { EnumeratePlugins, PluginType } from './pluginregistry.js';
import { EnvironmentSettings } from '../engine/viewer/shadingmodel.js';
import { IntersectionMode } from '../engine/viewer/viewermodel.js';
import { Loc } from '../engine/core/localization.js';
import { StepDeletionState } from './stepdeletionstate.js';
import { BuildStepOutputFileName, IsStepFileName } from './stepfileutils.js';
import { StepSaveService } from './stepsaveservice.js';
const WebsiteUIState =
{
@ -198,6 +201,10 @@ 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.stepDeletionState = null;
this.deleteSelectedButton = null;
this.stepSaveButton = null;
this.stepSaveService = new StepSaveService ('http://127.0.0.1:17890');
}
Load ()
@ -282,9 +289,11 @@ export class Website
CloseAllDialogs ();
this.model = null;
this.stepDeletionState = null;
this.viewer.Clear ();
this.parameters.fileNameDiv.innerHTML = '';
this.UpdateStepButtonsVisibility (false);
this.navigator.Clear ();
this.sidebar.Clear ();
@ -296,9 +305,19 @@ export class Website
{
this.model = importResult.model;
this.parameters.fileNameDiv.innerHTML = importResult.mainFile;
let importedExtension = GetFileExtension (importResult.mainFile);
if (importedExtension === 'stp' || importedExtension === 'step') {
this.stepDeletionState = new StepDeletionState (importResult.model);
} else {
this.stepDeletionState = null;
}
this.UpdateStepButtonsVisibility (this.stepDeletionState !== null);
this.viewer.SetMainObject (threeObject);
this.viewer.SetUpVector (Direction.Y, false);
this.navigator.FillTree (importResult);
this.navigator.FillTree ({
...importResult,
stepDeletionState : this.stepDeletionState
});
this.sidebar.UpdateControlsVisibility ();
this.FitModelToWindow (true);
}
@ -454,10 +473,110 @@ export class Website
UpdateMeshesVisibility ()
{
this.viewer.SetMeshesVisibility ((meshUserData) => {
return this.navigator.IsMeshVisible (meshUserData.originalMeshInstance.id);
let meshId = meshUserData.originalMeshInstance.id;
let visibleInNavigator = this.navigator.IsMeshVisible (meshId);
let visibleInDeletionState = this.stepDeletionState === null || !this.stepDeletionState.IsNodeDeletedByPath (this.stepDeletionState.GetMeshNodePath (meshId));
return visibleInNavigator && visibleInDeletionState;
});
}
DeleteSelectedStepNode ()
{
if (this.stepDeletionState === null) {
ShowMessageDialog (
Loc ('Delete Selected'),
Loc ('This action is only available for STEP files.')
);
return;
}
if (this.navigator.selection === null) {
ShowMessageDialog (
Loc ('Delete Selected'),
Loc ('Select a tree node or part first.')
);
return;
}
let hasDeleted = false;
if (this.navigator.selection.type === SelectionType.Node) {
if (!this.stepDeletionState.CanDeleteNode (this.navigator.selection.nodeId)) {
return;
}
hasDeleted = true;
} else if (this.navigator.selection.type === SelectionType.Mesh) {
hasDeleted = true;
}
if (!hasDeleted) {
ShowMessageDialog (
Loc ('Delete Selected'),
Loc ('Select a tree node or part first.')
);
return;
}
if (!window.confirm (Loc ('Delete selected node and all its children?'))) {
return;
}
if (this.navigator.selection.type === SelectionType.Node) {
this.stepDeletionState.DeleteNodeById (this.navigator.selection.nodeId);
} else if (this.navigator.selection.type === SelectionType.Mesh) {
this.stepDeletionState.DeleteMeshNode (this.navigator.selection.meshInstanceId);
}
this.navigator.SetSelection (null);
this.navigator.FillTree ({
model : this.model,
missingFiles : [],
stepDeletionState : this.stepDeletionState
});
this.UpdateMeshesVisibility ();
this.viewer.Render ();
}
UpdateStepButtonsVisibility (isVisible)
{
if (this.deleteSelectedButton !== null) {
ShowDomElement (this.deleteSelectedButton.buttonDiv, isVisible);
}
if (this.stepSaveButton !== null) {
ShowDomElement (this.stepSaveButton.buttonDiv, isVisible);
}
}
async SaveEditedStepFile ()
{
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) {
return;
}
if (!IsStepFileName (sourceFile.name)) {
return;
}
try {
let fileBlob = await this.stepSaveService.SaveStepFile (
sourceFile.name,
sourceFile.content,
this.stepDeletionState.GetDeletedNodePaths ()
);
let downloadUrl = URL.createObjectURL (fileBlob);
DownloadUrlAsFile (downloadUrl, BuildStepOutputFileName (sourceFile.name));
URL.revokeObjectURL (downloadUrl);
} catch (err) {
ShowMessageDialog (
Loc ('Save STEP Failed'),
Loc ('The local STEP save service is not available.'),
err.message
);
}
}
UpdateMeshesSelection ()
{
let selectedMeshId = this.navigator.GetSelectedMeshId ();
@ -714,6 +833,12 @@ export class Website
let importer = this.modelLoaderUI.GetImporter ();
DownloadModel (importer);
});
this.deleteSelectedButton = AddButton (this.toolbar, 'hidden', Loc ('Delete Selected'), ['only_full_width', 'only_on_model'], () => {
this.DeleteSelectedStepNode ();
});
this.stepSaveButton = AddButton (this.toolbar, 'file_download', Loc ('Save STEP'), ['only_full_width', 'only_on_model'], async () => {
await this.SaveEditedStepFile ();
});
AddButton (this.toolbar, 'export', Loc ('Export'), ['only_full_width', 'only_on_model'], () => {
ShowExportDialog (this.model, this.viewer, {
isMeshVisible : (meshInstanceId) => {
@ -760,6 +885,8 @@ export class Website
this.LoadModelFromFileList (ev.target.files);
}
});
this.UpdateStepButtonsVisibility (false);
}
InitDragAndDrop ()
@ -924,6 +1051,9 @@ export class Website
let meshInstance = this.model.GetMeshInstance (meshInstanceId);
this.sidebar.AddObject3DProperties (this.model, meshInstance);
},
onNodeSelected : () => {
this.sidebar.AddObject3DProperties (this.model, this.model);
},
onMaterialSelected : (materialIndex) => {
this.sidebar.AddMaterialProperties (this.model.GetMaterial (materialIndex));
},

View File

@ -28,6 +28,8 @@ import exporter_test from './tests/exporter_test.js';
import exportimport_test from './tests/exportimport_test.js';
import property_test from './tests/property_test.js';
import parameterlist_test from './tests/parameterlist_test.js';
import stepdeletionstate_test from './tests/stepdeletionstate_test.js';
import stepfileutils_test from './tests/stepfileutils_test.js';
process.chdir (path.resolve ());
SetGlobals ();
@ -58,3 +60,5 @@ exporter_test ();
exportimport_test ();
property_test ();
parameterlist_test ();
stepdeletionstate_test ();
stepfileutils_test ();

View File

@ -0,0 +1,57 @@
import * as assert from 'assert';
import * as OV from '../../source/engine/main.js';
import { GetHierarchicalModelNoFinalization } from '../utils/testutils.js';
import { StepDeletionState } from '../../source/website/stepdeletionstate.js';
export default function suite ()
{
describe ('StepDeletionState', function () {
it ('builds stable child-index paths', function () {
let model = GetHierarchicalModelNoFinalization ();
let state = new StepDeletionState (model);
assert.strictEqual (state.GetNodePath (0), '');
assert.strictEqual (state.GetNodePath (1), '0');
assert.strictEqual (state.GetNodePath (2), '1');
assert.strictEqual (state.GetNodePath (3), '0/0');
assert.strictEqual (state.GetNodePath (4), '0/1');
});
it ('normalizes subtree deletions', function () {
let model = GetHierarchicalModelNoFinalization ();
let state = new StepDeletionState (model);
state.DeleteNodePath ('0');
state.DeleteNodePath ('0/0');
state.DeleteNodePath ('1');
assert.deepStrictEqual (state.GetDeletedNodePaths (), ['0', '1']);
assert.ok (state.IsNodeDeletedByPath ('0/0'));
assert.ok (state.IsNodeDeletedByPath ('0/1'));
assert.ok (!state.IsNodeDeletedByPath (''));
});
it ('maps mesh-backed leaf parts to their owning node path', function () {
let model = GetHierarchicalModelNoFinalization ();
let state = new StepDeletionState (model);
let meshInstanceId = new OV.MeshInstanceId (3, 4);
assert.strictEqual (state.GetMeshNodePath (meshInstanceId), '0/0');
state.DeleteMeshNode (meshInstanceId);
assert.deepStrictEqual (state.GetDeletedNodePaths (), ['0/0']);
});
it ('keeps root undeletable while regular nodes stay deletable', function () {
let model = GetHierarchicalModelNoFinalization ();
let state = new StepDeletionState (model);
assert.strictEqual (state.CanDeleteNode (0), false);
assert.strictEqual (state.CanDeleteNode (1), true);
assert.strictEqual (state.CanDeleteNode (3), true);
});
});
}

View File

@ -0,0 +1,20 @@
import * as assert from 'assert';
import { BuildStepOutputFileName, IsStepFileName } from '../../source/website/stepfileutils.js';
export default function suite ()
{
describe ('StepFileUtils', function () {
it ('detects step file names', function () {
assert.strictEqual (IsStepFileName ('demo.step'), true);
assert.strictEqual (IsStepFileName ('demo.stp'), true);
assert.strictEqual (IsStepFileName ('demo.obj'), false);
});
it ('builds edited step file names', function () {
assert.strictEqual (BuildStepOutputFileName ('gearbox.step'), 'gearbox-edited.step');
assert.strictEqual (BuildStepOutputFileName ('housing.stp'), 'housing-edited.stp');
});
});
}

1
tools/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Tool package for local development helpers."""

View File

@ -0,0 +1 @@
"""Local STEP save service package."""

View File

@ -0,0 +1,38 @@
import json
import sys
def enumerate_objects(objects, parent_path=""):
for index, document_object in enumerate(objects):
object_path = str(index) if parent_path == "" else parent_path + "/" + str(index)
yield object_path, document_object
child_objects = list(document_object.OutList)
yield from enumerate_objects(child_objects, object_path)
def main():
input_path = sys.argv[1]
deleted_paths_path = sys.argv[2]
output_path = sys.argv[3]
with open(deleted_paths_path, "r", encoding="utf-8") as deleted_file:
deleted_paths = set(json.load(deleted_file))
import FreeCAD
import ImportGui
document = FreeCAD.newDocument("TrimmedStep")
ImportGui.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)
ImportGui.export(document.Objects, output_path)
FreeCAD.closeDocument(document.Name)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,50 @@
import json
import os
import pathlib
import subprocess
import tempfile
from .step_tree import normalize_deleted_paths
class FreeCADWorker:
def __init__(self, freecad_cmd="FreeCADCmd"):
self.freecad_cmd = freecad_cmd
def trim_step_file(self, file_name, file_bytes, deleted_paths):
normalized_paths = normalize_deleted_paths(deleted_paths)
with tempfile.TemporaryDirectory(prefix="o3dv-step-") as temp_dir:
temp_path = pathlib.Path(temp_dir)
input_path = temp_path / file_name
output_name = self._build_output_name(file_name)
output_path = temp_path / output_name
delete_manifest_path = temp_path / "deleted_paths.json"
input_path.write_bytes(file_bytes)
delete_manifest_path.write_text(
json.dumps(normalized_paths),
encoding="utf-8",
)
subprocess.run(
[
self.freecad_cmd,
os.fspath(pathlib.Path(__file__).with_name("freecad_trim_step.py")),
os.fspath(input_path),
os.fspath(delete_manifest_path),
os.fspath(output_path),
],
check=True,
capture_output=True,
text=True,
)
return output_path.read_bytes(), output_name
def _build_output_name(self, file_name):
lower_name = file_name.lower()
if lower_name.endswith(".step"):
return file_name[:-5] + "-edited.step"
if lower_name.endswith(".stp"):
return file_name[:-4] + "-edited.stp"
return file_name + "-edited.step"

View File

@ -0,0 +1 @@
Flask==3.1.1

View File

@ -0,0 +1,50 @@
import pathlib
import sys
from flask import Flask, Response, jsonify, request
try:
from .freecad_worker import FreeCADWorker
from .step_tree import load_deleted_paths
except ImportError:
repo_root = pathlib.Path(__file__).resolve().parents[2]
if str(repo_root) not in sys.path:
sys.path.insert(0, str(repo_root))
from tools.step_service.freecad_worker import FreeCADWorker
from tools.step_service.step_tree import load_deleted_paths
def create_app(worker=None):
app = Flask(__name__)
step_worker = worker or FreeCADWorker()
@app.post("/save-step")
def save_step():
uploaded_file = request.files.get("file")
if uploaded_file is None or uploaded_file.filename == "":
return jsonify({"message": "Missing STEP file."}), 400
raw_deleted_paths = request.form.get("deletedPaths", "[]")
try:
deleted_paths = load_deleted_paths(raw_deleted_paths)
except ValueError as err:
return jsonify({"message": str(err)}), 400
output_bytes, output_name = step_worker.trim_step_file(
uploaded_file.filename,
uploaded_file.read(),
deleted_paths,
)
return Response(
output_bytes,
mimetype="application/step",
headers={
"Content-Disposition": f'attachment; filename="{output_name}"'
},
)
return app
if __name__ == "__main__":
create_app().run(host="127.0.0.1", port=17890, debug=False)

View File

@ -0,0 +1,24 @@
import json
def _is_prefix(parent_path: str, child_path: str) -> bool:
return child_path == parent_path or child_path.startswith(parent_path + "/")
def normalize_deleted_paths(paths):
normalized = []
for raw_path in sorted(set(paths)):
if not raw_path:
continue
if any(_is_prefix(existing, raw_path) for existing in normalized):
continue
normalized = [existing for existing in normalized if not _is_prefix(raw_path, existing)]
normalized.append(raw_path)
return normalized
def load_deleted_paths(raw_json: str):
parsed = json.loads(raw_json)
if not isinstance(parsed, list):
raise ValueError("deletedPaths must be a JSON array.")
return normalize_deleted_paths(parsed)

View File

@ -0,0 +1,39 @@
import io
import json
import unittest
from unittest import mock
from tools.step_service.server import create_app
class StepServerTests(unittest.TestCase):
def test_rejects_missing_file(self):
app = create_app(mock.Mock())
client = app.test_client()
response = client.post("/save-step", data={"deletedPaths": "[]"})
self.assertEqual(response.status_code, 400)
def test_streams_generated_step(self):
worker = mock.Mock()
worker.trim_step_file.return_value = (b"STEPDATA", "edited.step")
app = create_app(worker)
client = app.test_client()
response = client.post(
"/save-step",
data={
"file": (io.BytesIO(b"INPUT"), "demo.step"),
"deletedPaths": json.dumps(["0/1"]),
},
content_type="multipart/form-data",
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data, b"STEPDATA")
self.assertEqual(response.headers["Content-Disposition"], 'attachment; filename="edited.step"')
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,15 @@
import unittest
from tools.step_service.step_tree import normalize_deleted_paths
class StepTreeTests(unittest.TestCase):
def test_parent_path_removes_redundant_children(self):
self.assertEqual(
normalize_deleted_paths(["0/1", "0", "1/0", "1/0/2"]),
["0", "1/0"],
)
if __name__ == "__main__":
unittest.main()