Skip to content

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

  1. Re-check any ornament you had dialled in, or pin the old behaviour explicitly (below).
  2. If you wrote a custom scaling callable, divide its return by your radius range — see the custom fix.
  3. If you print to a fixed size, note that size_mm now 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:

ornament.generate_and_save("flower.stl", size_mm=80, size_measure="max")

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.