Skip to content

Bonsai: add radial and helical arrays to bim.add_array - #9483

Open
theoryshaw wants to merge 1 commit into
IfcOpenShell:v0.9.0from
theoryshaw:radial_arrays
Open

theoryshaw wants to merge 1 commit into
IfcOpenShell:v0.9.0from
theoryshaw:radial_arrays

Conversation

@theoryshaw

Copy link
Copy Markdown
Member

Closes #9482.

Adds a Radial array type to bim.add_array, sweeping copies about a centre and axis and optionally climbing along that axis to form a helix — spiral stairs, helical ramps, curved seating tiers, radial façade fins.

Placement math is now in one place

tool.Array.child_matrix is the single source of truth for instance transforms. Both tool.Model._regenerate_array_body and ArrayPreviewDecorator call it — previously each carried its own hand-written copy of the offset formula, so the drag-time ghost preview could disagree with what Finish actually built. Linear placement is unchanged; it just moved.

Schema

New BBIM_Array.Data keys, all .get-defaulted:

type, angle, rise, rise_method, axis, center, full_circle, rotate_children

Layers written before this change have none of them and place byte-for-byte as before. bpy.ops.bim.add_array() with no arguments behaves exactly as it always has.

Three design points worth reviewing

The sweep is deliberately unclamped. Multi-turn is a first-class case, so unlike every other subtype="ANGLE" property in model/prop.py the angle carries no min/max — only a soft range to tame mouse drag. The failure mode here is counter-intuitive and worth stating: wrapping the final angle * i product is harmless, because rotations are periodic and Rotation(630°) is Rotation(270°). The damage happens upstream — clamping the stored property, or normalising the sweep before DISTRIBUTE divides it, changes the step (540/16 = 33.75° becomes 180/16 = 11.25°) and quietly rebuilds a different array. Worse, the last instance still lands correctly either way, so end-position assertions cannot detect it. There is an AST guard pinning the no-clamp contract for exactly this reason.

Angle and rise take their DISTRIBUTE decisions independently. A spiral stair is normally known as one total and one per-copy quantity: the floor-to-floor height is fixed by the building while the tread angle is a design choice, or the sweep is fixed by the plan while the riser is set by code. Coupling both to one switch makes that unstatable. An absent rise_method inherits method, so a partial spec stays coherent.

The pivot is stored relative to the source instance, not as an absolute world point. Children track the parent through the existing BBIM_Array CHILD_OF constraint, so an absolute pivot would stay behind when the parent moved and tear the ring away from its own contents.

Derived readouts

Whichever half of each pair you type, the panel shows the other — the riser implied by a floor-to-floor total is what building code constrains and it exists nowhere else:

What you type Derived sweep Derived rise
17 treads, 30° each, 3.0 m floor-to-floor Sweep: 480.00° (1.33 turns) Per Copy: 188 mm
17 treads, 540° total, 180 mm risers Per Copy: 33.75° Total: 2.880 m
22 treads, 30° each, 180 mm risers Sweep: 630.00° (1.75 turns) Total: 3.780 m

Centre picking

bim.input_cursor_center_array takes the pivot from the 3D cursor — and with Shift, the rotation axis from the cursor's Z, which Shift+RMB aligns to a surface normal. bim.center_array_on_selected puts it on another selected object's origin, which is the commonest real pick (select the newel column and a tread) and needs no snapping machinery.

Switching a layer to radial seeds a pivot one bounding-box width off −X. Left on the object's own origin every copy would spin in place on top of the original and the array would read as broken — the same failure the linear path already avoids with its bbox-derived offset.

Tests

  • 44 numeric cases on child_matrix and the resolved-value helpers: linear parity, legacy layers with no type key, multi-turn integrity, helix coupling, all four mode combinations, degenerate axis, count == 1. Tolerances are 1e-5 because mathutils is single-precision.
  • 5 AST forward-compat guards pinning the single-placement-site and no-clamp contracts. Both were mutation-tested — re-introducing a hand-rolled offset, or clamping the angle, fails them with an actionable message.
  • 6 BDD scenarios covering the panel round-trip, including the mixed per-copy-angle / total-rise spec.

Not included

Angle and centre gizmos — radial parameters are panel-edited for now, and the three linear offset arrows hide in radial mode so a drag can't write a value the rebuild ignores. The type-toggle gizmo currently reuses the cycle glyph and would read better with a dedicated one.

🤖 Generated with Claude Code

Extends the parametric array with a RADIAL layer type that sweeps copies
about a centre and axis, optionally climbing along that axis to form a
helix (spiral stairs, helical ramps, corkscrew ramps, curved tiers).

Placement math is consolidated into tool.Array.child_matrix, which both
tool.Model._regenerate_array_body and ArrayPreviewDecorator now call. The
two previously carried separate hand-written copies of the offset formula,
so the drag-time ghost preview could drift from what Finish built.

New BBIM_Array.Data keys, all .get-defaulted so layers written before
radial existed place byte-for-byte as before, and bpy.ops.bim.add_array()
with no arguments is unchanged:

    type, angle, rise, rise_method, axis, center, full_circle,
    rotate_children

Notes on the design:

- The sweep is never wrapped or clamped. Multi-turn is a first-class case,
  so unlike every other ANGLE property in model/prop.py the angle carries
  no min/max — only a soft range to tame mouse drag. The damage from a
  clamp is subtle: wrapping the final angle*i product is harmless (rotations
  are periodic), but normalising the stored sweep before DISTRIBUTE divides
  it changes the step (540/16 = 33.75 degrees becomes 180/16 = 11.25) while
  the LAST instance still lands correctly, so end-position assertions cannot
  detect it.

- angle and rise take their DISTRIBUTE decisions independently. A spiral
  stair is normally known as one total and one per-copy quantity -- the
  floor-to-floor height is fixed by the building while the tread angle is a
  design choice, or vice versa -- and coupling the two modes makes that
  ordinary specification unstatable. An absent rise_method inherits method.

- Whichever half of each pair the user types, the panel shows the other:
  tool.Array.resolved_rise / resolved_angle return (per_copy, total) and
  the edit body renders the derived one. The riser implied by a
  floor-to-floor total is what building code actually constrains and it
  exists nowhere else, so without this it is only discoverable by rebuilding
  the array and measuring it. A total sweep past one turn is reported with
  its turn count, since the bare angle is exactly what hides the wrap.

- The pivot is stored relative to the source instance, never as an absolute
  world point. Children track the parent through the BBIM_Array CHILD_OF
  constraint, so an absolute pivot would stay behind when the parent moved
  and tear the ring away from its own contents.

- Switching a layer to radial seeds a pivot one bounding-box width off -X.
  Left on the object's own origin, every copy would spin in place on top of
  the original and the array would read as broken -- the same failure the
  linear path avoids with its bbox-derived offset.

- Every new AddArray property is SKIP_SAVE. Blender replays an operator's
  last-used values on the next interactive invocation and the Array panel's
  "+" button passes no arguments at all, so without it a single radial add
  would make every later "+" click silently produce a radial array.

- The display gap counts (angle_gap_count / rise_gap_count) are separate
  from the DISTRIBUTE divisors, which floor at 1 to keep the division safe.
  For a readout that floor would invent a phantom step on a lone instance.

Centre picking: bim.input_cursor_center_array takes the pivot from the 3D
cursor (Shift also takes the axis from the cursor's Z, which Shift+RMB
aligns to a surface normal), and bim.center_array_on_selected puts it on
another selected object's origin. The panel branches on type, the three
linear offset arrows hide in radial mode, and a type toggle joins the
gizmo row. Angle and centre gizmos are a follow-up.

Tests: 44 numeric cases on the placement and resolved-value functions, 5
AST forward-compat guards pinning the single-placement-site and no-clamp
contracts, and 6 BDD scenarios. One test checks the derived readout against
what child_matrix actually places so the display cannot drift from the
geometry. Tolerances are 1e-5 because mathutils is single-precision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bonsai: radial (polar) arrays in bim.add_array, including multi-turn helices for spiral stairs

1 participant