diff --git a/README.md b/README.md index b8e0757..6ab45f9 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/package-lock.json b/package-lock.json index 64d532f..1414fca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/source/website/navigator.js b/source/website/navigator.js index 36a8534..e5e5e6e 100644 --- a/source/website/navigator.js +++ b/source/website/navigator.js @@ -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 (); diff --git a/source/website/navigatoritems.js b/source/website/navigatoritems.js index d3134eb..cfb5a89 100644 --- a/source/website/navigatoritems.js +++ b/source/website/navigatoritems.js @@ -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 () diff --git a/source/website/navigatormeshespanel.js b/source/website/navigatormeshespanel.js index d9a9c25..845ab3e 100644 --- a/source/website/navigatormeshespanel.js +++ b/source/website/navigatormeshespanel.js @@ -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); } } diff --git a/source/website/stepdeletionstate.js b/source/website/stepdeletionstate.js new file mode 100644 index 0000000..f9277dd --- /dev/null +++ b/source/website/stepdeletionstate.js @@ -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 (); + } +} diff --git a/source/website/stepfileutils.js b/source/website/stepfileutils.js new file mode 100644 index 0000000..7e1db1d --- /dev/null +++ b/source/website/stepfileutils.js @@ -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'; +} diff --git a/source/website/stepsaveservice.js b/source/website/stepsaveservice.js new file mode 100644 index 0000000..28c3a10 --- /dev/null +++ b/source/website/stepsaveservice.js @@ -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 (); + } +} diff --git a/source/website/treeview.js b/source/website/treeview.js index adb6e80..0e04689 100644 --- a/source/website/treeview.js +++ b/source/website/treeview.js @@ -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); }); diff --git a/source/website/website.js b/source/website/website.js index 682cc26..1c62111 100644 --- a/source/website/website.js +++ b/source/website/website.js @@ -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)); }, diff --git a/test/test.js b/test/test.js index b5ac6a6..9f050ff 100644 --- a/test/test.js +++ b/test/test.js @@ -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 (); diff --git a/test/tests/stepdeletionstate_test.js b/test/tests/stepdeletionstate_test.js new file mode 100644 index 0000000..99a4dc4 --- /dev/null +++ b/test/tests/stepdeletionstate_test.js @@ -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); + }); +}); + +} diff --git a/test/tests/stepfileutils_test.js b/test/tests/stepfileutils_test.js new file mode 100644 index 0000000..cffbc7d --- /dev/null +++ b/test/tests/stepfileutils_test.js @@ -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'); + }); +}); + +} diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 0000000..2cb62fb --- /dev/null +++ b/tools/__init__.py @@ -0,0 +1 @@ +"""Tool package for local development helpers.""" diff --git a/tools/step_service/__init__.py b/tools/step_service/__init__.py new file mode 100644 index 0000000..6e4d7fe --- /dev/null +++ b/tools/step_service/__init__.py @@ -0,0 +1 @@ +"""Local STEP save service package.""" diff --git a/tools/step_service/freecad_trim_step.py b/tools/step_service/freecad_trim_step.py new file mode 100644 index 0000000..69e7ee0 --- /dev/null +++ b/tools/step_service/freecad_trim_step.py @@ -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() diff --git a/tools/step_service/freecad_worker.py b/tools/step_service/freecad_worker.py new file mode 100644 index 0000000..ced2359 --- /dev/null +++ b/tools/step_service/freecad_worker.py @@ -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" diff --git a/tools/step_service/requirements.txt b/tools/step_service/requirements.txt new file mode 100644 index 0000000..aa4b129 --- /dev/null +++ b/tools/step_service/requirements.txt @@ -0,0 +1 @@ +Flask==3.1.1 diff --git a/tools/step_service/server.py b/tools/step_service/server.py new file mode 100644 index 0000000..1a272a3 --- /dev/null +++ b/tools/step_service/server.py @@ -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) diff --git a/tools/step_service/step_tree.py b/tools/step_service/step_tree.py new file mode 100644 index 0000000..aba2162 --- /dev/null +++ b/tools/step_service/step_tree.py @@ -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) diff --git a/tools/step_service/tests/test_server.py b/tools/step_service/tests/test_server.py new file mode 100644 index 0000000..805dfad --- /dev/null +++ b/tools/step_service/tests/test_server.py @@ -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() diff --git a/tools/step_service/tests/test_step_tree.py b/tools/step_service/tests/test_step_tree.py new file mode 100644 index 0000000..59477e5 --- /dev/null +++ b/tools/step_service/tests/test_step_tree.py @@ -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()