Skip to content

Commit b6e0ea4

Browse files
author
bonsaiPR automation
committed
Merge remote-tracking branch 'pr-9851/terrain-contours' into Frankenstein_build-0.9.0-alpha2610072353
2 parents af25082 + 70c8a9f commit b6e0ea4

14 files changed

Lines changed: 1025 additions & 2 deletions

File tree

‎docs/dev-notes/terrain-contours.md‎

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
<!-- This file was generated with the assistance of an AI coding tool. -->
2+
3+
# Terrain contours — dev note
4+
5+
> Living design note for the `terrain-contours` branch, checked out in `C:\IfcOpenShell`
6+
> (Blender 5.1's linked tree), based on `v0.9.0`.
7+
> Status: **v1 tested in Blender 5.1; PR open against `v0.9.0`.**
8+
9+
## Implementation status (v1)
10+
11+
| Piece | Where | Verified |
12+
|---|---|---|
13+
| Slice + chain (numpy, pure) | `tool/terrain.py` `slice_triangles`, `chain_segments` | Standalone script: cone → one closed loop at the right radius; slope → one ordered open line; level exactly on vertices → no zero-length steps; two hills → two loops; 80k tris × 48 levels ≈ 1.3 s |
14+
| Top-surface filter (BVH ray up) | `tool/terrain.py` `get_top_surface_triangles` | Blender 5.1, sculpted test terrain (see fix below) |
15+
| Core flow | `core/terrain.py` | `test/core/test_terrain.py`, 5 pass under plain pytest |
16+
| IFC writing (annotation, placement, polylines, psets, assign_product, container) | `tool/terrain.py` `create_contour` | Blender 5.1, IFC4 / feet test project |
17+
| Panel + 3 operators | `bim/module/terrain/` — Object properties → Parametric Geometry → Contours | not yet |
18+
| Drawings include contours | `tool/drawing.py` `get_drawing_elements` (2-line change) | Blender 5.1 plan drawing: each contour drawn once, `IndexContour` styled |
19+
| Geographic Element Tool header button | — | deferred — convenience only, add if the panel proves too far away |
20+
21+
Decisions made while coding:
22+
23+
- Terrain = active mesh object whose element is an `IfcGeographicElement` **or `IfcSite`**
24+
(many real files keep the terrain as the site's Body).
25+
- `root.create_entity(predefined_type="CONTOURLINE")` already does the schema switch:
26+
IFC4X3 → `PredefinedType`, IFC4 → `ObjectType = "CONTOURLINE"`. Kept upper-case in IFC4
27+
(Bonsai convention; `get_predefined_type` reads both). `Pset_AnnotationContourLine` is
28+
attached in both schemas.
29+
- One annotation per elevation, `Model/Annotation/MODEL_VIEW` context (created if
30+
missing), placement at (terrain x, terrain y, contour z), polylines are
31+
`IfcIndexedPolyCurve`s from `ShapeBuilder.polyline`.
32+
- Index contours get `EPset_Annotation.Classes = "IndexContour"` → SVG class.
33+
- Levels are multiples of the interval in **project** Z (Blender Z + georeference
34+
`blender_offset_z`), so 2 ft contours land on true 2 ft elevations under a false origin.
35+
- Slicing runs before anything is deleted, so a too-small interval (> 2000 levels)
36+
errors without touching existing contours.
37+
- Contours are contained in the terrain's container (or the site itself) explicitly,
38+
because `assign_class` skips containment for any annotation while a drawing camera
39+
is active.
40+
41+
### Fixed: broken / intermittent contours on sculpted terrain (2026-10-07)
42+
43+
Test file: `D:\Dropbox\Gitea_OD\Community_Troubleshooting\Terrain-Contours` (box with a
44+
sculpted top, 1,090 quads). The first version ran the "visible from above" ray test per
45+
**polygon**, from the average of its vertices. On a warped quad that point can sit below the
46+
quad's own surface, so the ray hit the quad itself: 370 of 371 rejected faces were
47+
self-hits, which left holes and split each level into 4–21 fragments. Now the test runs per
48+
**loop triangle** (a triangle's centroid lies on it) and steps past hits on the triangle's
49+
own polygon. Result on the test file: 1–2 polylines per level; the only excluded triangles
50+
are the 4 side faces and the bottom; every open end lies on the terrain boundary.
51+
52+
### Drawing path — checked
53+
54+
`get_drawing_elements` dropped every annotation not in the drawing's group; contours are
55+
now let through. They may then be drawn **twice**: by the C++ SVG serializer (linework,
56+
annotation contexts — but it only emits annotations within the storey Z range in floor
57+
plans) and by `svgwriter.draw_misc_annotation` (annotation layer, which carries the
58+
`IndexContour` class). Checked in a real plan drawing: each contour appears once and
59+
`.PredefinedType-CONTOURLINE.IndexContour` styles the index lines.
60+
61+
## Problem
62+
63+
Drafting a site survey in Bonsai needs topographic contour lines generated from a terrain
64+
mesh: every *N* units (2 ft, 5 ft, 1 m …), with a heavier **index contour** every *k*th
65+
line, regenerated when the terrain changes, and appearing in plan drawings and sheets.
66+
67+
There is nothing in Bonsai today that does this. Third-party Blender options exist
68+
(Parametric Shapes' *Contour Generator*, a Gumroad geometry-nodes tree) but they produce
69+
plain Blender geometry, which Bonsai drawings do not render — drawings only see IFC
70+
elements.
71+
72+
## IFC representation
73+
74+
IFC 4.3 has a dedicated class for this:
75+
76+
- **`IfcAnnotation`, `PredefinedType = CONTOURLINE`** — "annotation used to illustrate
77+
lines connecting points of equal elevation". IFC4 has no predefined type on
78+
`IfcAnnotation`; there the type goes in `ObjectType` (see the schema table below).
79+
- **`Pset_AnnotationContourLine.ContourValue`** (`IfcLengthMeasure`) — the elevation of
80+
the line.
81+
82+
### IFC4 vs IFC4X3 differences (checked against `IFC4.exp` and the repo's pset templates)
83+
84+
| Concept | IFC4 ADD2 TC1 | IFC4X3 |
85+
|---|---|---|
86+
| `IfcAnnotation.PredefinedType` | **does not exist** (entity has no attributes beyond `IfcProduct`) | `CONTOURLINE` |
87+
| `Pset_AnnotationContourLine` | exists, applicable to `IfcAnnotation/ContourLine` (matched on `ObjectType`) | exists, applicable to `IfcAnnotation/CONTOURLINE` |
88+
| `IfcGeographicElementTypeEnum.TERRAIN` | exists | exists |
89+
| `IfcEarthworksCut` / `IfcEarthworksFill` | **do not exist** | exist |
90+
91+
Consequences: in IFC4 set `ObjectType = "ContourLine"` (the spelling the IFC4 pset
92+
applicability uses — Bonsai's own annotation ObjectTypes are upper-case, so check
93+
whether pset-template filtering is case-sensitive before choosing); in IFC4X3 set
94+
`PredefinedType = CONTOURLINE`. Write a small schema switch, as
95+
`bim/module/model/product.py` already does for vegetation (IFC4 `USERDEFINED`/
96+
`VEGETATION` vs IFC4X3 `VEGETATION`).
97+
98+
Decision: each contour elevation is one **model-space** `IfcAnnotation.CONTOURLINE`
99+
(not assigned to a drawing group) whose body is 3D polylines at the true elevation. This
100+
means:
101+
102+
- other IFC tools see real contour objects, not just a Bonsai drawing artefact;
103+
- they read correctly in 3D and in plan;
104+
- index vs. intermediate contours can be styled independently in drawing CSS (class or
105+
ObjectType distinction — to settle during implementation).
106+
107+
Generation parameters live on the **terrain element** (`IfcGeographicElement`, TERRAIN)
108+
in a `BBIM_Contours` pset, following the `BBIM_Roof` / `BBIM_Stair` parametric pattern:
109+
110+
| Property | Meaning |
111+
|---|---|
112+
| `Interval` | contour spacing, project length units |
113+
| `IndexInterval` | every *k*th contour is an index contour (default 5) |
114+
| `MinElevation` / `MaxElevation` | optional clamp; default = terrain Z range |
115+
116+
The generated annotations need a link back to their terrain so Update can find and
117+
replace them. Candidate: `IfcRelAssignsToProduct` (annotation assigned to the terrain).
118+
To verify against how Bonsai already relates generated annotations before choosing.
119+
120+
Ruled out: adding the contours as a second Plan/Annotation representation on the terrain
121+
itself. Simpler, but invisible to other tools as objects and awkward to style index
122+
contours differently.
123+
124+
## UI placement
125+
126+
1. **Object Properties → "Contours" panel**, polled on a selected terrain element: shows
127+
the `BBIM_Contours` values, **Generate / Update** and **Remove**. This is where the
128+
interval is changed later.
129+
2. **Geographic Element Tool header** (`bim.geographic_element_tool`,
130+
`bim/module/model/workspace.py`) — when a terrain is selected, show the same
131+
Update button, mirroring how the tool shows wall-specific actions when a wall is
132+
selected.
133+
3. **Drawings: no new machinery** if contours are annotations — plan drawings include
134+
them, CSS styles them. Elevation labels on index contours are phase 2 (decorator or
135+
generated TEXT annotations; label placement/collision is the hard part).
136+
137+
Logic goes in `core/` + `tool/` (Bonsai's usual split) so it does not depend on which
138+
panel calls it.
139+
140+
### Relation to PR #9304 (Saikei Civil CIVIL tab)
141+
142+
PR #9304 adds a **CIVIL** Properties tab for alignment authoring. That tab would be the
143+
natural long-term home for site/terrain tools. Decision: **do not stack on it** — no code
144+
is shared (alignment curve math is unrelated to mesh slicing), its merge path is
145+
uncertain (reviewer is building an alternative alignment UI), and stacking would block a
146+
small PR on a large one. If the CIVIL tab lands, moving the Contours panel there is a
147+
`bl_context` / `bl_parent_id` change.
148+
149+
## Updating
150+
151+
- **v1:** explicit **Update Contours** — delete the annotations previously generated for
152+
this terrain, regenerate from the current mesh and pset.
153+
- **Later, opt-in:** regenerate when the terrain representation is saved on leaving Edit
154+
Mode. Opt-in because terrain meshes are heavy.
155+
156+
## Algorithm sketch
157+
158+
For each elevation `z` in `ceil(zmin/I)*I … zmax` step `I`:
159+
160+
1. `bmesh.ops.bisect_plane` on a **copy** of the terrain mesh with a horizontal plane at
161+
`z` (transformed into the object's local space — see below), keeping only the cut
162+
edges (`geom_cut`).
163+
2. Restrict to edges on the **top surface** (see "volume terrain" below).
164+
3. Chain the cut edges into polylines (open where they hit the mesh border, closed
165+
loops otherwise) and write them as `IfcIndexedPolyCurve` / polyline items.
166+
167+
Alternative to evaluate: Geometry Nodes Mesh Boolean "Intersecting Edges" against a
168+
stack of planes. Rejected for v1 — harder to drive from an IFC operator and to chain into
169+
ordered polylines.
170+
171+
## Related local add-ons (same "site tools" family)
172+
173+
Ryan has two standalone add-ons that may enter the ecosystem later. Both work on terrain
174+
and share hard-won facts this feature needs:
175+
176+
**Mesh Outline Projector**
177+
(`D:\Dropbox\Gitea_OD\Utility_Apps\Blender\addons\Mesh Outline Projector`) — projects
178+
the top-down outline of objects (e.g. building footprints) onto a terrain's top surface,
179+
cuts it with vertical bisect planes and paints the covered faces with the source
180+
material. Directly reusable lessons:
181+
182+
- **`bmesh` / `BVHTree` work in local space**; world-space planes must be transformed by
183+
`matrix_world.inverted()` (plane from three transformed points, not a transformed
184+
normal).
185+
- **`bisect_plane` leaves no face straddling the cut** — the same guarantee makes
186+
contour edges clean.
187+
- **"Top surface" cannot be found from normals** — IFC/imported meshes often have flipped
188+
winding. Its `make_top_face_predicate` (roughly-horizontal `|n.z|` **and** an upward
189+
ray-cast against a BVH snapshot hits nothing) is exactly what contours need for
190+
**volume terrain**: slicing a solid terrain horizontally otherwise also yields loops
191+
around the sides and bottom. Candidate to promote into a shared `tool.Terrain` helper.
192+
193+
**Excavate** (`D:\Dropbox\Gitea_OD\Utility_Apps\Blender\addons\Excavate`) — turns
194+
selected terrain faces into a flat-bottomed pit (vertical walls, flat floor). In IFC
195+
terms this maps naturally to **`IfcEarthworksCut`** (IFC 4.3 feature subtraction;
196+
`IfcEarthworksFill` is its counterpart), which would keep the terrain unmodified and the
197+
cut as a separate, quantifiable element. **Both are IFC4X3-only**; an IFC4 equivalent
198+
would need an `IfcOpeningElement` (or a direct mesh edit), so this one is schema-gated.
199+
200+
Possible shape of a future Bonsai "Terrain" module: contours, outline projection
201+
(footprints / paving zones onto terrain), earthworks cut/fill. Contours come first
202+
because they are the smallest and need no change to the terrain geometry itself.
203+
Note: projected *materials* in Bonsai would be per-face IFC styles, a harder problem —
204+
out of scope here.
205+
206+
## Open questions
207+
208+
- Existing vs. proposed grade: two terrains with status EXISTING / NEW → dashed vs. solid
209+
contours. Inherit status onto the generated annotations?
210+
- Clip contours to the site boundary, or the full terrain extent?
211+
- Which relationship links annotations to their terrain (see above)?
212+
- IFC2X3 support — probably not; contours are an IFC4+ feature.
213+
214+
## To test (once implemented)
215+
216+
- [ ] Heightfield terrain, imperial (2 ft / index 10 ft) and metric (1 m / index 5 m).
217+
- [ ] Volume (solid) terrain with flipped normals — only top-surface contours.
218+
- [ ] Rotated / translated terrain object — elevations are world Z.
219+
- [ ] Update after editing the terrain — old annotations replaced, none orphaned.
220+
- [ ] Contours appear in a plan drawing with index/intermediate styling.
221+
- [ ] Interval change 2 ft → 5 ft regenerates correctly.

‎src/bonsai/bonsai/bim/__init__.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@
9393
"web": None,
9494
"light": None,
9595
"alignment": None,
96+
"terrain": None,
9697
"clip_box": None,
9798
"clipboard": None,
9899
# Uncomment this line to enable loading of the demo module. Happy hacking!
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Bonsai - OpenBIM Blender Add-on
2+
# Copyright (C) 2026 Ryan Schultz
3+
#
4+
# This file is part of Bonsai.
5+
#
6+
# Bonsai is free software: you can redistribute it and/or modify
7+
# it under the terms of the GNU General Public License as published by
8+
# the Free Software Foundation, either version 3 of the License, or
9+
# (at your option) any later version.
10+
#
11+
# Bonsai is distributed in the hope that it will be useful,
12+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
# GNU General Public License for more details.
15+
#
16+
# You should have received a copy of the GNU General Public License
17+
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
18+
19+
# This file was generated with the assistance of an AI coding tool.
20+
21+
import bpy
22+
23+
from . import operator, prop, ui
24+
25+
classes = (
26+
operator.GenerateContours,
27+
operator.UpdateContours,
28+
operator.RemoveContours,
29+
prop.BIMTerrainProperties,
30+
ui.BIM_PT_terrain_contours,
31+
)
32+
33+
34+
def register():
35+
bpy.types.Scene.BIMTerrainProperties = bpy.props.PointerProperty(type=prop.BIMTerrainProperties)
36+
37+
38+
def unregister():
39+
del bpy.types.Scene.BIMTerrainProperties
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Bonsai - OpenBIM Blender Add-on
2+
# Copyright (C) 2026 Ryan Schultz
3+
#
4+
# This file is part of Bonsai.
5+
#
6+
# Bonsai is free software: you can redistribute it and/or modify
7+
# it under the terms of the GNU General Public License as published by
8+
# the Free Software Foundation, either version 3 of the License, or
9+
# (at your option) any later version.
10+
#
11+
# Bonsai is distributed in the hope that it will be useful,
12+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
# GNU General Public License for more details.
15+
#
16+
# You should have received a copy of the GNU General Public License
17+
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
18+
19+
# This file was generated with the assistance of an AI coding tool.
20+
21+
import bpy
22+
23+
import bonsai.tool as tool
24+
25+
26+
def refresh():
27+
TerrainData.is_loaded = False
28+
29+
30+
class TerrainData:
31+
data = {}
32+
is_loaded = False
33+
34+
@classmethod
35+
def load(cls):
36+
element = tool.Ifc.get_entity(bpy.context.active_object) if bpy.context.active_object else None
37+
settings = tool.Terrain.get_contour_settings(element) if element else None
38+
cls.data = {
39+
"interval": tool.Unit.blender_format_unit(settings[0]) if settings else None,
40+
"index_interval": settings[1] if settings else None,
41+
"total_contours": len(tool.Terrain.get_contours(element)) if element else 0,
42+
}
43+
cls.is_loaded = True

0 commit comments

Comments
 (0)