# ifc-geometry lower plan
Status: active scaffold under parent tasks `GEOM-CONTRACT`, `GEOM-SESSION`,
`GEOM-CTX`, `GEOM-PLACE`, `GEOM-PROFILE`, `GEOM-CURVE`, `GEOM-SURFACE`,
`GEOM-BREP`, `GEOM-SOLID`, and `GEOM-MAP`.
Last updated: 2026-08-19
Follow `AGENTS.md`. Claim one local task, leave blockers/decisions beneath it,
and check it off only after the proof runs.
## Work queue
- [x] `LOW-CONTRACT` - validate/normalize every source direction and axis exactly once
- Implements: `GEOM-CONTRACT`.
- Proof: `resource::direction` is the single normalization point
(`resolve_unit`, `resolve_ratios_3d`); its contract tests cover the
degenerate cases that a scattered implementation gets wrong --
`zero_length_direction_is_degenerate_rather_than_nan` and
`zero_magnitude_vector_is_legal_and_yields_the_zero_vector`.
- Decision: closed as satisfied rather than as new work. Two dependent
tasks (`LOW-SESSION`, `LOW-CURVE`) had already recorded that this was
"NOT a real prerequisite" because the normalization they needed already
lived in `resource::direction`; the box simply never followed. The
deliberate *non*-normalization of an `IfcVector` magnitude in
`lower::curve` is part of this contract, not a violation of it: that
magnitude is a parameterisation, not an orientation.
- [x] `LOW-SESSION` - shared builder, EntityId memo, active stack, roots, and provenance
- Implements: `GEOM-SESSION`.
- Proof: `cargo test -p ifc-geometry` (413 passing); `tests/lower_session.rs`
covers cross-family combination, entity and shared-profile memoization,
frame-distinct keys, cycle detection, depth budget, and graph-fault
attribution.
- Decision: `LOW-CONTRACT` was NOT a real prerequisite; direction validation
already lives in `resource::direction` and the session is agnostic to it.
- Note: source attribution is implemented separately below.
- [x] `LOW-DISPATCH` - total entity dispatcher and typed unsupported results
- Proof: `tests/lower_dispatch_corpus.rs` walks the committed corpus; every
representation item either lowers or returns a typed `Unsupported` naming a
real entity. Census: 25 lowered; unsupported by family: FACETEDBREP 20,
MAPPEDITEM 24, SWEPTDISKSOLID 3, CSGSOLID 1, HALFSPACESOLID 1,
BOOLEANRESULT 1, BOOLEANCLIPPINGRESULT 1.
- Decision: a nested failure reports the INNERMOST unlowerable entity, not
the outer item that referenced it, so the report points at the actual gap.
- Implemented families: EXTRUDEDAREASOLID, REVOLVEDAREASOLID, BOOLEANRESULT,
BOOLEANCLIPPINGRESULT. Planned families are declared as data in
`dispatch::PLANNED`, each with a concrete stated reason.
- [x] `LOW-CONTEXT` - units/context/placement composition exactly once
- Requires: `LOW-SESSION`, `INPUT-REP`, `INPUT-PRODUCT`.
- Implements: `GEOM-PLACE`.
- Proof: `tests/lower_product.rs` (4 tests) plus the ifc-cli corpus gate
`products_are_distributed_by_their_placements`.
- Decision: the placement chain is composed in FILE units and converted to
metres exactly once at the end. Converting per link would scale a depth-n
chain n times; every family lowerer already converts its own local
placement, so the world frame handed to them must arrive in metres.
- Decision: representation selection is a preference list (Body, Facetation)
and never the first entry. Wall #928204 in issue_098_wall_W.ifc lists its
Axis Curve2D before its Body, so first-wins yields a line, not a solid.
- Note: the direction-contract prerequisite was dropped; normalisation
already lives in `resource::direction` and placement does not depend on it.
- [x] `LOW-CURVE` - exact curve nodes for the families used as directrices
- Scope: the directrix curve families of the curve parent task; that
parent stays open for B-splines, ellipses and offset curves.
- Proof: `cargo test -p ifc-geometry` (6 curve unit tests, 3 corpus tests in
`tests/lower_csg_swept.rs`); 9/9 mutation probes; crate clippy in both
feature columns.
- Scope: `IfcPolyline`, `IfcLine`, `IfcCircle`, `IfcEllipse`,
`IfcTrimmedCurve`, `IfcCompositeCurve`, `IfcIndexedPolyCurve`,
`IfcBSplineCurveWithKnots`, and `IfcRationalBSplineCurveWithKnots`.
Convention-only `IfcBSplineCurve` still reports a typed `Unsupported`; no
knot sequence is invented for a base spline.
- Parameter space (`lower/curve/parameter_space.rs`): `IfcPCurve` reference
curves admit `IfcPolyline`, line-only `IfcIndexedPolyCurve`, `IfcLine`,
`IfcCircle`, `IfcEllipse` and the explicit-knot `IfcBSplineCurveWithKnots`
/ `IfcRationalBSplineCurveWithKnots`, all read verbatim with no unit
conversion. Convention-only `IfcBSplineCurve`, explicit-arc indexed
polycurves, trimmed and composite curves stay typed refusals; see
`GEOM-CURVE` for the remaining families.
- Decision: `LOW-CONTRACT` was NOT a prerequisite. Direction normalization
already lives in `resource::direction`, and this module deliberately does
NOT normalize an `IfcVector` magnitude, which is parameterisation rather
than orientation.
- Decision: a conic trim parameter is an ANGLE and a line parameter is a
LENGTH. The basis curve therefore selects the unit conversion. A single
length factor turns the crankbar's 0.082 rad arcs into 8.2e-5 rad in a
millimetre file; the arc still renders.
- [x] `LOW-PROFILE` - steel sections and nesting profile families
- Requires: `LOW-DISPATCH`, `LOW-CONTEXT`.
- Implements: `IfcIShapeProfileDef`, `IfcAsymmetricIShapeProfileDef`,
`IfcLShapeProfileDef`, `IfcTShapeProfileDef`, `IfcUShapeProfileDef`,
`IfcCShapeProfileDef`, `IfcZShapeProfileDef`, `IfcEllipseProfileDef`,
`IfcTrapeziumProfileDef`, `IfcCompositeProfileDef`,
`IfcDerivedProfileDef` and `IfcMirroredProfileDef`.
- Proof: `tests/lower_profile_families.rs` (10 tests), 8/8 mutation probes,
corpus census 93 -> 105.
- Decision: `IfcMirroredProfileDef` cannot read its `Operator`, which the
schema marks DERIVED. The mirror about the local y axis is implied by the
TYPE, so lowering it through the `IfcDerivedProfileDef` path would yield
an unmirrored copy that looks right in isolation.
- Decision: `IfcCenterLineProfileDef` now lowers. The kernel gained
`Profile::CenterLine` and miter offsetting, so the adapter reads the open
path and the full width and leaves the offset to the tier that owns it.
Its arm sits BEFORE the `IfcArbitraryOpenProfileDef` refusal: it is a
subtype, so the parent's arm would otherwise swallow it.
- Decision: profile nesting carries an explicit depth budget. Nothing in IFC
forbids a derived profile whose parent is itself, and a stack overflow is
a crash a consumer cannot catch.
- [x] `LOW-EXACT` - exact profile/surface node construction
- Audited (2026-09-05): all three thirds are done (curve via `LOW-CURVE`,
profiles via `lower::profile`, surfaces below), and every declared
prerequisite is now complete: `LOW-CONTRACT` [x], `INPUT-PROFILE` [x]
(resolved by ownership in #25), `INPUT-MAT` [x]. The box was held open
only by stale cross-file prerequisite state.
- Requires: `LOW-CONTRACT`, `INPUT-PROFILE`, `INPUT-MAT`.
- Scope note: the curve third is done, see `LOW-CURVE`. Profiles already
lower via `lower::profile`. The SURFACE third is now done too (below);
this task stays open because its declared prerequisites `LOW-CONTRACT`,
`INPUT-PROFILE` and `INPUT-MAT` are themselves still pending.
- Done (surface third): planes, linear extrusions, the curved elementary families
(cylinder, sphere, torus), `IfcSurfaceOfRevolution`,
`IfcRectangularTrimmedSurface`, `IfcBSplineSurfaceWithKnots`,
`IfcRationalBSplineSurfaceWithKnots`, and `IfcCurveBoundedPlane` all lower
via `lower/surface.rs`; convention-only `IfcBSplineSurface` remains
unsupported because it carries no authored knot sequence.
`Transform::to_geom_frame` carries the placement's own U/V axes, so a
surface keeps the parameterisation trims are taken against.
Proof: 12 unit tests, `tests/lower_surface.rs` (3 tests) plus
`tests/lower_synthetic_surfaces.rs` (7 tests), 8/8 mutation probes,
crate clippy in both feature columns.
- Fixture note: the curved and B-spline families had NO licensed source.
A survey of 909 `.ifc` files across ifc-lite (MPL-2.0), IfcOpenShell
(LGPL-3.0), IfcOpenShell/files (no licence) and buildingSMART
(CC-BY-4.0) found every instance of them sitting in the unlicensed repo.
They are now exercised by `test/fixtures/synthetic-surfaces/`, generated
by `tools/gen_surface_fixtures.py`. Generated output is our own work, so
the licence question does not arise; the generator is committed alongside
it so the fixtures stay reproducible rather than opaque blobs.
- Decision: `IfcArbitraryOpenProfileDef` now has an explicit authored-path
route through `lower_open_profile_node`, backed by Axiolid `OpenProfile`.
It remains refused by the AREA-profile API: an open path has no area and
closing it would fabricate a face. When it appears as a swept surface's
`SweptCurve`, it is a generatrix and remains unwrapped to its named curve.
- [x] `LOW-CSG` - CSG solids, CSG primitives, and swept-disk solids
- Requires: `LOW-CURVE`, `LOW-DISPATCH`.
- Scope: the CSG and swept-disk families of the solid parent task; that
parent stays open for advanced brep and surface-curve sweeps.
- Proof: 6 unit tests, `tests/lower_csg_swept.rs` (3 corpus tests), 9/9
mutation probes. Corpus census rose 72 -> 80 and the unsupported set is
now EMPTY for the committed corpus.
- Decision: `IfcCsgSolid` lowers to whatever its `TreeRootExpression` lowers
to. The wrapper carries no geometry, so emitting a node for it would add a
graph level no consumer can act on.
- Decision: a CSG primitive is LOCAL by kernel contract, so its `Position`
rides on an `Instance` node rather than being folded into the extents.
Folding would discard the origin offset and break any rotation.
- `IfcRectangularPyramid` completes the CSG primitive set (2026-08-30).
Its slots are `XLength, YLength, Height`, following `IfcBlock`; the
`IfcRightCircularCone` ordering puts `Height` first and would silently
swap height with width.
- [x] `LOW-SWEEP` - tapered, variable-section and spine sweeps
- Requires: `LOW-CURVE`, `LOW-DISPATCH`.
- Implements: `IfcExtrudedAreaSolidTapered`, `IfcRevolvedAreaSolidTapered`,
`IfcFixedReferenceSweptAreaSolid`, `IfcSectionedSpine`, and
`IfcSweptDiskSolidPolygonal` including its `FilletRadius`.
- Proof: `tests/lower_tapered_sweeps.rs` (7 corpus tests), 8/8 mutation
probes, corpus census 86 -> 93.
- Decision: `IfcSweptDiskSolidPolygonal` shipped in two steps. It first
lowered only without a fillet, refusing the filleted case because the
neutral `SweptDisk` had no fillet field and lowering anyway would silently
sharpen every bend in a pipe run. The kernel then gained
`SweptDisk.fillet_radius`, so the family is now fully IMPLEMENTED and the
`conditional:` dispatch machinery that supported the split was removed
rather than left as dead scaffolding.
- Decision: a spine section's placement composes with the world frame rather
than replacing it, so the stations stay distinct.
- Decision: a polyline or composite-curve trim parameter is a segment index
and is NOT unit-scaled. This corrected an existing defect in
`IfcSweptDiskSolid` and `IfcTrimmedCurve`, whose test had asserted the
wrong behaviour; ISO 10303-42 and `IfcParameterValue` settle it.
- [x] `LOW-COLLECT` - bounding boxes and loose geometry collections
- Requires: `LOW-DISPATCH`, `LOW-CURVE`.
- Scope: only the collection/bbox families. Set members reuse curve and
surface lowering that already shipped; no part of this task waits on the
open profile work, so that task is not a prerequisite here.
- Implements: the `IfcGeometricSet` / surface-model / bounding-box families.
- Proof: `tests/lower_collections_and_primitives.rs` (5 corpus tests),
7/7 mutation probes, corpus census 82 -> 86.
- Decision: an `IfcBoundingBox` world AABB is recomputed from all eight
transformed corners. The box is aligned to its own representation, so
under rotation the local minimum corner is not the world minimum corner.
- Decision: surface models lower to a `Collection` of shells, never a solid,
even when every shell is closed. `IfcShellBasedSurfaceModel` is not a
legal boolean operand and must not report a volume.
- Decision: curves and surfaces remain non-dispatchable as top-level items.
`collection.rs` routes them for set members only, through `is_a`.
- [x] `LOW-BREP` - topology plus geometry handles
- Requires: `LOW-DISPATCH`.
- Implements: `GEOM-BREP`.
- Proof: `tests/lower_brep.rs` (10 tests) plus the corpus census.
- Decision: planar facets carry `surface: None`. The loop's points define the
plane exactly; fitting one risks disagreeing with the vertices.
- Extended: `IfcAdvancedBrep` and `IfcAdvancedBrepWithVoids` reuse the same
walk. `IfcAdvancedFace` fills `Face::surface`, `IfcEdgeCurve` fills
`Edge::curve`, and `IfcEdgeLoop`/`IfcOrientedEdge` make edge sharing
explicit. Both sense flags compose: an edge's own `SameSense` sets the
stored sense, and each oriented-edge use flips it. Proof:
`tests/lower_advanced_brep.rs` (7 tests) on a generated fixture, 7/7
mutation probes.
- Decision: vertices intern by source `EntityId`, edges by unordered endpoint
pair, both scoped per solid. The corpus builds 12 bodies and 2028 faces from
one 196-point pool, so per-slot emission would multiply vertices ~40x and
leave every edge unshared, turning closed solids into loose facets.
- Note: two exact-geometry prerequisites were dropped. Faceted breps need no
exact curve or surface nodes, so the dependency was theoretical.
- [x] `LOW-TESS` - preserve authored n-gons/holes/triangles without retessellation
- Implements: `GEOM-TESS`.
- Proof: 7 unit tests in `lower/tessellated/tests.rs`, 3 fixture tests in
`tests/lower_tessellated.rs`, and the corpus census (64 -> 67 lowered,
`IFCTRIANGULATEDFACESET` out of the unsupported set). 6/6 mutation probes.
- Decision: `INPUT-TOPO` was NOT a real prerequisite. A face set carries no
adjacency, so the topology views B-rep needs are irrelevant here; the
tessellated readers import only `error` and `slots`.
- Decision: these lower to `axiolid-mesh` types, not `BRep`. A face set is
already a discretisation, so recovering topology would mean inferring
shared edges by comparing floats -- inventing information the file never
carried. `PolygonMesh` keeps authored n-gons and holes verbatim so the
fill rule and tolerance stay with the kernel.
- [x] `LOW-HALFSPACE` - exact half spaces as boolean cutting tools
- Progress: unbounded, boxed, and polygonally bounded half spaces all lower
exactly.
- Done (2026-09-05): `IfcPolygonalBoundedHalfSpace` lowers to
`SolidOperation::BoundedHalfSpace` on the axiolid v0.11.0 pin. `Position` is
passed as the operation's own `placement`, independent of `BaseSurface`, so
the authored prism is neither dropped nor relocated; the boundary lowers
through the shared curve path rather than a competing reader. Evidence:
`a_polygonal_bound_is_never_dropped` plus two mutations (identity placement,
aliased boundary node) that each fail it.
- Update (2026-09-05): Axiolid now declares `SolidOperation::BoundedHalfSpace`
(landed 2026-08-30, commit `9de5042`) with exactly the contract this task
needs -- a half-space node, a 2D boundary curve, and a placement. The
structural blocker is gone. The `agreement=false` mirroring defect
(axiolid/kernel#83) is **fixed** as of axiolid `6fe7bf3`/v0.9.0, verified
both by their own new test (`both_agreement_values_stay_outward_wound`,
landed alongside the fix) and independently re-run against `c144808d`
with the L-shaped probe from the original report: footprints now match
exactly between `agreement=true` and `agreement=false`.
- **Still blocked** on a second, distinct gap found while re-checking this
task after the fix: `bounded_half_space(boundary, plane, agreement, ...)`
derives the boundary's in-plane basis solely from `plane.normal` (picks
`Vec3::X` or `Vec3::Y` as reference); there is no parameter for an
independent boundary orientation. `IfcPolygonalBoundedHalfSpace.Position`
is schema-defined as independent of `BaseSurface` -- it need not share an
origin or orientation with it -- so any file whose `Position.RefDirection`
differs from Axiolid's internal guess would silently rotate the clip to
the wrong place. `SolidOperation::BoundedHalfSpace.placement` cannot fix
this after the fact: it transforms the finished mesh, but by then
`bounded_half_space` has already committed to its own guessed basis.
Filed upstream as axiolid/kernel#93; this task stays blocked until an
explicit-frame parameter lands and is independently verified.
- Proof: `src/lower/halfspace/tests.rs` (6 tests), `tests/lower_halfspace.rs`
(3 tests over `issue_1155_halfspace_flyaway.ifc`), corpus census 67 -> 72
lowered, and 6/6 mutation probes.
- Decision: `GEOM-PROFILE`/`GEOM-SURFACE` were NOT prerequisites. A planar
base surface needs only `resource::placement`, which already exists. The
exact-surface dependency applies to curved bases, which are reported as
unsupported rather than flattened to a tangent plane.
- Decision: IFC `AgreementFlag` is INVERTED relative to the neutral
`HalfSpace.agreement`. IFC `.T.` selects the side the normal points away
from; the kernel's `true` selects the normal side. Transcribing it straight
through cuts away the half that should have been kept, and no geometric
check catches it -- the boolean still evaluates and the mesh is still
watertight.
- Decision: `IfcBoxedHalfSpace.Enclosure` is a computational search enclosure
and does not change the Boolean result, so the neutral half-space is exact.
`IfcPolygonalBoundedHalfSpace.PolygonalBoundary` does change the effective
cutter and is typed unsupported until a neutral positioned-bound contract exists.
- Note: a surviving mutant showed the renormalization after the world
transform is unreachable with a unit-basis frame, because
`axis_placement_transform` already normalizes. The test now composes a
scaling frame so the assertion is load-bearing.
- [x] `LOW-MAP` - Instance nodes with cycle/depth budgets
- Implements: `GEOM-MAP`.
- Proof: `cargo test -p ifc-geometry` (11 mapped tests), crate clippy, corpus census.
- Decision: `LOW-CONTEXT` was NOT a real prerequisite. A mapped item composes
world/target/origin frames itself; representation-context selection is a
product-shape concern that sits above item lowering.
- Decision: the shared subtree is lowered in the map's own space, so the
per-occurrence placement rides on the `Instance` transform. That is what
lets many occurrences reuse one subtree.
- [x] `LOW-PROV` - separate NodeId-to-IFC provenance map
- Requires: `LOW-SESSION`.
- Proof: `tests/lower_provenance.rs` covers real multi-entity subtrees,
innermost active scopes, unscoped nodes, and memo reuse; 5/5 mutation
probes plus crate clippy and the full gate.
- Decision: the side table is partial. Nodes emitted for an IFC entity are
attributed; caller-synthesized unscoped nodes stay unattributed rather than
receiving a fabricated entity id.
- [x] `LOW-CENSUS` - lower every supported corpus item and classify every unsupported item
- Requires: `LOW-DISPATCH`.
- Implements: `GEOM-CENSUS`.
- Proof: `tests/lower_dispatch_corpus.rs` carries both halves.
`every_concrete_ifc4_representation_item_is_classified` is schema-driven,
not corpus-driven: it walks every concrete IFC4 representation item and
requires each to appear exactly once across `IMPLEMENTED`, `PLANNED` or
`data/ifc4-representation-item-dispositions.tsv`, so an unclassified new
entity fails CI. `every_corpus_representation_item_lowers_or_reports_a_
typed_reason` covers the other half, and
`every_implemented_family_has_committed_corpus_evidence` stops
`IMPLEMENTED` from becoming an unbacked claim.
- Scope boundary: family-level classification is enforced here. Variant-level
dispositions within a partially supported family -- which authored forms
are admitted and which are refused -- are carried by the `PARTIAL` catalog
in `lower/dispatch.rs` and gated by
`every_partial_family_declares_both_admitted_and_refused_variants` and
`declared_variant_support_matches_runtime_behaviour` (issue #27).
## Completion log
Append `TASK-ID - proof - material decision`; keep long logs out of this file.
- `LOW-MAP` - 11 mapped tests green, 6/6 mutation probes killed, corpus
dispatch 25 -> 43 lowered - instancing is preserved as `Instance` over a
shared subtree; transform order is `world o target o origin` with units
applied once per frame.
- `LOW-SESSION` - 413 tests pass; 4/4 mutation probes caught (cycle, depth,
dispatch reason, profile memo) - family lowerers now append into one caller
owned builder and return `NodeId`; `finish` is the only freeze point.
- `LOW-DISPATCH` - corpus census above - unimplemented families are declared
data with stated reasons rather than a wildcard no-op.