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