diff --git a/CombineModels/CombineModels.py b/CombineModels/CombineModels.py
index 70afac3..d4921bb 100644
--- a/CombineModels/CombineModels.py
+++ b/CombineModels/CombineModels.py
@@ -3,6 +3,8 @@
import logging
import vtk, qt, ctk, slicer
from slicer.ScriptedLoadableModule import *
+from slicer.i18n import tr as _
+from slicer.i18n import translate
from slicer.util import VTKObservationMixin
#
@@ -92,6 +94,12 @@ def setup(self):
self.ui.operationDifferenceRadioButton.connect("toggled(bool)", lambda toggled, op="difference": self.operationButtonToggled(op))
self.ui.operationDifference2RadioButton.connect("toggled(bool)", lambda toggled, op="difference2": self.operationButtonToggled(op))
+ self.ui.triangulateInputsCheckBox.connect("stateChanged(int)", self.updateParameterNodeFromGUI)
+
+ # Spin Boxes
+ self.ui.numberOfRetriesSpinBox.valueChanged.connect(self.updateParameterNodeFromGUI)
+ self.ui.randomTranslationMagnitudeSpinBox.valueChanged.connect(self.updateParameterNodeFromGUI)
+
# Buttons
self.ui.applyButton.connect('clicked(bool)', self.onApplyButton)
self.ui.toggleVisibilityButton.connect('clicked(bool)', self.onToggleVisibilityButton)
@@ -198,6 +206,24 @@ def updateGUIFromParameterNode(self, caller=None, event=None):
self.ui.toggleVisibilityButton.enabled = (self._parameterNode.GetNodeReference("OutputModel") is not None)
+ # translate randomly order of magnitude (value is negative by default)
+ randomTranslationMagnitude = int(self._parameterNode.GetParameter("randomTranslationMagnitude"))
+ self.ui.randomTranslationMagnitudeSpinBox.value = randomTranslationMagnitude
+
+ numberOfRetries = int(self._parameterNode.GetParameter("numberOfRetries"))
+ self.ui.numberOfRetriesSpinBox.value = numberOfRetries
+ if numberOfRetries > 0:
+ self.ui.numberOfRetriesSpinBox.toolTip = "Model B will be randomized if operation fails"
+ self.ui.randomTranslationMagnitudeSpinBox.enabled = True
+ randomTranslationAmount = 10**-randomTranslationMagnitude
+ self.ui.randomTranslationMagnitudeSpinBox.toolTip = f"If the operation fails, it will retry with a random translation of {randomTranslationAmount}"
+ else:
+ self.ui.numberOfRetriesSpinBox.toolTip = "Computation will be attempted only with exact inputs."
+ self.ui.randomTranslationMagnitudeSpinBox.enabled = False
+ self.ui.randomTranslationMagnitudeSpinBox.toolTip = "Set a number of retries to larger than 0"
+
+ self.ui.triangulateInputsCheckBox.checked = self._parameterNode.GetParameter("triangulateInputs") == "True"
+
# All the GUI updates are done
self._updatingGUIFromParameterNode = False
@@ -216,6 +242,11 @@ def updateParameterNodeFromGUI(self, caller=None, event=None):
self._parameterNode.SetNodeReferenceID("InputModelB", self.ui.inputModelBSelector.currentNodeID)
self._parameterNode.SetNodeReferenceID("OutputModel", self.ui.outputModelSelector.currentNodeID)
+ self._parameterNode.SetParameter("numberOfRetries", str(self.ui.numberOfRetriesSpinBox.value))
+ self._parameterNode.SetParameter("randomTranslationMagnitude", str(self.ui.randomTranslationMagnitudeSpinBox.value))
+
+ self._parameterNode.SetParameter("triangulateInputs", "true" if self.ui.triangulateInputsCheckBox.checked else "false")
+
self._parameterNode.EndModify(wasModified)
def operationButtonToggled(self, operation):
@@ -237,7 +268,11 @@ def onApplyButton(self):
self._parameterNode.GetNodeReference("InputModelA"),
self._parameterNode.GetNodeReference("InputModelB"),
self._parameterNode.GetNodeReference("OutputModel"),
- self._parameterNode.GetParameter("Operation"))
+ self._parameterNode.GetParameter("Operation"),
+ int(self._parameterNode.GetParameter("numberOfRetries")),
+ int(self._parameterNode.GetParameter("randomTranslationMagnitude")),
+ self._parameterNode.GetParameter("triangulateInputs") == "true"
+ )
except Exception as e:
slicer.util.errorDisplay("Failed to compute results: "+str(e))
@@ -285,8 +320,23 @@ def setDefaultParameters(self, parameterNode):
"""
if not parameterNode.GetParameter("Operation"):
parameterNode.SetParameter("Operation", "union")
-
- def process(self, inputModelA, inputModelB, outputModel, operation):
+ if not parameterNode.GetParameter("numberOfRetries"):
+ parameterNode.SetParameter("numberOfRetries", "2")
+ if not parameterNode.GetParameter("randomTranslationMagnitude"):
+ parameterNode.SetParameter("randomTranslationMagnitude", "4")
+ if not parameterNode.GetParameter("triangulateInputs"):
+ parameterNode.SetParameter("triangulateInputs", "true")
+
+ def process(
+ self,
+ inputModelA,
+ inputModelB,
+ outputModel,
+ operation,
+ numberOfRetries = 2,
+ randomTranslationMagnitude = 4,
+ triangulateInputs = True
+ ):
"""
Run the processing algorithm.
Can be used without GUI widget.
@@ -294,6 +344,9 @@ def process(self, inputModelA, inputModelB, outputModel, operation):
:param inputModelB: second input model node
:param outputModel: result model node, if empty then a new output node will be created
:param operation: union, intersection, difference, difference2
+ :param numberOfRetries: number of retries if operation fails
+ :param randomTranslationMagnitude: order of magnitude of the random translation
+ :param triangulateInputs: triangulate input models before boolean operation
"""
if not inputModelA or not inputModelB or not outputModel:
@@ -318,32 +371,122 @@ def process(self, inputModelA, inputModelB, outputModel, operation):
else:
raise ValueError("Invalid operation: "+operation)
- if inputModelA.GetParentTransformNode() == outputModel.GetParentTransformNode():
- combine.SetInputConnection(0, inputModelA.GetPolyDataConnection())
- else:
- transformToOutput = vtk.vtkGeneralTransform()
- slicer.vtkMRMLTransformNode.GetTransformBetweenNodes(inputModelA.GetParentTransformNode(), outputModel.GetParentTransformNode(), transformToOutput)
- transformer = vtk.vtkTransformPolyDataFilter()
- transformer.SetTransform(transformToOutput)
- transformer.SetInputConnection(inputModelA.GetPolyDataConnection())
- combine.SetInputConnection(0, transformer.GetOutputPort())
-
- if inputModelB.GetParentTransformNode() == outputModel.GetParentTransformNode():
- combine.SetInputConnection(1, inputModelB.GetPolyDataConnection())
- else:
- transformToOutput = vtk.vtkGeneralTransform()
- slicer.vtkMRMLTransformNode.GetTransformBetweenNodes(inputModelB.GetParentTransformNode(), outputModel.GetParentTransformNode(), transformToOutput)
- transformer = vtk.vtkTransformPolyDataFilter()
- transformer.SetTransform(transformToOutput)
- transformer.SetInputConnection(inputModelB.GetPolyDataConnection())
- combine.SetInputConnection(1, transformer.GetOutputPort())
-
- # These parameters might be useful to expose:
- # combine.MergeRegsOn() # default off
- # combine.DecPolysOff() # default on
- combine.Update()
-
- outputModel.SetAndObservePolyData(combine.GetOutput())
+ inputpolyData_Output = [] # polydata inputs in "Output" coordinate system
+ for inputModel in [inputModelA, inputModelB]:
+ transformToOutput = vtk.vtkGeneralTransform()
+ slicer.vtkMRMLTransformNode.GetTransformBetweenNodes(inputModel.GetParentTransformNode(), outputModel.GetParentTransformNode(), transformToOutput)
+ transformerToOutput = vtk.vtkTransformPolyDataFilter()
+ transformerToOutput.SetTransform(transformToOutput)
+ if triangulateInputs:
+ triangulatedInputModel = vtk.vtkTriangleFilter()
+ triangulatedInputModel.SetInputData(inputModel.GetPolyData())
+ triangulatedInputModel.Update()
+ transformerToOutput.SetInputData(triangulatedInputModel.GetPolyData())
+ else:
+ transformerToOutput.SetInputData(inputModel.GetPolyData())
+ transformerToOutput.Update()
+ inputpolyData_Output.append(transformerToOutput.GetOutput())
+
+ polydataA = inputpolyData_Output[0]
+ polydataB = inputpolyData_Output[1]
+
+ # First handle cases where inputs are not valid otherwise the boolean filter will crash
+ modelAEmpty = polydataA.GetNumberOfPoints() == 0
+ modelBEmpty = polydataB.GetNumberOfPoints() == 0
+ if modelAEmpty and modelBEmpty:
+ # both inputs are empty, output is empty regardless of the operation
+ outputModel.SetAndObservePolyData(vtk.vtkPolyData())
+ return
+ elif modelAEmpty or modelBEmpty:
+ # exactly one input is empty
+ if operation == "union":
+ outputModel.SetAndObservePolyData(polydataA if modelBEmpty else polydataB)
+ return
+ elif operation == "intersection":
+ # if one model is empty then intersection is empty
+ outputModel.SetAndObservePolyData(vtk.vtkPolyData())
+ return
+ elif operation == "difference":
+ # A-B
+ outputModel.SetAndObservePolyData(polydataA if modelBEmpty else vtk.vtkPolyData())
+ return
+ elif operation == "difference2":
+ # B-A
+ outputModel.SetAndObservePolyData(polydataB if modelAEmpty else vtk.vtkPolyData())
+ return
+
+ # We need to combine the models
+ polydataBOriginal = polydataB
+ for attemptIndex in range(numberOfRetries+1):
+
+ if attemptIndex == 0:
+ polydataB = polydataBOriginal
+ else:
+ # Add random translation to model B
+ # https://github.com/zippy84/vtkbool/issues/81
+ logging.info(f"Retrying boolean operation with random translation (attempt {attemptIndex+1})")
+ transform = vtk.vtkTransform()
+ unitVector = [vtk.vtkMath.Random()-0.5 for _ in range(3)]
+ vtk.vtkMath.Normalize(unitVector)
+ import numpy as np
+ translationVector = np.array(unitVector) * (10**-randomTranslationMagnitude)
+ perturbationTransform = vtk.vtkTransform()
+ perturbationTransform.Translate(translationVector)
+ perturbationTransformer = vtk.vtkTransformPolyDataFilter()
+ perturbationTransformer.SetTransform(perturbationTransform)
+ perturbationTransformer.SetInputData(polydataBOriginal)
+ perturbationTransformer.Update()
+ polydataB = perturbationTransformer.GetOutput()
+
+ # Calculate the result using Boolean operation
+ combine.SetInputData(0, polydataA)
+ combine.SetInputData(1, polydataB)
+ # These parameters might be useful to expose:
+ # combine.MergeRegsOn() # default off
+ # combine.DecPolysOff() # default on
+ combine.Update()
+
+ combineFilterSuccessful = combine.GetOutput().GetNumberOfPoints() != 0
+ if combineFilterSuccessful:
+ polydataCombined = combine.GetOutput()
+ break
+
+ # Boolean operation failed, but if the meshes are not intersecting
+ # then it may be a special case that we can handle with simpler methods
+ collisionDetectionFilter = vtk.vtkCollisionDetectionFilter()
+ collisionDetectionFilter.SetInputData(0, polydataA)
+ collisionDetectionFilter.SetInputData(1, polydataB)
+ identityMatrix = vtk.vtkMatrix4x4()
+ collisionDetectionFilter.SetMatrix(0,identityMatrix)
+ collisionDetectionFilter.SetMatrix(1,identityMatrix)
+ collisionDetectionFilter.SetCollisionModeToFirstContact()
+ collisionDetectionFilter.Update()
+ intersecting = collisionDetectionFilter.GetNumberOfContacts() > 0
+ if intersecting:
+ # this is not a trivial case, try again
+ continue
+
+ # No intersection, we can do without computing Boolean operation
+ if operation == 'union':
+ # models do not touch so we can simply append them
+ appendFilter = vtk.vtkAppendPolyData()
+ appendFilter.AddInputData(polydataA)
+ appendFilter.AddInputData(polydataB)
+ appendFilter.Update()
+ polydataCombined = appendFilter.GetOutput()
+ break
+ elif operation == 'intersection':
+ # models do not touch so we return an empty model
+ polydataCombined = vtk.vtkPolyData()
+ break
+ elif operation == 'difference': # A-B
+ polydataCombined = polydataA
+ break
+ elif operation == 'difference2': # B-A
+ polydataCombined = polydataB
+ break
+
+ outputModel.SetAndObservePolyData(polydataCombined)
outputModel.CreateDefaultDisplayNodes()
# The filter creates a few scalars, don't show them by default, as they would be somewhat distracting
outputModel.GetDisplayNode().SetScalarVisibility(False)
diff --git a/CombineModels/Resources/UI/CombineModels.ui b/CombineModels/Resources/UI/CombineModels.ui
index 2278914..a710ec5 100644
--- a/CombineModels/Resources/UI/CombineModels.ui
+++ b/CombineModels/Resources/UI/CombineModels.ui
@@ -6,8 +6,8 @@
0
0
- 279
- 372
+ 350
+ 582
@@ -199,6 +199,90 @@
+ -
+
+
+ Advanced
+
+
+ true
+
+
+
-
+
+
+ Triangulate input models:
+
+
+
+ -
+
+
+
+
+
+
+ -
+
+
+ Retry failed operations
+
+
+
-
+
+
+ Number of retries:
+
+
+
+ -
+
+
+ 5
+
+
+ 2
+
+
+
+ -
+
+
+ Translation magnitude:
+
+
+
+ -
+
+
+ 1e
+
+
+ 0
+
+
+ -5.000000000000000
+
+
+ 0.000000000000000
+
+
+ -4.000000000000000
+
+
+ length
+
+
+ qMRMLSpinBox::MaximumValue|qMRMLSpinBox::MinimumValue|qMRMLSpinBox::Suffix
+
+
+
+
+
+
+
+
+
-
@@ -247,11 +331,21 @@
1
+
+ ctkDoubleSpinBox
+ QWidget
+
+
qMRMLNodeComboBox
QWidget
+
+ qMRMLSpinBox
+ ctkDoubleSpinBox
+
+
qMRMLWidget
QWidget
@@ -288,8 +382,8 @@
135
- 260
- 278
+ 331
+ 237
@@ -309,5 +403,21 @@
+
+ CombineModels
+ mrmlSceneChanged(vtkMRMLScene*)
+ randomTranslationMagnitudeSpinBox
+ setMRMLScene(vtkMRMLScene*)
+
+
+ 283
+ 549
+
+
+ 228
+ 372
+
+
+