Decomposing a part#
How to run the decomposition on your own file, read what comes back, and change how it behaves. For what the method actually does and why the answers overlap, read How a part is broken into simple blocks first — it will save you a lot of confusion here.
Note
Prerequisites: How a part is broken into simple blocks
Tip
Tutorial data file:
zhang2020_figure_6_a.step
Decomposing#
Hand Decomposer a file and ask it what came out. Your
solid is never modified.
from volmdlr_tools.decomposition import Decomposer
part = Decomposer.from_step("zhang2020_figure_6_a.step")
print(part.summary())
zhang2020_figure_6_a: 3 cells, 2 maximal volumes, 100.0% of the material covered,
0 cells left out, 0 unfusable, 0 still concave
The decomposition happens on the first question you ask and is kept, so asking a second
question costs nothing — which matters, because a large part takes minutes. Call run()
if you would rather choose when those minutes are spent.
print(len(part.volumes))
2
Each entry of volumes is an ordinary solid, so anything you can ask of a solid you can
ask of a volume — its size, its faces, its position:
whole = part.solid.volume()
for index, volume in enumerate(part.volumes):
print(f"volume {index}: {volume.volume() / whole:.1%} of the part")
volume 0: 68.1% of the part
volume 1: 50.2% of the part
Warning
Those add up to 118.2 %, and that is correct. Volumes overlap by design — a part usually reads as material in more than one way, and every reading is returned. Do not treat the result as a partition of the part, and do not sum the shares expecting 100 %.
Which volumes reach which faces#
The question asked most often of a decomposition is where a volume touches the part, and it runs both ways.
print(part.faces_of_volume(0))
print(part.volumes_of_face(1))
(0, 1, 2, 3, 4, 9)
(0, 1)
Volume 0 is bounded by six of the part’s ten faces. Face 1 is reached by both volumes — which is the overlap above, seen at face level rather than as a percentage.
Both directions are available whole, indexed rather than looked up:
volume_facesPer volume, the faces of the part it is bounded by. It can be empty: a volume made only of cells deep inside the part is bounded by cuts alone.
face_volumesPer face of the part, the volumes reaching it — over every face, so a face no volume reaches is an empty set rather than a missing entry.
unreached = [face for face, volumes in enumerate(part.face_volumes) if not volumes]
print(unreached)
[]
This is what makes the result usable upstream of feature recognition: a reading you can put back onto the faces you started from.
Reading the result#
Everything below is on the whole result, which the decomposer hands over:
result = part.decomposition
Decomposition carries the answers and, just as
importantly, an honest account of anything the run could not do. Nothing is swallowed
silently. You can also reach it straight from a solid you already hold, with
decompose() — one call, one value, nothing to ask.
The answers#
volumesThe maximal volumes. Each has no concave edge, and none is contained in another.
volume_cellsPer volume, which cells it was built from — indices into
cell_decomposition.cells. This is how you find out that two volumes share material:print(result.volume_cells)
(frozenset({0, 1}), frozenset({1, 2}))Cell 1 appears in both. That single fact is the whole reason the shares came to more than 100 %.
How much was explained#
This is the number to judge a run by, and it does not follow from the count of volumes.
covered_fractionThe share of the part’s material lying inside at least one returned volume.
uncovered_cellsWhich cells no volume reached — named, not just counted, so you can look at them against the shape.
print(f"{result.covered_fraction:.1%}")
print(result.uncovered_cells)
100.0%
()
Everything explained, nothing left over. On a complicated part you should expect less than 100 %, and the uncovered cells tell you where it went.
What the run could not do#
Several separate reports, each answering a different question. On this part every one of them is empty, which is what a clean run looks like:
print(result.stuck_nuclei)
print(result.concave_volumes)
print(result.cell_decomposition.dropped_face_ids)
()
()
()
stuck_nucleiSeed cells that reached none of the returned volumes, either way it can happen: their collected cells would not join into a single solid, so there was never a volume to judge; or the volume they grew was discarded as held inside another while the seed cell itself was not, the two reading as one lump.
concave_volumesIndices into
volumesof volumes that still hold a concave edge, and so are not really maximal. A volume only ends up here after the method has already tried decomposing it again from scratch and failed to separate it. It is reported rather than dropped, because dropping it would take its material out of the reading without saying so.cell_decomposition.dropped_face_idsFaces whose cuts could not be applied. A cut that would break the partition is left out rather than allowed to ruin it, and every face behind one is named here. A face contributes several cuts, so appearing here does not mean the face contributed nothing.
cell_decomposition.uncut_face_idsFaces whose cut was applied but kept nothing, the inside corner that asked for it having been lost in the cutting. The part is under-cut and nothing else says so: a partition missing a cut still tiles the part perfectly.
cell_decomposition.unbounded_face_idsFaces whose cut was chosen while one of the neighbours meant to stop it was missing, so it reached further through the part than the material would have allowed. Extra cuts, rather than missing ones.
cell_decomposition.unpaired_interior_facesAnything named here means the partition has a gap. A cut inside the part has material on both sides of it, so a cut face with only one cell beside it is a hole. Named rather than counted, so you can look at it against the shape.
Important
stuck_nuclei being empty does not mean the part was fully explained. It says
growth finished, not that it reached everywhere — only a seed cell begins anything,
so a region containing none lies in no volume however plainly it is one, while the count
of stuck seed cells stays at zero. Read covered_fraction for that question.
The cells underneath#
cell_decomposition is the partition the volumes were collected over, and nuclei
lists the seed cells — those carrying no extension face, which are the only cells a volume
is grown from:
print(len(result.cell_decomposition.cells))
print(result.nuclei)
3
(0, 2)
If you only want the cells and not the volumes — to look at how a part got cut, or because
you want to collect them yourself — call
decompose_into_cells() instead. It takes the same
volume_tolerance, localisation and concavity settings.
Changing how it behaves#
Four settings. The defaults are the published construction and are what you want unless you have a specific reason otherwise.
concavity — which corners ask for a cut#
"sharp"(default)Only junctions where the two faces genuinely meet at an angle.
"all"Every reentrant junction, including tangent ones — where two faces meet smoothly, at no angle at all.
loose = Decomposer(part.solid, concavity="all")
print(len(loose.volumes))
2
Prefer "sharp". A tangent junction is what an inside corner looks like after the
rounding that used to soften it has been removed: the faces still touch smoothly, and
cutting there divides material the part does not actually divide. On this simple part the
two readings agree; on a part full of rounded features they will not.
localisation — how far a sheet reaches#
"material"(default)The sheet is stopped by the sheets of the neighbouring faces its own face meets, and by the part’s own material. This is the published construction.
"none"The sheet is stopped by nothing but how far the part reaches. This exists to measure the default against — it is the denominator, not a way to decompose. It produces far more cells and takes far longer for a reading nobody wants.
Both readings are worked through in pictures, on the part the method is first shown on, in How far a sheet reaches.
from volmdlr_tools.decomposition import Decomposer
unlocalised = Decomposer(part.solid, localisation="none")
print(len(unlocalised.volumes))
2
The two agree on this part, which is the point of having it: a part that stops none of the sheets has to read the same both ways, so the reading nobody decomposes with is pinned to the same meaning as the one everybody does.
There is one known risk with the default worth stating plainly: a sheet bounded by the material stops where the material stops, so it can end inside the part — where a sheet runs into a bore, say — and a cut that ends inside the part does not separate it. Whether a cut divides material is a question about the cells, so it is asked once they exist: a cut with the same cell on both sides of it divided nothing, and the part is cut again without it. Nothing about that can be read off the sheet beforehand, since a piece can be bounded by material on every side it has and still not cut the material in two.
close_maximality — merge volumes that could be one#
Off by default. When on, two returned volumes are merged wherever their union still has no concave edge.
merged = Decomposer(part.solid, close_maximality=True)
print(len(merged.volumes))
2
This goes beyond the published method, which discards a contained volume but never merges two. Without it, some returned volumes are contained in a valid union of others, which the definition says is not maximal. It is slow on large parts.
volume_tolerance — the volume error allowed#
The relative error allowed between the cells and the solid, defaulting to 1e-6.
Raise it if the run is refused for a shortfall you can see is not material going missing.
Every cut leaves a skin as thick as the surfaces it was made with, so a part asking for many
cuts can overshoot a tight budget with none of its material lost anywhere — read the error
reported in the refusal against the size of the part before reading it as a failure of the
cutting. The benchmark suite runs at 1e-4 for that reason, and because the corpus mixes
parts modelled in millimetres with parts modelled in metres.
When coverage comes back low#
Coverage well under 100 % means material ended up in no volume. Two places to look, in this order.
1. Were cuts refused? Check result.cell_decomposition.dropped_face_ids, and the two
reports beside it. If faces are named there, sheets were offered that could not be applied,
so the part is under-cut and regions that should have been separated stayed fused. This is
the common cause.
2. Were there seed cells where you needed them? Compare result.nuclei against
result.uncovered_cells. A region that contains no seed cell produces no volume,
however obviously it is one — nothing begins there. stuck_nuclei will still be empty,
which is why it is not the number to check.
Then look at it. A percentage tells you how much was missed; only a picture tells you where:
part.display_3d()
That opens a 3D view with the original part underneath, one colour per volume over it, and everything no volume reached in grey. The grey is the point as much as the colours.
Errors#
DecompositionError is raised when the part cannot be
cut into cells that tile it — the one invariant the cutting stage guards.
IndecomposableError is raised when there is nothing
for the method to do: the solid holds no material, or its partition holds no seed cell at
all, so nothing can be grown.
Either way there is no result to return — not a partial one being hidden.
Where to go next#
How a part is broken into simple blocks — what the method does, in pictures
volmdlr_tools.decomposition package — the full API reference