feat: add step tree deletion demo
This commit is contained in:
parent
05bc283d59
commit
4e2e190268
28
README.md
28
README.md
@ -22,3 +22,31 @@ Online 3D Viewer (https://3dviewer.net) is a free and open source web solution t
|
|||||||
## External Libraries
|
## 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).
|
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
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "online-3d-viewer",
|
"name": "online-3d-viewer",
|
||||||
"version": "0.16.0",
|
"version": "0.18.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "online-3d-viewer",
|
"name": "online-3d-viewer",
|
||||||
"version": "0.16.0",
|
"version": "0.18.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@simonwep/pickr": "1.9.0",
|
"@simonwep/pickr": "1.9.0",
|
||||||
|
|||||||
@ -7,7 +7,8 @@ import { PanelSet } from './panelset.js';
|
|||||||
export const SelectionType =
|
export const SelectionType =
|
||||||
{
|
{
|
||||||
Material : 1,
|
Material : 1,
|
||||||
Mesh : 2
|
Mesh : 2,
|
||||||
|
Node : 3
|
||||||
};
|
};
|
||||||
|
|
||||||
export class Selection
|
export class Selection
|
||||||
@ -17,10 +18,13 @@ export class Selection
|
|||||||
this.type = type;
|
this.type = type;
|
||||||
this.materialIndex = null;
|
this.materialIndex = null;
|
||||||
this.meshInstanceId = null;
|
this.meshInstanceId = null;
|
||||||
|
this.nodeId = null;
|
||||||
if (this.type === SelectionType.Material) {
|
if (this.type === SelectionType.Material) {
|
||||||
this.materialIndex = data;
|
this.materialIndex = data;
|
||||||
} else if (this.type === SelectionType.Mesh) {
|
} else if (this.type === SelectionType.Mesh) {
|
||||||
this.meshInstanceId = data;
|
this.meshInstanceId = data;
|
||||||
|
} else if (this.type === SelectionType.Node) {
|
||||||
|
this.nodeId = data;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -33,7 +37,10 @@ export class Selection
|
|||||||
return this.materialIndex === rhs.materialIndex;
|
return this.materialIndex === rhs.materialIndex;
|
||||||
} else if (this.type === SelectionType.Mesh) {
|
} else if (this.type === SelectionType.Mesh) {
|
||||||
return this.meshInstanceId.IsEqual (rhs.meshInstanceId);
|
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 ({
|
this.meshesPanel.Init ({
|
||||||
|
onNodeSelected : (nodeId) => {
|
||||||
|
this.SetSelection (new Selection (SelectionType.Node, nodeId));
|
||||||
|
},
|
||||||
onMeshSelected : (meshId) => {
|
onMeshSelected : (meshId) => {
|
||||||
this.SetSelection (new Selection (SelectionType.Mesh, meshId));
|
this.SetSelection (new Selection (SelectionType.Mesh, meshId));
|
||||||
},
|
},
|
||||||
@ -223,6 +233,11 @@ export class Navigator
|
|||||||
navigator.panelSet.ShowPanel (navigator.meshesPanel);
|
navigator.panelSet.ShowPanel (navigator.meshesPanel);
|
||||||
}
|
}
|
||||||
navigator.meshesPanel.GetMeshItem (selection.meshInstanceId).SetSelected (select);
|
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);
|
this.callbacks.onMaterialSelected (this.selection.materialIndex);
|
||||||
} else if (this.selection.type === SelectionType.Mesh) {
|
} else if (this.selection.type === SelectionType.Mesh) {
|
||||||
this.callbacks.onMeshSelected (this.selection.meshInstanceId);
|
this.callbacks.onMeshSelected (this.selection.meshInstanceId);
|
||||||
|
} else if (this.selection.type === SelectionType.Node) {
|
||||||
|
this.callbacks.onNodeSelected (this.selection.nodeId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.UpdatePanels ();
|
this.UpdatePanels ();
|
||||||
|
|||||||
@ -96,6 +96,12 @@ export class NodeItem extends TreeViewGroupButtonItem
|
|||||||
this.callbacks.onShowHide (nodeId);
|
this.callbacks.onShowHide (nodeId);
|
||||||
});
|
});
|
||||||
this.AppendButton (this.showHideButton);
|
this.AppendButton (this.showHideButton);
|
||||||
|
|
||||||
|
if (IsDefined (this.callbacks.onSelected)) {
|
||||||
|
this.OnClick (() => {
|
||||||
|
this.callbacks.onSelected (nodeId);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
GetNodeId ()
|
GetNodeId ()
|
||||||
|
|||||||
@ -70,6 +70,7 @@ export class NavigatorMeshesPanel extends NavigatorPanel
|
|||||||
super (parentDiv);
|
super (parentDiv);
|
||||||
|
|
||||||
this.callbacks = null;
|
this.callbacks = null;
|
||||||
|
this.stepDeletionState = null;
|
||||||
this.nodeIdToItem = new Map ();
|
this.nodeIdToItem = new Map ();
|
||||||
this.meshInstanceIdToItem = new Map ();
|
this.meshInstanceIdToItem = new Map ();
|
||||||
this.rootItem = null;
|
this.rootItem = null;
|
||||||
@ -143,6 +144,7 @@ export class NavigatorMeshesPanel extends NavigatorPanel
|
|||||||
Fill (importResult)
|
Fill (importResult)
|
||||||
{
|
{
|
||||||
super.Fill (importResult);
|
super.Fill (importResult);
|
||||||
|
this.stepDeletionState = importResult.stepDeletionState || null;
|
||||||
|
|
||||||
const rootNode = importResult.model.GetRootNode ();
|
const rootNode = importResult.model.GetRootNode ();
|
||||||
let isHierarchical = false;
|
let isHierarchical = false;
|
||||||
@ -345,6 +347,9 @@ export class NavigatorMeshesPanel extends NavigatorPanel
|
|||||||
const nodeName = GetNodeName (node.GetName ());
|
const nodeName = GetNodeName (node.GetName ());
|
||||||
const nodeId = node.GetId ();
|
const nodeId = node.GetId ();
|
||||||
let nodeItem = new NodeItem (nodeName, nodeId, {
|
let nodeItem = new NodeItem (nodeName, nodeId, {
|
||||||
|
onSelected : (selectedNodeId) => {
|
||||||
|
panel.callbacks.onNodeSelected (selectedNodeId);
|
||||||
|
},
|
||||||
onShowHide : (selectedNodeId) => {
|
onShowHide : (selectedNodeId) => {
|
||||||
panel.callbacks.onNodeShowHide (selectedNodeId);
|
panel.callbacks.onNodeShowHide (selectedNodeId);
|
||||||
},
|
},
|
||||||
@ -377,6 +382,10 @@ export class NavigatorMeshesPanel extends NavigatorPanel
|
|||||||
|
|
||||||
function AddModelNodeToTree (panel, model, node, parentItem, mode)
|
function AddModelNodeToTree (panel, model, node, parentItem, mode)
|
||||||
{
|
{
|
||||||
|
if (panel.stepDeletionState !== null && panel.stepDeletionState.IsNodeDeletedByPath (panel.stepDeletionState.GetNodePath (node.GetId ()))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let meshNodes = [];
|
let meshNodes = [];
|
||||||
for (let childNode of node.GetChildNodes ()) {
|
for (let childNode of node.GetChildNodes ()) {
|
||||||
if (mode === MeshesPanelMode.TreeView) {
|
if (mode === MeshesPanelMode.TreeView) {
|
||||||
@ -396,6 +405,12 @@ export class NavigatorMeshesPanel extends NavigatorPanel
|
|||||||
}
|
}
|
||||||
for (let meshIndex of node.GetMeshIndices ()) {
|
for (let meshIndex of node.GetMeshIndices ()) {
|
||||||
let mesh = model.GetMesh (meshIndex);
|
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);
|
AddMeshToNodeTree (panel, node, mesh, meshIndex, parentItem, mode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
104
source/website/stepdeletionstate.js
Normal file
104
source/website/stepdeletionstate.js
Normal 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 ();
|
||||||
|
}
|
||||||
|
}
|
||||||
16
source/website/stepfileutils.js
Normal file
16
source/website/stepfileutils.js
Normal 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';
|
||||||
|
}
|
||||||
26
source/website/stepsaveservice.js
Normal file
26
source/website/stepsaveservice.js
Normal 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 ();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -45,6 +45,7 @@ export class TreeViewItem
|
|||||||
{
|
{
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.parent = null;
|
this.parent = null;
|
||||||
|
this.selected = false;
|
||||||
this.mainElement = CreateDiv ('ov_tree_item');
|
this.mainElement = CreateDiv ('ov_tree_item');
|
||||||
this.mainElement.setAttribute ('title', this.name);
|
this.mainElement.setAttribute ('title', this.name);
|
||||||
this.nameElement = AddDiv (this.mainElement, 'ov_tree_item_name', this.name);
|
this.nameElement = AddDiv (this.mainElement, 'ov_tree_item_name', this.name);
|
||||||
@ -70,15 +71,6 @@ export class TreeViewItem
|
|||||||
{
|
{
|
||||||
parentDiv.appendChild (this.mainElement);
|
parentDiv.appendChild (this.mainElement);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
export class TreeViewSingleItem extends TreeViewItem
|
|
||||||
{
|
|
||||||
constructor (name, icon)
|
|
||||||
{
|
|
||||||
super (name, icon);
|
|
||||||
this.selected = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
SetSelected (selected)
|
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
|
export class TreeViewButtonItem extends TreeViewSingleItem
|
||||||
{
|
{
|
||||||
constructor (name, icon)
|
constructor (name, icon)
|
||||||
@ -188,7 +188,8 @@ export class TreeViewGroupItem extends TreeViewItem
|
|||||||
InsertDomElementAfter (this.childrenDiv, this.mainElement);
|
InsertDomElementAfter (this.childrenDiv, this.mainElement);
|
||||||
this.Show (this.isVisible);
|
this.Show (this.isVisible);
|
||||||
this.ShowChildren (this.isChildrenVisible);
|
this.ShowChildren (this.isChildrenVisible);
|
||||||
this.OnClick ((ev) => {
|
this.openCloseButton.addEventListener ('click', (ev) => {
|
||||||
|
ev.stopPropagation ();
|
||||||
this.isChildrenVisible = !this.isChildrenVisible;
|
this.isChildrenVisible = !this.isChildrenVisible;
|
||||||
this.ShowChildren (this.isChildrenVisible);
|
this.ShowChildren (this.isChildrenVisible);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { NavigationMode, ProjectionMode } from '../engine/viewer/camera.js';
|
|||||||
import { RGBColor } from '../engine/model/color.js';
|
import { RGBColor } from '../engine/model/color.js';
|
||||||
import { Viewer } from '../engine/viewer/viewer.js';
|
import { Viewer } from '../engine/viewer/viewer.js';
|
||||||
import { AddDiv, AddDomElement, ShowDomElement, SetDomElementOuterHeight, CreateDomElement, GetDomElementOuterWidth } from '../engine/viewer/domutils.js';
|
import { AddDiv, AddDomElement, ShowDomElement, SetDomElementOuterHeight, CreateDomElement, GetDomElementOuterWidth } from '../engine/viewer/domutils.js';
|
||||||
import { CalculatePopupPositionToScreen, ShowListPopup } from './dialogs.js';
|
import { CalculatePopupPositionToScreen, ShowListPopup, ShowMessageDialog } from './dialogs.js';
|
||||||
import { HandleEvent } from './eventhandler.js';
|
import { HandleEvent } from './eventhandler.js';
|
||||||
import { HashHandler } from './hashhandler.js';
|
import { HashHandler } from './hashhandler.js';
|
||||||
import { Navigator, Selection, SelectionType } from './navigator.js';
|
import { Navigator, Selection, SelectionType } from './navigator.js';
|
||||||
@ -16,7 +16,7 @@ import { ThreeModelLoaderUI } from './threemodelloaderui.js';
|
|||||||
import { Toolbar } from './toolbar.js';
|
import { Toolbar } from './toolbar.js';
|
||||||
import { DownloadModel, ShowExportDialog } from './exportdialog.js';
|
import { DownloadModel, ShowExportDialog } from './exportdialog.js';
|
||||||
import { ShowSnapshotDialog } from './snapshotdialog.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 { ShowOpenUrlDialog } from './openurldialog.js';
|
||||||
import { ShowSharingDialog } from './sharingdialog.js';
|
import { ShowSharingDialog } from './sharingdialog.js';
|
||||||
import { GetDefaultMaterials, ReplaceDefaultMaterialsColor } from '../engine/model/modelutils.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 { EnvironmentSettings } from '../engine/viewer/shadingmodel.js';
|
||||||
import { IntersectionMode } from '../engine/viewer/viewermodel.js';
|
import { IntersectionMode } from '../engine/viewer/viewermodel.js';
|
||||||
import { Loc } from '../engine/core/localization.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 =
|
const WebsiteUIState =
|
||||||
{
|
{
|
||||||
@ -198,6 +201,10 @@ export class Website
|
|||||||
this.uiState = WebsiteUIState.Undefined;
|
this.uiState = WebsiteUIState.Undefined;
|
||||||
this.layouter = new WebsiteLayouter (this.parameters, this.navigator, this.sidebar, this.viewer, this.measureTool);
|
this.layouter = new WebsiteLayouter (this.parameters, this.navigator, this.sidebar, this.viewer, this.measureTool);
|
||||||
this.model = null;
|
this.model = null;
|
||||||
|
this.stepDeletionState = null;
|
||||||
|
this.deleteSelectedButton = null;
|
||||||
|
this.stepSaveButton = null;
|
||||||
|
this.stepSaveService = new StepSaveService ('http://127.0.0.1:17890');
|
||||||
}
|
}
|
||||||
|
|
||||||
Load ()
|
Load ()
|
||||||
@ -282,9 +289,11 @@ export class Website
|
|||||||
CloseAllDialogs ();
|
CloseAllDialogs ();
|
||||||
|
|
||||||
this.model = null;
|
this.model = null;
|
||||||
|
this.stepDeletionState = null;
|
||||||
this.viewer.Clear ();
|
this.viewer.Clear ();
|
||||||
|
|
||||||
this.parameters.fileNameDiv.innerHTML = '';
|
this.parameters.fileNameDiv.innerHTML = '';
|
||||||
|
this.UpdateStepButtonsVisibility (false);
|
||||||
|
|
||||||
this.navigator.Clear ();
|
this.navigator.Clear ();
|
||||||
this.sidebar.Clear ();
|
this.sidebar.Clear ();
|
||||||
@ -296,9 +305,19 @@ export class Website
|
|||||||
{
|
{
|
||||||
this.model = importResult.model;
|
this.model = importResult.model;
|
||||||
this.parameters.fileNameDiv.innerHTML = importResult.mainFile;
|
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.SetMainObject (threeObject);
|
||||||
this.viewer.SetUpVector (Direction.Y, false);
|
this.viewer.SetUpVector (Direction.Y, false);
|
||||||
this.navigator.FillTree (importResult);
|
this.navigator.FillTree ({
|
||||||
|
...importResult,
|
||||||
|
stepDeletionState : this.stepDeletionState
|
||||||
|
});
|
||||||
this.sidebar.UpdateControlsVisibility ();
|
this.sidebar.UpdateControlsVisibility ();
|
||||||
this.FitModelToWindow (true);
|
this.FitModelToWindow (true);
|
||||||
}
|
}
|
||||||
@ -454,10 +473,110 @@ export class Website
|
|||||||
UpdateMeshesVisibility ()
|
UpdateMeshesVisibility ()
|
||||||
{
|
{
|
||||||
this.viewer.SetMeshesVisibility ((meshUserData) => {
|
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 ()
|
UpdateMeshesSelection ()
|
||||||
{
|
{
|
||||||
let selectedMeshId = this.navigator.GetSelectedMeshId ();
|
let selectedMeshId = this.navigator.GetSelectedMeshId ();
|
||||||
@ -714,6 +833,12 @@ export class Website
|
|||||||
let importer = this.modelLoaderUI.GetImporter ();
|
let importer = this.modelLoaderUI.GetImporter ();
|
||||||
DownloadModel (importer);
|
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'], () => {
|
AddButton (this.toolbar, 'export', Loc ('Export'), ['only_full_width', 'only_on_model'], () => {
|
||||||
ShowExportDialog (this.model, this.viewer, {
|
ShowExportDialog (this.model, this.viewer, {
|
||||||
isMeshVisible : (meshInstanceId) => {
|
isMeshVisible : (meshInstanceId) => {
|
||||||
@ -760,6 +885,8 @@ export class Website
|
|||||||
this.LoadModelFromFileList (ev.target.files);
|
this.LoadModelFromFileList (ev.target.files);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.UpdateStepButtonsVisibility (false);
|
||||||
}
|
}
|
||||||
|
|
||||||
InitDragAndDrop ()
|
InitDragAndDrop ()
|
||||||
@ -924,6 +1051,9 @@ export class Website
|
|||||||
let meshInstance = this.model.GetMeshInstance (meshInstanceId);
|
let meshInstance = this.model.GetMeshInstance (meshInstanceId);
|
||||||
this.sidebar.AddObject3DProperties (this.model, meshInstance);
|
this.sidebar.AddObject3DProperties (this.model, meshInstance);
|
||||||
},
|
},
|
||||||
|
onNodeSelected : () => {
|
||||||
|
this.sidebar.AddObject3DProperties (this.model, this.model);
|
||||||
|
},
|
||||||
onMaterialSelected : (materialIndex) => {
|
onMaterialSelected : (materialIndex) => {
|
||||||
this.sidebar.AddMaterialProperties (this.model.GetMaterial (materialIndex));
|
this.sidebar.AddMaterialProperties (this.model.GetMaterial (materialIndex));
|
||||||
},
|
},
|
||||||
|
|||||||
@ -28,6 +28,8 @@ import exporter_test from './tests/exporter_test.js';
|
|||||||
import exportimport_test from './tests/exportimport_test.js';
|
import exportimport_test from './tests/exportimport_test.js';
|
||||||
import property_test from './tests/property_test.js';
|
import property_test from './tests/property_test.js';
|
||||||
import parameterlist_test from './tests/parameterlist_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 ());
|
process.chdir (path.resolve ());
|
||||||
SetGlobals ();
|
SetGlobals ();
|
||||||
@ -58,3 +60,5 @@ exporter_test ();
|
|||||||
exportimport_test ();
|
exportimport_test ();
|
||||||
property_test ();
|
property_test ();
|
||||||
parameterlist_test ();
|
parameterlist_test ();
|
||||||
|
stepdeletionstate_test ();
|
||||||
|
stepfileutils_test ();
|
||||||
|
|||||||
57
test/tests/stepdeletionstate_test.js
Normal file
57
test/tests/stepdeletionstate_test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
20
test/tests/stepfileutils_test.js
Normal file
20
test/tests/stepfileutils_test.js
Normal 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
1
tools/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Tool package for local development helpers."""
|
||||||
1
tools/step_service/__init__.py
Normal file
1
tools/step_service/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Local STEP save service package."""
|
||||||
38
tools/step_service/freecad_trim_step.py
Normal file
38
tools/step_service/freecad_trim_step.py
Normal 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()
|
||||||
50
tools/step_service/freecad_worker.py
Normal file
50
tools/step_service/freecad_worker.py
Normal 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"
|
||||||
1
tools/step_service/requirements.txt
Normal file
1
tools/step_service/requirements.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
Flask==3.1.1
|
||||||
50
tools/step_service/server.py
Normal file
50
tools/step_service/server.py
Normal 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)
|
||||||
24
tools/step_service/step_tree.py
Normal file
24
tools/step_service/step_tree.py
Normal 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)
|
||||||
39
tools/step_service/tests/test_server.py
Normal file
39
tools/step_service/tests/test_server.py
Normal 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()
|
||||||
15
tools/step_service/tests/test_step_tree.py
Normal file
15
tools/step_service/tests/test_step_tree.py
Normal 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()
|
||||||
Loading…
Reference in New Issue
Block a user