Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified docs/gallery/assets/export-preset-axis-hero.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/gallery/assets/terrain-scatter-hero.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/gallery/contact-sheets/terrain-scatter-contact-sheet.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 10 additions & 4 deletions docs/gallery/export-preset-axis/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -697,8 +697,10 @@ <h2>Source</h2>
scene = bpy.context.scene
source.hide_render = <span class="k">True</span>
source.hide_viewport = <span class="k">True</span>
sit_on_floor(unity_objs, -<span class="n">2.15</span>, <span class="n">0.0</span>)
sit_on_floor(godot_objs, <span class="n">1.95</span>, <span class="n">0.0</span>)
<span class="c"># Closer together: at -2.15 / +1.95 the pair left an empty metre of floor</span>
<span class="c"># between them, and each prop was small in its half of the frame.</span>
sit_on_floor(unity_objs, -<span class="n">1.35</span>, <span class="n">0.0</span>)
sit_on_floor(godot_objs, <span class="n">1.35</span>, <span class="n">0.0</span>)

floor_me = bpy.data.meshes.new(<span class="s">&quot;Floor&quot;</span>)
bm = bmesh.new()
Expand Down Expand Up @@ -734,10 +736,14 @@ <h2>Source</h2>
cam_data = bpy.data.cameras.new(<span class="s">&quot;Cam&quot;</span>)
cam_data.lens = <span class="n">50.0</span>
cam = bpy.data.objects.new(<span class="s">&quot;Cam&quot;</span>, cam_data)
cam.location = (<span class="n">3.12</span>, -<span class="n">8.15</span>, <span class="n">2.45</span>)
<span class="c"># Oblique rather than straight down +Y: the Godot reimport lies with its</span>
<span class="c"># mast along -Y, and a camera looking along Y foreshortened that mast to</span>
<span class="c"># a stub in front of the base plate, so the one thing the check proves</span>
<span class="c"># (it lies) barely read. From the side-front the mast shows its length.</span>
cam.location = (<span class="n">4.9</span>, -<span class="n">5.1</span>, <span class="n">2.2</span>)
scene.collection.objects.link(cam)
aim = bpy.data.objects.new(<span class="s">&quot;Aim&quot;</span>, <span class="k">None</span>)
aim.location = (<span class="n">0.0</span>, <span class="n">0.0</span>, <span class="n">0.85</span>)
aim.location = (<span class="n">0.40</span>, -<span class="n">0.30</span>, <span class="n">1.00</span>)
scene.collection.objects.link(aim)
con = cam.constraints.new(<span class="s">&quot;TRACK_TO&quot;</span>)
con.target = aim
Expand Down
2 changes: 1 addition & 1 deletion docs/gallery/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -981,7 +981,7 @@ <h2><a href="treasure-chest/">treasure-chest</a></h2>
</article>
<article class="card" data-tags="mesh export showcase">
<a class="card-media" href="terrain-scatter/" tabindex="-1" aria-hidden="true">
<img src="assets/terrain-scatter-hero.webp" alt="A square tile of sandy rolling terrain with pale rocks scattered on the hill." loading="lazy" decoding="async" />
<img src="assets/terrain-scatter-hero.webp" alt="A square tile of dark rolling earth with nine faceted grey rocks seated across the hill, none touching." loading="lazy" decoding="async" />
</a>
<div class="card-body">
<h2><a href="terrain-scatter/">terrain-scatter</a></h2>
Expand Down
246 changes: 212 additions & 34 deletions docs/gallery/terrain-scatter/index.html

Large diffs are not rendered by default.

14 changes: 10 additions & 4 deletions examples/export-preset-axis/export_preset_axis.py
Original file line number Diff line number Diff line change
Expand Up @@ -391,8 +391,10 @@ def render_still(source, unity_objs, godot_objs, path, engine):
scene = bpy.context.scene
source.hide_render = True
source.hide_viewport = True
sit_on_floor(unity_objs, -2.15, 0.0)
sit_on_floor(godot_objs, 1.95, 0.0)
# Closer together: at -2.15 / +1.95 the pair left an empty metre of floor
# between them, and each prop was small in its half of the frame.
sit_on_floor(unity_objs, -1.35, 0.0)
sit_on_floor(godot_objs, 1.35, 0.0)

floor_me = bpy.data.meshes.new("Floor")
bm = bmesh.new()
Expand Down Expand Up @@ -428,10 +430,14 @@ def render_still(source, unity_objs, godot_objs, path, engine):
cam_data = bpy.data.cameras.new("Cam")
cam_data.lens = 50.0
cam = bpy.data.objects.new("Cam", cam_data)
cam.location = (3.12, -8.15, 2.45)
# Oblique rather than straight down +Y: the Godot reimport lies with its
# mast along -Y, and a camera looking along Y foreshortened that mast to
# a stub in front of the base plate, so the one thing the check proves
# (it lies) barely read. From the side-front the mast shows its length.
cam.location = (4.9, -5.1, 2.2)
scene.collection.objects.link(cam)
aim = bpy.data.objects.new("Aim", None)
aim.location = (0.0, 0.0, 0.85)
aim.location = (0.40, -0.30, 1.00)
scene.collection.objects.link(aim)
con = cam.constraints.new("TRACK_TO")
con.target = aim
Expand Down
Binary file modified examples/export-preset-axis/preview.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
85 changes: 84 additions & 1 deletion examples/mesh-automasking-settings/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ with `bpy.data.brushes.new("ProbeBrush", mode="SCULPT")`. `mode="SCULPT"`
is load-bearing on 5.2: a default-mode `new(name)` leaves
`mesh_automasking_settings is None`.

**Subset** (not all 18+ attributes): `use_automasking_topology` and
**Subset** (3 of the 18 attributes; the full map is below): `use_automasking_topology` and
`use_automasking_cavity` keep their identifiers after the move; cavity
factor does not (`Brush.automasking_cavity_factor` vs
`MeshAutomaskingSettings.cavity_factor`). That pair covers the location
Expand Down Expand Up @@ -46,6 +46,89 @@ every binary.

No `SMOKE_SKIP`. Every matrix leg exercises the contract.

## Who hits this

Sculpt add-ons, brush-preset importers and tool-setting UIs that read or
write `brush.use_automasking_*` / `brush.automasking_*`. On 5.2 the first
such access raises `AttributeError: 'Brush' object has no attribute ...`.
A panel that draws it disappears from the UI, and an importer stops
halfway through a preset.

Two traps on the way to fixing it:

- **The prefix rule is not uniform.** The ten `use_automasking_*`
booleans keep their identifiers on the new struct. The eight
`automasking_*` values drop the prefix, so a blanket
`getattr(mas, old_name)` port breaks on exactly those eight.
- **A brush made without a mode has no settings struct.**
`bpy.data.brushes.new(name)` returns a brush whose
`mesh_automasking_settings` is `None` on 5.2. It must be created with
`mode="SCULPT"`. A reader that trusts the pointer then dereferences
`None`.

The version-safe reader is the one this example asserts
(`read_current`): take `.mesh_automasking_settings` when it exists and is
not `None`, otherwise fall back to the `Brush` attributes. Writes go
through the same struct and round-trip: setting `cavity_factor` and
`use_automasking_topology` on it reads back unchanged on 5.2.1.

## Full attribute map

Measured by listing `Brush` RNA on 4.5.11 and 5.1.2 (18 automasking
properties, identical on both), and `MeshAutomaskingSettings` RNA on 5.2.1
(19 properties).

| 4.5 / 5.1 `Brush.` | 5.2 `Brush.mesh_automasking_settings.` |
| --- | --- |
| `use_automasking_topology` | `use_automasking_topology` |
| `use_automasking_face_sets` | `use_automasking_face_sets` |
| `use_automasking_boundary_edges` | `use_automasking_boundary_edges` |
| `use_automasking_boundary_face_sets` | `use_automasking_boundary_face_sets` |
| `use_automasking_cavity` | `use_automasking_cavity` |
| `use_automasking_cavity_inverted` | `use_automasking_cavity_inverted` |
| `use_automasking_custom_cavity_curve` | `use_automasking_custom_cavity_curve` |
| `use_automasking_start_normal` | `use_automasking_start_normal` |
| `use_automasking_view_normal` | `use_automasking_view_normal` |
| `use_automasking_view_occlusion` | `use_automasking_view_occlusion` |
| `automasking_boundary_edges_propagation_steps` | `boundary_edges_propagation_steps` |
| `automasking_cavity_blur_steps` | `cavity_blur_steps` |
| `automasking_cavity_curve` | `cavity_curve` |
| `automasking_cavity_factor` | `cavity_factor` |
| `automasking_start_normal_falloff` | `start_normal_falloff` |
| `automasking_start_normal_limit` | `start_normal_limit` |
| `automasking_view_normal_falloff` | `view_normal_falloff` |
| `automasking_view_normal_limit` | `view_normal_limit` |
| — | `cavity_curve_op` (new in 5.2, no Brush counterpart) |

On 5.2.1 the only automasking-named property left on `Brush` is the
`mesh_automasking_settings` pointer itself.

## Re-verified

| Measurement | 4.5.11 | 5.1.2 | 5.2.1 |
| --- | --- | --- | --- |
| `bpy.types.MeshAutomaskingSettings` exists | no | no | yes |
| Automasking properties on `Brush` | 18 | 18 | 1 (the pointer) |
| `mesh_automasking_settings`, `mode="SCULPT"` | no attribute | no attribute | struct |
| `mesh_automasking_settings`, default mode | no attribute | no attribute | `None` |
| Brushes in factory-empty | 0 | 0 | 0 |
| default exit | 0 | 0 | 0 |
| `--assume-brush-attrs` exit | 0 | 0 | 5 |

Exiting 0 on 4.5.11 and 5.1.2 under the falsifier is correct by design.
The old attributes still exist there, so the naive read works; the
falsifier exists to fail where they were removed.

## API reference

- [`bpy.types.MeshAutomaskingSettings`](https://docs.blender.org/api/current/bpy.types.MeshAutomaskingSettings.html)
(5.2 only)
- [`bpy.types.Brush`](https://docs.blender.org/api/current/bpy.types.Brush.html)
([4.5 LTS](https://docs.blender.org/api/4.5/bpy.types.Brush.html), where
the `use_automasking_*` / `automasking_*` attributes live)
- [`BlendDataBrushes.new`](https://docs.blender.org/api/current/bpy.types.BlendDataBrushes.html#bpy.types.BlendDataBrushes.new)
— the `mode` argument

## Run

```bash
Expand Down
16 changes: 15 additions & 1 deletion showcase/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ entry in `showcase/gallery.json`, and a rendered still.
`--float-nails` lifts nail heads off their strap (exit 18).
`--short-mortar` stops every mortar joint shy of its stones (exit 18).
`--open-ends` restores open U-band ends so the water is not contained
(exit 18).
(exit 18). `--pile-rocks` draws scattered stones together so they
interpenetrate (file-local code; `terrain-scatter` uses 20).
`--sharp-iron` skips a chamfer pass so the edge-treatment budget fails
(file-local code; `crate-stack` uses 21).
A budget with no falsifier witnesses nothing:
Expand Down Expand Up @@ -349,6 +350,19 @@ entry in `showcase/gallery.json`, and a rendered still.
exposed face of the contents — perimeter **and** interior points, since
a rim band covers the perimeter only — and require every ray to hit the
vessel within a named reach. `--open-ends` is the falsifier.
- **Scattered parts do not interpenetrate (file-local code).** Jittered
scatter pushes neighbours into each other, and nothing else in the
hygiene family compares one scattered part with another. BVH-test every
pair and assert **0** overlaps. Space the parts structurally: relax
their centres apart to twice a radius bound **derived** from the same
constants that size them, so scaling the parts widens the spacing too.
A falsifier that only skips the relaxation proves nothing if the parts
happen to miss anyway; `terrain-scatter`'s `--pile-rocks` also draws
the scatter inward so collisions are guaranteed.
- **Rock is broken, not smooth.** A smooth-shaded ellipsoid is an egg.
Cleave it with a few closed-form planes (project every vertex beyond a
plane onto it) and shade it flat, so it reads as broken stone. Cleaving
takes volume off, so re-check scale against the host afterwards.
- **Keep a falsifier's envelope still.** A falsifier that moves the
support the piece grounds on re-grounds the whole piece and moves the
AABB with it. `water-trough`'s `--short-legs` lifted the shoes; the
Expand Down
2 changes: 1 addition & 1 deletion showcase/gallery.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@
"name": "terrain-scatter",
"dir": "showcase/terrain-scatter",
"teaches": "A Geometry Nodes hill tile with seated icosphere scatter through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.",
"alt": "A square tile of sandy rolling terrain with pale rocks scattered on the hill.",
"alt": "A square tile of dark rolling earth with nine faceted grey rocks seated across the hill, none touching.",
"witnessesFix": "Recomputed: 1758 tris, two materials with 720 stone faces, UVs in 0..1 with zero AABB overlap, outer AABB 1.800×1.800×0.552 m, grounded zmin, 9 stones of ≥80 faces seated on sampled dirt Z (offset −35 mm, floor zmin 0.113 m), LOD ratios in band, convex collider 81 tris, hygiene zero, non-empty glTF. --skip-decimate exits 9; --stray-vert 15; --lift-z 16; --poke-rock 17; --float-rocks 18; --box-rocks 19.",
"hero": "docs/gallery/assets/terrain-scatter-hero.webp",
"preview": "showcase/terrain-scatter/preview.webp",
Expand Down
51 changes: 43 additions & 8 deletions showcase/terrain-scatter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

A showcase piece, not an example. Geometry Nodes sine-hill Mesh Grid
with an Index-jittered Instance-on-Points scatter, realized cubes
replaced by closed-form displaced icospheres seated on sampled dirt Z,
replaced by closed-form cleaved, faceted stones seated on sampled dirt Z,
then the shipped pipeline: unique-cell UVs, Cycles high-to-low normal
bake, LOD chain, convex collider, Unity glTF export.

Expand All @@ -24,7 +24,7 @@ imported as a package). Hygiene combinatorics match
`examples/mesh-hygiene-audit` (copied, not imported).

Intended size: 1.80 m square hill tile, ~0.16 m sine amplitude, nine
seated stones; outer AABB 1.800 × 1.800 × 0.552 m.
seated stones; outer AABB 1.800 × 1.800 × 0.584 m.

## Budgets

Expand All @@ -38,9 +38,9 @@ materials, UVs, evaluated LOD, collider, or export file.
| LOD2 ratio | 0.10–0.35 of base | 0.2196 / 0.2196 / 0.2196 |
| Materials | exactly 2 distinct, ≥24 stone faces | 2 slots, 720 stone |
| UVs | in `0..1`, AABB overlap ≤ 1e-5 | in range, overlap 0 |
| Outer AABB | (1.800, 1.800, 0.552) m ± 0.015 | (1.8000, 1.8000, 0.5523), zmin 0 |
| Collider tris | ≤ 120 | 81 |
| Export | written, size > 0 | 158156 / 158156 / 158140 bytes |
| Outer AABB | (1.800, 1.800, 0.584) m ± 0.015 | (1.8000, 1.8000, 0.5838), zmin 0 |
| Collider tris | ≤ 120 | 72 |
| Export | written, size > 0 | 157476 / 157476 / 157460 bytes |

Base triangles rose from **1194 to 1758** in the quality pass: 9-vert hill
became a 21-vert grid, and bevelled cubes became subdiv-2 icospheres.
Expand All @@ -51,7 +51,8 @@ DECIMATE COLLAPSE triangle counts are **not** guaranteed identical across
series — the gate is a ratio band, not an exact count. This mesh matched
on 4.5.11 / 5.1.2 / 5.2.1. Bake pixels are stochastic; the gate is
`has_data` plus operator `FINISHED`, not byte-identity. Construction uses
no RNG. Export byte counts differ by 16 B on 5.2.1 (glTF serializer), not
no RNG: jitter, cleave planes and relaxation are all closed form or fixed
iteration. Export byte counts differ by 16 B on 5.2.1 (glTF serializer), not
a gated axis.

### Hygiene
Expand All @@ -69,8 +70,39 @@ Recomputed from the generated mesh, not asserted about the script.
| Grounded: `zmin` | within 1e-4 of 0 | 0.0000 |
| Stone shells | 9 | 9 |
| Per-stone faces | ≥ 40 | 80 |
| Stone floor `zmin` | ≥ 0.012 m | 0.11334 |
| Seat offset (`zmin` − dirt Z) | ≤ 0.02 m | −0.03500 |
| Stone floor `zmin` | ≥ 0.012 m | 0.11112 |
| Seat offset (`zmin` − dirt Z) | ≤ 0.02 m | −0.00745 |
| Interpenetrating stone pairs (BVH overlap) | 0 | 0 |

### Why the stones are cleaved, relaxed and faceted

The committed stones were smooth-shaded, near-white ellipsoids with a
gentle sine bump: eggs or marshmallows on a pale clay slab. Two pairs
also interpenetrated, because the GN scatter jitters by up to 0.22 m on a
0.575 m grid, and no budget compared stone to stone.

- **Cleaved.** Each ellipsoid is cut by `N_CLEAVES` planes whose normals
and depths come from the stone's own centre, closed form. Every vertex
beyond a plane is projected onto it, which leaves flat broken faces.
Stones are shaded flat, because broken rock is faceted; smooth shading
turned the cleaved stones back into eggs.
- **Scaled.** Cleaving takes about a third off each stone, and at the old
size they read as pebbles, so every semi-axis is `ROCK_SCALE` = 1.35×.
- **Relaxed.** Stone centres are pushed apart to
`2 × STONE_R_BOUND + STONE_CLEAR` over a fixed number of symmetric
passes, and clamped so each stone's bound stays on the tile.
`STONE_R_BOUND` is derived from `ROCK_SCALE` and the largest semi-axis,
bump and tilt, so scaling the stones widens the spacing with them. With
today's numbers the unrelaxed scatter would also clear; the relaxation
is what keeps that true when the scatter or the sizes change.
- **Materials.** Dark mottled soil and grey weathered stone, with
roughness and a small bump from fine object-space noise.

`stone_overlap_audit` BVH-tests every stone pair. `--pile-rocks` skips
the relaxation and draws the scatter in to 40% so neighbours collide: 6
pairs interpenetrate on 5.2.1, and the piece exits 20. Skipping the
relaxation alone would prove nothing, because the cleaved stones miss
each other unrelaxed.

### Falsifiers

Expand All @@ -85,6 +117,7 @@ and returned the same code on each.
| `--poke-rock` | stone floor `zmin` | 17 |
| `--float-rocks` | seat offset vs sampled dirt Z | 18 |
| `--box-rocks` | per-stone face floor | 19 |
| `--pile-rocks` | stone-to-stone interpenetration is 0 | 20 |

## Run

Expand All @@ -96,6 +129,7 @@ blender --background --python terrain_scatter.py -- --lift-z
blender --background --python terrain_scatter.py -- --poke-rock
blender --background --python terrain_scatter.py -- --float-rocks
blender --background --python terrain_scatter.py -- --box-rocks
blender --background --python terrain_scatter.py -- --pile-rocks
blender --background --python terrain_scatter.py -- --output terrain.png
```

Expand Down Expand Up @@ -129,3 +163,4 @@ hygiene and joint-fit family.
| 17 | Stone floor poke (`--poke-rock`) |
| 18 | Float above host (`--float-rocks`) |
| 19 | Stone shell faces (`--box-rocks`) |
| 20 | Stone pairs interpenetrate (`--pile-rocks`) |
Binary file modified showcase/terrain-scatter/preview.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading