Source
This page is CHANGELOG.md
included verbatim — edit that file, not this page.
Changelog¶
Notable changes to goifc. The API is unstable pre-1.0 — breaking changes land on minor versions, as the README states. Releases before v0.2.0 predate this file.
Unreleased¶
v0.9.2 — 2026-08-21¶
Fixed¶
- An elevation outline left open by ONE short seam is now closed across it and drawn, instead of the element being discarded whole (#37). The boundary walk is exact in real arithmetic only; on float32-derived world coordinates it can classify a sub-edge inconsistently and leave the ring open, and the all-or-nothing verdict then threw away everything. On kb645 that lost 45 exterior elements, every one of them to a single open chain with a gap between 0.9 mm and 9.1 mm — in one case 56 segments of correct outline discarded for one 9.1 mm seam. 914 entities drawn before, 959 after, 45 of them bridged.
The repair is deliberately the narrowest thing that recovers that case: EXACTLY one vertex short by one and one long by one — two loose ends, so one open chain — and a gap of at most 10 mm. Several chains, a vertex unbalanced by more than one, or a longer gap is a torn outline rather than a seam that failed to weld, and is refused exactly as before.
New ElevationEntity.OutlineBridged and Element.SilhouetteBridgedOn report it,
and reporting is the point: a bridged outline is a drawing that is right and a
measurement that is invented, because the bridging segment is area no face in the
mesh accounts for. Quantities are unaffected — unionMeasure2D, and every other
measurement path, still goes through unionBoundary and its unchanged strict gate.
Total a facade with Facing.FaceArea or NetAreas, never by integrating an
outline.
v0.9.1 — 2026-08-21¶
Fixed¶
Scene.Elevations/Scene.ElevationsWith/Scene.ElevationOnno longer admit a host to the two sheets perpendicular to it (#28). The membership test wasdot(normal, plane.N) > 0— anything not pointing away. A wall perpendicular to a sheet has a dot of zero in exact arithmetic and a hair above it once a real placement transform has been through the normal, so it was admitted; and becauseSilhouetteOnprojects the whole solid, an edge-on wall admitted on a rounding error still drew its full thickness by height. On kb645 that put 913 of 950 drawn hosts — and all 87 of 87 ETICS hosts — on two perpendicular sheets, with the secondary carrying 10-50% of the dominant sheet's area.
Membership is now a bearing test against a documented threshold: both vectors are
flattened to horizontal, renormalized, and compared against cos(45°) — the same
quantity Facing.Azimuth bins by, which is the point, since the elevation
membership test disagreeing with the engine's own orientation classifier is what
#28 is. Against a four-sheet compass set an unambiguous host is now drawn exactly
once; a host at a true 45° is genuinely diagonal and lands on both, a documented
tie rather than an accident. On kb645: 914 of 914 drawn hosts on exactly one sheet,
87 of 87 ETICS hosts on exactly one sheet.
The threshold carries a documented 1e-12 tolerance, and it is not decoration.
Normalizing the everyday diagonal [1, 1, 0] lands one ULP BELOW cos(45°), so a
bare >= rejects it on both sheets and deletes the wall from the drawing — exactly
the failure the >= exists to prevent, reintroduced by rounding. 1e-12 clears one
ULP by four orders of magnitude while costing ~8e-11 of a degree of angular slack.
Flattening to horizontal is load-bearing rather than tidiness. A raw 3-D dot conflates "pointing the wrong way" with "tilted off vertical", so a wall leaning 45° while squarely facing east has its dot dragged under the threshold by the Z term and is dropped from every sheet — which, measured on kb645, silently deleted 45 exterior proxies from the drawing. A wall nobody draws is worse than a wall drawn twice, because nobody checks it.
Behaviour change worth reading before you upgrade. 36 fewer hosts are drawn on
kb645 (950 → 914). Every one of them was previously drawn ONLY as an edge-on sliver
on a perpendicular sheet: their own facade admits them, but SilhouetteOn returns
an empty outline there, so they never appeared on the elevation they belong to.
That empty face-on silhouette is a separate, pre-existing defect and is not
addressed here. This release stops drawing the sliver; it does not yet draw the
wall.
v0.9.0 — 2026-08-19¶
Added¶
Scene.ElevationsWith(f, r, planes, facings)—Scene.Elevationsover a classification the caller already holds.BuildFacingsdominates the cost of drawing a facade set (on a ~1,900-element model, roughly 15s of a 17s call), and its result is worth more than the drawing:Facing.FaceAreabinned byFacing.Azimuthis the sound way to total a facade, because summing the sheets is not — an element with two exposed sides is drawn on two perpendicular sheets, so per-sheet totals double-count it. A caller wanting both the drawings and the quantities previously had to classify the same scene twice. A nil or empty map is taken at face value, not as a request to build one: silently classifying there would restore the exact cost the call exists to avoid, invisibly, because the drawing would still look right.ImportNode.OpeningDeductionandImportNode.ProjectedGross— the two halvesNetAreais the difference of, on the import contract.NetAreaalone cannot be aggregated: a host with noIfcRelVoidsElementis ABSENT from the reconciliation, so summing nets over a facade silently drops every solid wall. Netting a total means subtracting the DEDUCTION from the gross being totalled.ProjectedGrosscomes with it because both are measured on the host's winning projection axis, which foreshortens gross and deduction by the same factor — so a caller netting an unforeshortened gross (Facing.FaceArea) subtractsOpeningDeduction * (FaceArea / ProjectedGross), notOpeningDeductionraw. WithoutProjectedGrossthat bias is not merely uncorrected, it is invisible. Present exactly whenNetAreais, so an untrusted host never publishes a zero deduction that would read as "this wall has no openings".ImportNode.HasOpenings— whether the element carriesIfcRelVoidsElementopenings at all. This is the fact the nil-able area fields cannot express: they are absent for two OPPOSITE reasons — the host has none, so its net equals its gross; or its reconciliation was refused, so its net is unknown. Reading absence as "no openings" reports a fully-glazed wall as solid; reading it as "unknown" drops every solid wall from a facade total. Always meaningful, so a plain bool rather than a third nil-able field.
v0.8.1 — 2026-08-18¶
Added¶
ImportNode.OpeningPerimeter— the opening union's boundary length (m) on the import contract, so the measurement v0.8.0 added is actually reachable by consumers, which readImportNoderather thanNetArea. Present exactly whenNetAreais: both are published from one trusted reconciliation, so a consumer can never read a confident perimeter beside an absent net.
v0.8.0 — 2026-08-18¶
Added¶
NetArea.OpeningPerimeter— the boundary length, in metres, of the same opening unionOpeningDeductionmeasures the area of. Facade trades bill reveals (the returns around a window or door) per linear metre, and the length they follow is the union's outline rather than the sum of the individual voids' perimeters: where two footprints merge, the seam between them is interior to the union, and it belongs to the outline no more than the shared area belongs to the deduction twice. It is0when the host is untrusted, exactly likeOpeningDeduction, and both numbers come from one boundary walk so they can never describe different shapes. On a real ArchiCAD export it agrees to within 0.1 m with an independent per-void bounding-box measurement, and every host satisfiesperimeter² >= 4·pi·area.
v0.7.0 — 2026-08-17¶
Changed¶
Assembledgained an unexported field recording the*step.Fileit was built from, soBuildImportFromcan reject an assembly paired with a different file rather than silently joining data across models. Breaking for unkeyed composite literals —Assembled{r, s}no longer compiles; callers using keyed fields (Assembled{Result: r, Scene: s}) are unaffected.- The projected-polygon union engine now reports a closed boundary or refuses
the host outright, rather than silently returning the residual of an
unclosed walk.
unionArea2DandunionBoundarygained anokresult,silhouetteRingsreturns no rings instead of a wrong one, andNetAreasmarks such a host untrusted with a reason — the mechanism it already had for exactly this. Fixes a defect where an unclosed boundary's area integral, taken about the world origin, returned the residual scaled by the model's distance from the origin rather than a slightly wrong number (a 0.58 m² panel 47 m out reported 30.69 m²).
Added¶
(geometry.Element).SilhouetteOn(p)— the projected-polygon union of the faces opposingp.N, as seen from the+p.Nside, inp's UV coordinates. UnlikeFootprintOnit never falls back to a bounding box: a caller asking for a projection is asking a question a rectangle does not answer, so an absent outline is reported as absent.geometry.ElevationPlane(dir)— the vertical plane an elevation drawn alongdiruses: world up stays up on the page, unlikePlaneFromNormal's unspecified in-plane orientation.okis false for a non-finitediror one with no horizontal component (straight up or down is a plan, not an elevation).geometry.ElevationView/ElevationEntity— the orthographic facade view a plane projects: each entity's outline and punched-out openings in the plane's UV coordinates, plus its depth from the viewer. Membership is deliberately narrow —ExposureExterioronly, outward normal facing the viewer — so a courtyard wall is excluded and a slab, roof or column (no dominant vertical face family, so noFacing) never appears. Outline area minus opening area reconciles with that host'sNetAreasentry when both are measured on the same plane.(*geometry.Scene).ElevationOn(f, r, p)— projects the scene ontopand returns its elevation, classifying facings on every call.(*geometry.Scene).Elevations(f, r, planes)— one view per plane, classifying the scene's facings at most once (on the first valid plane) and sharing that work across the rest, sinceBuildFacingsdoesn't depend on the plane. An invalid plane yields its zero-value view in that position rather than being dropped, so the result stays index-aligned withplanes.ifc.BuildImportFrom(f, a)—BuildImportsplit into itsAssemblestage plus the import-contract assembly, so a caller that also wants theResultorScene(for an elevation, a net-area check, a GLB) can reuse the tessellationAssemblealready did instead of paying for it twice.Assembledmust be paired with the same*step.Fileit was built from (checked by pointer identity) —BuildImportFromrejects a mismatched pair rather than risk anExpressIDcollision silently joining one model's data onto another's element.
v0.6.0 — 2026-08-17¶
Changed¶
geometry: an opening now deducts its true projected silhouette from a host's net area, rather than its span. (#23)
v0.5.0 — 2026-08-17¶
Changed¶
geometry: the silhouette is now computed as a true projected-polygon union, rather than approximated. (#22)
v0.4.0 — 2026-08-17¶
Changed¶
geometry: net area now deducts the union of a host's opening footprints, rather than their sum — an opening overlapping another no longer gets double-deducted. (#20)
v0.3.0 — 2026-08-17¶
Added¶
geometry.Facing— an element's outward direction in world space: the area-weighted dominant normal of its vertical faces, signed to point at the exposed side, with anExposureand aConfidence.geometry.Exposureand its three values —ExposureExterior(the side reaches open air outside the building, and the only exposure that belongs on a compass elevation),ExposureEnclosed(a void the building encloses with nothing overhead: a courtyard, a lightwell — weather-exposed but on no elevation),ExposureInterior(no exposed side; an internal partition).geometry.FacingOf(e)— the facing of one element, orok == falsewhen it has no dominant vertical face family (a column, a slab, a degenerate mesh). With no neighbours both sides reach open air, so an element classified alone gets its axis,ExposureExterior, an ARBITRARY sign and low confidence.geometry.BuildFacings(elems)— classifies every element against the others, keyed byGlobalID. This is what lets the sign be decided at all; prefer it toFacingOfwhenever the neighbours are available. Elements with no facade are absent from the map rather than present with a zero value.(geometry.Facing).Azimuth(trueNorth)— the compass bearing in degrees clockwise fromtrueNorth, in[0, 360). Passmodel.TrueNorth(file)for a real bearing, or{0,1}for a model-space one.geometry.LayerAxis(e, ls)— the world direction a layer stack runs along, from the first declared layer toward the last. Compare it against aFacing.Normalto learn whether the declared order already runs from the exposed face inward: a negative dot product means the first declared layer is the outermost. The library reports the direction; reordering is the consumer's decision.model.TrueNorth(f)— the model's north direction in world XY, unit length, offIfcGeometricRepresentationContextattribute 5. Absent, malformed and zero-length norths all yield(0,1).
Known limitations¶
- Grid resolution is the failure mode. The outward sign comes from a
horizontal-slice occupancy grid at a fixed 10 cm cell, so a gap narrower than
one cell seals and an element thinner than one cell vanishes. Rasterization
is conservative — a cell the cross-section touches counts as occupied — so
the failure leans toward sealing, which reads as
ExposureInteriorat low confidence rather than leaking open air into a room. A slice taken through a fully glazed storey with no modelled mullions finds no occupancy to enclose it and reports its walls freestanding, again at low confidence. Threshold onConfidence: below ~0.5 the sign is a guess, and in a quantity context a wrong bin is a wrong invoice. Facing.FaceAreais the GROSS facade area of one side. It is measured against the resolvedNormalafter the sign is known, so summing it over the elements binned to one elevation gives that elevation's area. Openings are not subtracted — for net quantities useNetArea. It is the only place this package reads triangle winding: a mesh wound inward throughout reports the element's inner face, equal on a plain wall and smaller on a stepped one.- The sign is probed outward from the element's bounding-box centre, so an L-shaped or strongly curved element whose centre falls outside its own body degrades to low confidence.
BuildFacingscosts O(bands × elements), where a band is a distinct quantized element mid-height rather than a storey. Peak memory is one grid regardless of the band count, but the time is not bounded that way.
v0.2.0 — 2026-08-13¶
Breaking¶
geometry.LoopBelowis renamed togeometry.LoopSilhouette. The constant's string value is UNCHANGED ("below"), so drawing data already on disk stays valid and renderers matching the literal need no change. The fix is source-only: rename the identifier at the call site.
The old name described the only plane the package could cut — a horizontal one, always seen from above. Now that any plane can be cut, "below" is no longer what the role means, but it is still what the wire format says.
Added¶
geometry.Plane— a cutting plane as an origin plus an orthonormal right-handed basis (U,Vspan the plane;Nis the normal).geometry.HorizontalPlane(cutZ)— the planeFootprinthas always cut.geometry.PlaneFromNormal(origin, n)— derives a valid basis from a normal.ok == trueguaranteesValid(). The in-plane orientation ofUis deterministic but unspecified; build thePlaneyourself if you need a particular one.geometry.Plane.Valid()— reports whether a basis is finite, orthonormal and right-handed.SectionOnandFootprintOnemit no rings for a plane that fails it, so a caller that hand-builds a basis can check up front and tell a bad basis apart from a plane that genuinely missed the mesh.(geometry.Element).SectionOn(p)— the closed cut rings where an element's mesh crossesp, inp's UV coordinates. Cut rings only: it never falls back to a silhouette or a bounding box, so a caller building a section learns that the plane missed rather than receiving a fabricated outline.geometry.FootprintOn(e, p)—Footprintgeneralized to any plane, keeping the cut → silhouette → bounding-box fallback ladder.
Changed¶
geometry.Footprint(e, cutZ)keeps its signature and behaviour for every finitecutZ; it now delegates throughHorizontalPlane. A non-finitecutZyields nil, where it previously returned a ring built around NaN.
Fixed¶
PlaneFromNormalno longer reports success for a finite normal whose squared length overflows to infinity, which left an all-zero basis behind.- The bounding-box fallback no longer emits NaN corners for an element whose bounds were never measured.
Known limitations¶
- A plane that contains two edges of a solid while bisecting it yields no cut
rings. A triangle contributes a crossing segment only when it has a vertex
strictly above and one strictly below, so faces meeting the plane along an
edge contribute nothing and the ring cannot close. Through
FootprintOnthis degrades further: the cut is returned taggedLoopSilhouetterather thanLoopCut. - For a non-closed mesh the silhouette depends on which way
Npoints, and one of the two directions can fall through to the bounding-box fallback. For a closed solid the outline is invariant under flippingN.