Migrating from 3.0 to 3.1¶
3.1 is about one thing: ornaments stop being blobs. That means every ornament generated with default settings changes shape — deliberately, and it is the point of the release. Nothing else in the library is affected; 2D portraits, landscapes, the Riemann sphere renderers and their regression baselines are all untouched.
If you generate ornaments, read the next two sections. If you wrote a custom scaling callable,
read the third as well — it changes geometry without raising, which is the one thing here that can
go wrong quietly.
If you only do three things¶
- Re-check any ornament you had dialled in, or pin the old behaviour explicitly (below).
- If you wrote a
customscaling callable, divide its return by your radius range — see thecustomfix. - If you print to a fixed size, note that
size_mmnow means tip-to-tip width, so spiky pieces come out larger than before — see below.
Ornament geometry changes¶
Three defaults moved, and their effects compound:
| setting | 3.0 | 3.1 | why |
|---|---|---|---|
| normalization | none | normalize="geometric" |
the constant in front of f changed the shape, not just the labels |
scaling |
"arctan" |
"logarithmic" |
arctan admits no scale, so tip sharpness was not tunable at all |
depth (r_min) |
0.5 | 0.2 | every named scaling preset already said 0.2; only the STL defaults diverged |
To reproduce the 3.0 look exactly:
from complexplorer.export.stl import OrnamentGenerator
ornament = OrnamentGenerator(
lambda z: z / (z**10 - 1),
resolution=200,
scaling="arctan", # the old transfer
scaling_params={"r_min": 0.5, "r_max": 1.0}, # the old depth
normalize=None, # the old (absent) normalization
)
normalize=None on its own reproduces the old normalization behaviour; the old look needs all
three. Note that the useful thing to do is usually the opposite — keep the new defaults and adjust
pointiness and contrast — because normalization is what stops the arbitrary scale of your
function from deciding the geometry. The
physical workflow guide explains the
reasoning.
The custom scaling fix changes geometry silently¶
This is the one to check. In 3.0, dispatching scaling="custom" short-circuited
ModulusScaling.custom and used your callable's return value as the radius directly. Any r_min and
r_max you passed alongside it were silently discarded.
So anyone who used custom successfully wrote a callable returning a radius, because that was
the only thing that worked. In 3.1 dispatch goes through the documented contract: your output is
clipped to [0, 1] and then mapped onto [r_min, r_max].
A radius-returning callable already satisfies [0, 1], so nothing raises — the value is simply
remapped, and the geometry comes out wrong. With the default bounds of [0.5, 1.5], a callable that
returned 0.2 now yields a radius of 0.7.
# 3.0: the callable returned a radius, baking the depth in itself.
def transfer(moduli):
height = 1 / (1 + np.exp(-np.log(moduli) / 2.0))
return 0.2 + 0.8 * height # a radius in [0.2, 1.0]
# 3.1: return [0, 1] and let the bounds do the mapping.
def transfer(moduli):
return 1 / (1 + np.exp(-np.log(moduli) / 2.0)) # in [0, 1]
# ...with the depth where it belongs:
scaling_params = {"scaling_func": transfer, "r_min": 0.2, "r_max": 1.0}
The one-line version: drop the r_min + (r_max - r_min) * wrapper from your callable and pass
those bounds as parameters instead.
If your two-scale transfer was a hand-rolled mixture of logistics, contrast=(boost, weight) now
does it for you — see the guide.
size_mm now means tip-to-tip width¶
Exports come out larger than the same call produced in 3.0, by up to a factor of sqrt(3).
3.0 scaled the axis-aligned bounding box; 3.1 scales the object's true maximum width. The box is not
a property of the object — it depends on how the piece sits in the coordinate frame. A relief whose
spikes point along the cube diagonals (±1, ±1, ±1)/sqrt(3) projects each spike onto a coordinate
axis at only 0.577 of its length, so its box understates it by exactly sqrt(3) = 1.73. A
collection sized that way reports one nominal size while the pieces visibly differ; measured on the
reference collection, a nominal 80 mm produced real extents from 80 to 137 mm and volumes spanning
13.7×, and the largest object in the set was largest purely by accident of orientation.
How much your piece changes depends on how its features sit:
| relief | change at the same size_mm |
|---|---|
| spikes on the cube diagonals | sqrt(3) = 1.73× larger |
| icosahedral pieces (near axis-aligned) | about 2% larger |
| a shape whose widest direction is already axis-aligned | unchanged |
To reproduce a 3.0 export exactly:
size_measure="max" is also the right choice when the bounding box is genuinely what matters, such
as fitting a build plate. scale_to_size(mesh, size, axis="max") is unchanged and still sizes the
box; axis="extent" is the new measure, and max_extent() reports it without scaling.
The derived tip scale is capped at 6.0¶
pointiness * pole_order is now clamped to 6.0. If you passed pole_order=4 or higher, the applied
scale is lower than the product, deliberately: beyond roughly order 3 the mesh cannot deliver the
dynamic range the tip-exponent rule assumes, so an uncapped scale flattens the body instead of
sharpening the tip. Measured on an order-5 icosahedral relief, the radial range used goes from 63% at
k = 10 to 80% at k = 6.
The cap does not apply to sharpness, which you set deliberately — so sharpness=10.0 still means
10.0, and the gain-calibrated ln(10) case is untouched.
validate_printability loses two keys and gains two¶
| 3.0 key | 3.1 |
|---|---|
wall_thickness_ok |
removed |
estimated_min_wall_mm |
removed |
recommended_size_mm |
removed |
| — | min_radius_mm |
| — | max_radius_mm |
Reading a removed key by name now raises KeyError, so this is a visible break rather than a quiet
one. It is worth knowing that the removed keys were never meaningful: validation ran after
scale_to_size, so the scale factor was always 1.0 and wall_thickness_ok reduced to 0.3 >= 0.8
— constant False for every mesh ever exported, with a recommendation that always suggested a size
2.67× larger. Nothing could have depended on it being right.
A relief has no walls. It is a star-shaped solid about the origin, so the meaningful measurement is
its radial extent, and it is at least 2 * min_radius_mm thick through the centre. What genuinely
fails on a fused-deposition printer is the ridge width between two adjacent pits; that is not
measured, and the verbose report now says so instead of substituting a number for it.
Topology reporting is fixed¶
If you worked around validate_printability reporting your closed meshes as open, you can stop.
extract_feature_edges enables boundary, feature, manifold and non-manifold extraction by default,
so requesting boundary edges alone returned essentially every edge in the mesh — a pyvista.Cube,
closed and manifold with n_open_edges == 0, was reported as neither watertight nor manifold,
because each of its twelve creases read as a hole. repair_mesh_simple and close_mesh_holes
carried the same mistake in their progress counters, which is why repair looked like it was failing
while it was in fact working.
Counts are now correct, which means they can be trusted and asserted on. count_edges is available
from complexplorer.export.stl if you want to do the same thing in your own code.
Saved meshes carry oriented normals¶
save_stl now computes consistent, outward-facing normals before writing, so the facet normals
recorded in the STL mean something to a consumer that reads them rather than recomputing. If you were
running compute_normals yourself after export, you no longer need to.
New: the polyhedral ornament family¶
Six presets join cp.catalog, each a relief with the full rotation symmetry of a Platonic solid:
tetrahedral_dual, octahedral_crown, cube_octahedron_dual, icosahedral_crown,
dodecahedron_icosahedron_dual and icosidodecahedral_star. cp.catalog.filter("polyhedral")
returns them.
Build one with OrnamentGenerator.from_preset, which is new and is the point: these presets carry the
relief settings their geometry needs — feature order and resolution — and rendering one without them
gives the blunt, under-resolved version of the piece.
from complexplorer.export.stl import OrnamentGenerator
star = cp.catalog.get("icosidodecahedral_star")
OrnamentGenerator.from_preset(star).generate_and_save("star.stl", size_mm=130)
The eight Klein relative invariants they are built from are exported too —
cp.icosahedral_hessian and friends, plus cp.polyhedral_features for the projected feature
locations. See the
guide for why a ratio of invariants must
have equal binary degree, and for the syzygy check to run on any set you transcribe yourself.
Nothing here is breaking. FunctionPreset gains three optional fields — pole_order, resolution
and clip_ornament_to_domain — which are absent on every existing preset and omitted from its
serialized record, so the presets you already use are unchanged. One behaviour worth knowing: a
preset's domain is a 2D viewing window, and clipping a sphere sample with one deletes cells. That is
wanted for the transcendental presets, whose far field overflows, and wrong for a relief with a real
feature at the north pole, so the polyhedral six set clip_ornament_to_domain=False.
If you feed a divisor to cp.normalization_constant yourself, note that four of the six have a
feature at infinity — their singularities key lists only the finite ones, and the closed form
takes finite divisors only, so it would return a confidently wrong number for those pieces. The
default sampled estimator has no such problem.
New parameters, all optional¶
None of these are required, and the defaults are what change the look:
| parameter | default | what it does |
|---|---|---|
normalize |
"geometric" |
"median", a float, or None; puts sea level where the function lives |
pointiness |
2.0 | tip exponent is 1 / pointiness; larger is sharper |
pole_order |
1.0 | so a double pole prints as sharp as a simple one |
sharpness |
— | the log-modulus scale directly; ln(10) makes one unit of relief 20 dB |
contrast |
off | (boost, weight) sculpts the body between sparse features |
size_measure |
"extent" |
"max" restores 3.0's bounding-box sizing |
max_extent() and count_edges() are new public helpers in complexplorer.export.stl.
cp.normalization_constant and cp.sampled_normalization_constant are new public helpers. The first
is the exact closed form for a rational function and requires a complete divisor; the second
estimates the same quantity from samples and requires none. Read the
warning on the first before using it — with a partial divisor it
returns a confidently wrong number rather than an error.