references/critique.md
# Critique rubric: judge a hard-surface part like the experts do
Run in this order, every shading pass and before delivery:
1. `r = HS.shading_audit(ob); v = HS.verdict(r)`: what code can see.
2. `HS.review([ob], out, views=("front","threequarter","top","low"), modes=("matcap","hsgrey","reflect","wire"))`:
open the sheet.
3. One `HS.closeup(ob, point, radius, path, mode="shiny")` per cut, per union, per corner where
three bevels meet. Radius about 2 to 3 times the feature. Open each.
4. Score the table below. Report every line that is not a pass, with its number or image.
The verdict and the eye must agree. A clean verdict with an ugly closeup means a defect the code
does not measure (write it down); an ugly verdict with a clean closeup means a threshold or a false
positive (say which).
## Rubric
| # | Criterion | What the experts look at | Code check | Pass |
| --- | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
| 1 | Scale applied | even bevel widths on every axis (du Mont [00:05:28], Gambrell [00:04:29]) | `r["scale_applied"]`, cutters `scale_applied` | all true |
| 2 | Stack order | cut edges beveled, WN at the bottom, mirror above WN (Ryuu [00:26:26]) | `r["stack_problems"]` | empty |
| 3 | Flat faces read flat | no gradient smear in a shiny matcap at any angle (Gambrell [00:00:33]) | `r["normals"]["smear_faces"]`, `r["nonplanar_base_faces"]` | 0 and 0 |
| 4 | Every hard edge catches light | continuous bevel highlight, no razor edge (Gambrell [00:16:31]) | `smoothed_hard_edges` 0; clamp ratio < 0.1 | closeups show a highlight on every edge |
| 5 | Bevel width constant around cuts | the bevel does not narrow near a cut (du Mont [00:29:45]) | `r["clamp"]`, `r["clamp_culprits"]`, `sliver_report` | ratio < 0.1 |
| 6 | Cutter hygiene | smooth, applied, wire, in Cutters, no coincident faces (Gambrell [00:25:26], Ryuu [00:05:09]) | `r["cutters"]` (coplanar, near-parallel, thin wall, narrow gap, near-miss, rounding facets) | every flag false, `pokes_out` true |
| 7 | Result is solid | no holes, no red faces in Face Orientation (Ryuu [00:21:11]) | `eval_non_manifold_edges`, `booleans[*].changes_mesh` | 0, all true |
| 8 | Cuts on curves | no stretch at the corners of a cut on a curved skin (Gambrell [00:01:39]) | `r["cuts_on_curvature"]` is a prompt, not a fail | closeup clean, or `transfer_normals` added and closeup clean |
| 9 | Corners | Arc miters read round, no pinch where bevels meet (Gambrell [00:18:28], Ryuu [00:22:49]) | `folded_edges` 0 | closeup |
| 10 | SubD surface (School B) | no pinching in a rotated shiny matcap, square quads, holding edges spaced (rileyb3d [00:06:00], PzThree [00:03:15]) | `r["subd_cage"]`, `bx_audit` quads % | no pinch in closeups |
| 11 | Design | clusters vs calm areas, alignment with neighbors, functional logic, transition parts at intersections (Ryuu [00:28:37], Gambrell [00:34:15], du Mont [00:39:36]) | none | a reason for every detail |
| 12 | Silhouette at use distance | the part reads from the gameplay or shot camera (Augusto [00:14:12]) | none | silhouette row reads |
| 13 | Game (if asked) | tri budget, triangles only, custom normals survive, bake clean (Gambrell [00:21:15], Augusto [00:11:48]) | `HS.tri_count`, `game_version` checks | within budget |
## Triage: symptom to cause
| Symptom (shiny matcap, orbit) | First test | Cause | Fix | Source / evidence |
| ------------------------------------------------------------ | ----------------------------------------------- | ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| Gradient smear on large flat faces next to bevels | `smear_faces` > 0; toggle Harden / WN | averaged normals | Harden Normals on the micro bevel; WN last | Gambrell svS9 [00:02:42]; measured 12.0 to 0.02 degrees |
| Stretch in a corner, WN and Harden change nothing | `nonplanar_base_faces` | face not planar | `HS.flatten_faces` | Gambrell qB1e [00:01:06] |
| Faceting everywhere after Shade Smooth | `smoothed_hard_edges` | smoothing across hard edges | micro bevel, Smooth by Angle, or WN Keep Sharp | Gambrell 1qVb [00:07:23] |
| Facets on cut walls | `eval_flat_shaded_faces`, cutter `smooth` false | flat-shaded cutter | smooth the cutter | Gambrell 1qVb [00:25:26], Ryuu [00:10:42] |
| Cut edges sharp, rest beveled | `stack_problems` | boolean below the bevel | `HS.order_stack` | Ryuu [00:26:26] |
| Bevels narrower everywhere after a new cut | `clamp_probe` ratio, `clamp_culprits` | a near-miss, thin wall, groove under 2x width, or rounding facets at the bevel angle limit | move the cutter clearly away or exactly on; widen the feature; more segments | du Mont [00:20:18]; verified clamp 1.38, 1.36, 0.47, 1.90, 0.45, 3.8 |
| Notch or bump in a bevel next to a cut | `cutter_report.thin_wall_*` | wall thinner than 2x width | move or shrink the cut | verified on the eye-socket wedge |
| Streaks and black patches around a round cut on a round part | `eval_non_manifold_edges` | cutter facets in phase with the target facets | half-segment phase | verified: 10 non-manifold edges |
| Part vanishes or gets holes after a boolean | `boolean_report(ob, try_solvers=True)` | broken operand (open or flipped) | repair the operand, return to EXACT | Ryuu [00:15:13]; verified: EXACT 19 non-manifold, MANIFOLD skipped the cut |
| Glitch that Float fixes | `coplanar_faces` | coincident faces | offset the cutter or keep EXACT | Gambrell 1qVb [00:26:33] is outdated; verified Float 16 non-manifold |
| Stretch at the corners of a cut on a curve | `cuts_on_curvature` + closeup | beveled n-gons on curvature | end the cut on a flat area; `HS.transfer_normals` | Gambrell qB1e [00:02:45]; verified p95 2.79 to 0.34 on a sphere |
| Butterfly streak on a mirror seam | closeup of the seam | mirror + boolean normals | `HS.mirror_seam_fix` | Gambrell qB1e [00:06:57] (not reproduced here) |
| SubD pinch | shiny closeup, rotate the view | loops too close, stretched pole | space and align loops, tighten around the pole | rileyb3d [00:06:00], Gambrell svS9 [00:13:41] |
| SubD artifact from nowhere | turn subdivision off, look at the cage | interior face or bad routing | route the loop through | Gambrell svS9 [00:14:44] |
| Distortion around an inset circle | closeup | no border ring | outset a ring around it | PzThree [00:42:25] |
| UV stretch along bevels on subD | checker texture | odd segments or wrong UV Smooth | even segments, Keep Corners, apply the bevel before seams | Lampel [00:11:15] |
| Cuts disappeared on a copy | `booleans[*].changes_mesh` false | copy moved away from shared cutters | keep in place, or collapse then move | verified in test_03 |
## Design critique (no metric, still required)
- **Big, medium, small** (rileyb3d [00:01:01]): primary silhouette first, secondary cut-ins,
tertiary details last. Tertiary detail on a weak primary is a fail.
- **Detail placement**: clusters where the eye should go, calm areas elsewhere (room for decals or
serials), each detail aligned to a neighbor's edge or axis (Ryuu [00:28:37], Gambrell [00:35:21]).
Random scatter reads as noise.
- **Function**: panels, fasteners, vents and joints should suggest how the object is built and moves
(du Mont annotating the concept [00:47:13], PzThree [00:54:14]).
- **Intersections**: a perfect intersection of two parts looks fake; add a collar, grommet or
chamfer (du Mont [00:39:36]).
- **Scale**: detail size follows real size (Augusto [00:24:31]); bevel width follows real size and
viewing distance.
- **Chamfers**: planar shifts that catch light add read; too many identical bevels read as CG (Ryuu
[00:33:05]).
## Delivery statement
State: school used and why; stack per object; verdict lines left and why; tris per object (render
and game); which closeups were looked at; what was not verified (for example: mirror seam fix not
reproduced, bake not done).
references/expert-notes.md
# Expert notes: hard surface
Distilled from nine videos (notes in `notes/hard-surface/`, digest `_digest_hard_surface.md`).
Timestamps are `[hh:mm:ss]` in the named video. Items marked [verified] were re-run in Blender 5.2.1
for this skill (tests in `tests/code/blender-hard-surface/`); [added] marks conclusions that are
this skill's, not an expert's.
## The two schools and when each wins
**School A: n-gons + booleans + bevel modifier + custom normals** (Gambrell, Ryuu, du Mont; Augusto
for game low poly). Flat faces may be n-gons; edges come from Bevel modifiers; shading from Harden
Normals and Weighted Normal. **School B: quad cage + Subdivision Surface + support loops or
creases** (rileyb3d, PzThree, Lampel, the quad half of Gambrell's comparison). Edge tightness comes
from loop proximity; the cage is the design.
| Condition | Winner | Evidence |
| ----------------------------------------------------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Mostly planar machined parts, many cuts, late design changes | A | same part in about a quarter of the time, cutters stay movable (Gambrell `svS9V2iAesA` [00:18:01], [00:18:33]) |
| Game mid-poly without a bake, engine triangulates anyway | A | triangulate, Weighted Normal (Gambrell [00:19:36], [00:21:48]) |
| Continuous curvature, soft transitions (bottles, handles, organic-mechanical) | B | subD bottle in under 15 minutes (rileyb3d `EYtYCIGSSDc` [00:15:07]) |
| Non-uniform roundness along one edge | B | the one advantage Gambrell concedes (`svS9V2iAesA` [00:20:41]) |
| Part will be sculpted or needs a high poly to bake | B | apply-one-level then multires (PzThree `iNYJ3wVeysg` [00:26:28]); subD high baked to low (rileyb3d [00:32:54]) |
| Booleans on curved surfaces | B, or A with a normal fix | beveled n-gons on curvature cannot shade (Gambrell `qB1eg3ef5vs` [00:02:12]); in 5.2.1 Harden + WN already shaded cylinder cuts cleanly, `transfer_normals` fixed a sphere [verified] |
| Quad-only pipeline or portfolio review | B | (Gambrell [00:24:35]) |
| Tiny or distant details on a subD model | neither: a plain bevel, no subdivision | (Lampel `R9g7Fmrc900` [00:06:45]) |
Hybrids are normal: booleans inside a subD workflow followed by retopology into quads (Gambrell
[00:10:24]); a Bevel modifier before Subdivision as editable support loops (Lampel [00:07:32]);
Subdivision level 1 with full creases to round a coarse cylinder inside a bevel-modifier model (du
Mont [00:52:18]).
Cost measured on the test parts [added, verified]: rounded case 1,984 tris (A) vs 4,032 + 576 grip
(B, level 2); barrel 7,308 (A, render stack) vs 25,728 (B, level 2) vs 3,716 (A game copy).
## Louis du Mont (CG Boost, Frogbot course, 2026, Blender 5.x)
- Everything that shapes edges stays a modifier until the end; destructive bevels only on cutters
[00:11:52], [00:18:40].
- **Two bevels:** weight-limited design bevel, 8 then 12 segments, strong weights on silhouette
edges [00:12:25] to [00:14:07]; angle-limited micro bevel about 3 mm, 6 segments, Harden Normals
"so we don't have bulging" [00:14:40], [00:15:15].
- Micro bevel after every boolean, pinned last [00:19:46], [00:25:51]. In 5.2 pin bottom-up
[verified].
- **Bevel narrower after a boolean** = Clamp Overlap hiding thin polygons from a cutter edge near a
target edge; fix A move the cutter far away [00:21:25], fix B make it effectively coincident
[00:21:58]. Blender's own support edges can also make thin polygons: loop cut on the target
through the middle of the boolean region, between existing vertices [00:30:20], [00:30:55]. In
5.2.1 the EXACT solver produced no such slivers on any setup tried, and a support cut through a
round cut halved its rim edges and made clamping worse (0 to 1.16) [verified]; keep it for cases
where `sliver_report` shows boolean-made thin faces.
- Cutters must pass fully through; caps well past the surface [00:23:04], [00:29:09]; dissolve loops
the cutter does not need [00:24:10]; move cutters so they live only in the Cutters collection
[00:34:32].
- Apply scale before any bevel, and again after any object-mode scaling [00:05:28], [00:43:38];
apply rotation before Array on surface-aligned objects [00:32:03].
- Small parts: 1 mm bevel, 2 segments, Harden Normals [00:46:23]; collar 0.1 mm [00:41:22];
cyclorama 32 segments [01:58:34].
- Hide perfect intersections behind a collar [00:39:36]. Extrude Manifold for inward extrusions
[00:54:28]. Coarse cylinder: full creases on cap edges + Subdivision level 1 [00:52:18].
- Rig prep: duplicate the collection, Convert to Mesh on the copies (keeps custom normals)
[01:07:29] [verified via `collapse`].
- Watches bevel width on both sides of every cut; wire overlay and cutters as wire to see proximity
[00:20:52], [00:29:45].
## Josh Gambrell (Blender Bros)
**Complete beginners guide (`1qVbGr_ie30`, 2021):**
- Real objects have no perfectly sharp edges; a tiny bevel on nearly every piece makes it read real
[00:16:31].
- Bevel modifier: 3 segments "unless I'm making game assets", Harden Normals, tiny width, Miter
Outer Arc [00:17:20], [00:18:28].
- Order: booleans first, bevel after; "if something's not working put your bevel at the bottom"
[00:20:11], [00:20:44].
- Clamp Overlap is "a diagnosing" tool [00:26:00]. His fix "switch Exact to Fast, 99% of cases"
[00:26:33] is outdated: Float breaks on coplanar faces in 5.2.1 [verified].
- Mirror the cutter, not the target, with its origin at world center [00:14:53], [00:15:27]. Union
instead of Join so two pieces share a bevel [00:18:57]. Smooth-shade the cutter [00:25:26]. Make a
slice single user before applying [00:31:51].
- Every detail placed for visual balance, never random [00:34:15].
**N-gons vs quads (`svS9V2iAesA`, 2021):**
- Same part both ways: n-gon school about a quarter of the time, editable, lower poly [00:18:01],
[00:19:06].
- Harden Normals "still fighting" on the boolean model: Weighted Normal cleans it [00:02:42].
- Shading is about managing topology, not quads: the quad version shaded arguably worse [00:13:41],
[00:25:08].
- Games: everything is triangulated anyway; triangulate, Weighted Normal, check the bake [00:19:36],
[00:21:48]. Learn quads anyway [00:22:22].
- Debug subD shading by turning subdivision off (interior faces, bad routing) [00:14:44]; pinching
from a stretched pole [00:13:41].
**Why your shading is broken (`qB1eg3ef5vs`, 2022):**
- If WN or Harden Normals changes nothing, "99% of the time" the face is not flat: scale it flat
[00:01:06].
- A boolean ending on a curved surface leaves beveled n-gons that cannot shade; end it on a flat
area, or Data Transfer custom normals (Face Corner, Projected Face Interpolated) from a clean
duplicate scoped by a vertex group [00:02:12] to [00:05:29]. In 5.2.1 the vertex-group scope fails
(EXACT gives cutter-made vertices the target's weights) and a distance scope works [verified].
- Mirror + boolean butterfly: bevel Face Strength Affected + WN Face Influence, weight 100
[00:06:57].
- Inspect in MatCap, orbit, switch matcaps [00:00:33], [00:06:22].
## Ryuu (Blender Bros, no paid add-ons, 2022)
- Cutter slightly larger than the target so faces never coincide (Z-fighting) [00:05:09]; cutter
edge bevels "10 is more than enough" [00:05:42]; auto-smooth the cutter before cutting [00:10:42].
- Even bevel segments on prototypes so a game version can drop every second loop [00:10:09]
[verified: 4 to 2 segments, 7,266 to 4,166 tris on the panel].
- **Exact drops the object when the geometry is broken**; switch the solver only to see what is
broken, then repair (apply, fill holes) [00:15:13] to [00:16:49]. Verified: an open cutter gives
EXACT 19 non-manifold edges and MANIFOLD silently skips the cut.
- When fixing bevel overlaps, move the vertices that do not support the curvature, never the support
edges [00:19:01]; clamping can leave doubles: merge [00:20:39].
- Red Face Orientation means shading and baking problems [00:21:11].
- Default bevel width 0.1 m is far too big [00:22:15]. "Weighted normal should be always at the
bottom" [00:26:26]; a late Mirror goes above it [00:28:37].
- Set cylinder vertex count at creation (40 for screw holes) [00:29:43]; big rolling curve 46 to 50
segments [00:12:53]; weld fillet about 10 [00:32:31].
- Clusters of detail vs calm areas for text and serials [00:28:37]; chamfers create planar shifts
that catch light [00:33:05].
## Pedro Augusto (BCON23, hard surface for games, 2023)
- No high poly: bake Blender's Bevel shader node for edge rounding, paint a 32-bit float height for
panel lines and rivets, merge into one normal map [00:18:03], [00:19:07], [00:21:22].
- Booleans, then Decimate, then cleanup gives a usable low poly "if my smooth group is already okay"
[00:11:21] (on a boolean n-gon mesh a planar dissolve saved little, 1,268 to 1,260 tris
[verified]).
- Budget 15k tris, one 2K map, animated or breakable parts separate [00:11:48], [00:12:57]; delete
loops that do not change the silhouette [00:14:12]; UV space weighted toward what the gameplay
camera sees [00:14:45].
- Only trust blueprints with section legends; model sections at their stations and bridge
[00:07:37], [00:08:11].
- Speed is a deliverable: 8 to 10 days per aircraft engine-ready [00:03:10].
## rileyb3d (Mastering subD, 2025)
- Primary, secondary, tertiary forms in order [00:01:01]. Circles start at 18 vertices; add a
separate 36-vertex object where detail needs more [00:01:34], [00:13:20].
- As few control points as possible: the Subdivision modifier does the heavy lifting [00:04:20].
- Pinching comes from loops too tight: space and align them, judge in a shiny, rotated matcap
[00:06:00], [00:08:52]. Brace both sides of a ridge equally [00:11:08].
- UV Smooth All removes stretch on a sparse cage [00:19:34]; apply scale and rotation before
unwrapping [00:19:34].
- Low poly = duplicate, lower levels (0 small parts, 1 body), apply, dissolve everything that does
not shape the silhouette, bake the rest [00:32:54] to [00:34:31]; bake 3K export 2K, OpenGL
normals (DirectX for Unreal) [00:37:16].
## PzThree (subD to multires, 2022)
- Build as resolutions: coarse cage, apply one level, next frequency of detail, repeat; save a
numbered collection per pass [00:05:54], [00:11:28] [verified: apply one level headless].
- Square quads, never long rectangles: the grid becomes the panel layout and the sculpt surface
[00:03:15], [00:09:12].
- Postpone sharpness until the resolution supports it [00:04:19], [00:30:16]. Avoid early insets:
they kill flow [00:05:23].
- Delete faces on the mirror plane before applying subdivision [00:12:00]. Simple then Catmull-Clark
subdivision for squarer rounding [00:03:48].
- The volume-holding level is the game mesh; multires detail bakes back to it [00:25:55],
[00:26:28]. Outset a border around inset circles [00:42:25]. Soft edges suit low poly, view
distance decides [00:07:00].
- Multires per part, preview 0, bake with Clear Image off so parts share a texture [00:57:32],
[00:59:43].
## Jonathan Lampel (CG Cookie, loops vs bevels vs creases, 2021)
- Holding edges beat creases: at visual parity 3,600 tris (loops, level 2) vs 36,800 (crease,
level 5) [00:05:28].
- Creases for small, low-importance details; for tiny or distant ones drop subdivision and bevel
[00:06:01], [00:06:45].
- Bevel modifier before Subdivision: 2 segments, profile 1.0 so the original edge stays, Limit
Method Weight, lower weight = sharper [00:08:06], [00:08:39]. Even segments and UV Smooth Keep
Corners, apply the bevel before seams [00:11:15], [00:11:46].
## Disagreements and deciding conditions
| Topic | Position A | Position B | Deciding condition |
| ----------------------- | ------------------------------------------------ | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| Solver | Fast fixes glitches (Gambrell 2021) | Exact, switch only to diagnose (Ryuu) | EXACT default; MANIFOLD for speed on manifold operands; FLOAT only to diagnose [verified] |
| Harden Normals vs WN | Harden is enough (du Mont) | WN when harden fights (Gambrell svS9, Ryuu) | Harden fixes faces next to bevels; WN fixes large flat regions with booleans; neither fixes non-planar faces. Both together is the toolkit default. |
| Clamp Overlap | off (Ryuu) | on, fix geometry (du Mont) | on for automated work; any clamp effect is a geometry defect, and it is global [verified] |
| Cutter proximity | far from target edges (du Mont) | exactly coincident (du Mont fix B) | far is robust; coincident only when exact; near-but-not-equal is the failure [verified] |
| Creases | avoid for main edges (Lampel) | full creases at level 1 on a coarse cylinder (du Mont) | creases to round a silhouette or for small details; support loops for primary edges |
| UV Smooth | Keep Corners (Lampel) | All (rileyb3d) | Keep Corners with a bevel or tight loops on seams; All for sparse smooth cages |
| Where to mirror | target (Ryuu) | cutter (Gambrell) | target when the whole part is symmetric; cutter when only the cut is |
| Apply booleans | live to the end (du Mont, Gambrell) | apply early (Augusto, Ryuu) | live while designing; apply for edit-mode access, a clean low poly or quads |
| High poly for games | none, Bevel-node bake + painted height (Augusto) | subD or multires high (rileyb3d, PzThree) | schedule vs need for sculpted damage and complex curvature |
| Sharpness timing | tighten loops now (Gambrell, rileyb3d) | postpone (PzThree) | single-pass cage vs iterative apply-one-level |
| Triangulate vs WN order | triangulate then WN (Gambrell spoken) | WN then triangulate (digest) | WN then Triangulate (keep custom normals) kept normals through glTF [verified]; compare in a matcap if unsure |
## Added by this skill (measured in 5.2.1)
- Clamp Overlap limits the bevel over the whole object: a slot 0.7 degrees from a facet edge
displaced a port rim 3.8 mm elsewhere; a groove across a design bevel narrowed the whole panel
1.9x.
- Every wall, step, groove and rounding facet must be wider than 2x the micro width; rounding facets
must stay clearly under the bevel angle limit (6 segments per half circle at 30 degrees clamped, 8
did not).
- Round cutters in phase with the target facets give EXACT non-manifold slivers (10 edges, visible
streaks); a half-segment offset is clean.
- Data Transfer must sit after WN, be scoped by distance, and draw from the uncut curved faces only
(minus a ring touching flat faces), with area weighting on long extrusions.
- Game copies and slices share their cutters: moving them drops the cuts.
references/procedures.md
# Tested procedures (Blender 5.2.1 LTS, headless)
Every block below ran on the installed Blender 5.2.1 with
`blender --background --factory-startup --python-exit-code 1 --python <test>`. Tests live in
`tests/code/blender-hard-surface/`:
| Test | What it proves | Result |
| ------------------------------ | ---------------------------------------------------------------------------------------------------------- | ------ |
| `test_01_stack_and_cutters.py` | apply_scale, stack order and pin semantics, cutters, slice, stack_problems, game_version, collapse | 26/26 |
| `test_02_shading_audit.py` | every expert failure mode reproduced, detected, fixed or measured; failure gallery renders | 32/32 |
| `test_03_parts.py` | three parts, both schools, audits and renders (writes `test_03_parts.blend`) | 12/12 |
| `test_04_game_routes.py` | glTF round trip, planar dissolve, subD low, PzThree apply-one-level, segment halving (needs test_03 first) | 5/5 |
| `test_05_raw_snippets.py` | the raw-bpy snippets of this file, no toolkit | 11/11 |
Renders and JSON: `archive/tests/hard_surface/skill_out/` (`parts/`, `failures/`, `sheet_*.png`).
## P0. Import and conventions
```python
import sys
sys.path.append("<skills>/scenario-blender-hard-surface/scripts") # also puts scenario-blender-expert/scripts on the path
import bx_hardsurface as HS
```
World-space meters. Objects are expected at scale 1 (`HS.apply_scale`). Cutters go to a `Cutters`
collection, helper objects (normal sources) to `HS_Helpers`. The module never touches objects it was
not given.
## P1. School A part end to end: sci-fi panel
Verified on 5.2.1, `test_03_parts.py` (Panel_A: verdict empty, 5,676 tris, custom normals,
manifold).
```python
import bmesh, bpy
from mathutils import Vector
def cube(name, size, loc):
me = bpy.data.meshes.new(name); bm = bmesh.new()
bmesh.ops.create_cube(bm, size=1.0)
bmesh.ops.scale(bm, vec=Vector(size), verts=bm.verts)
bm.to_mesh(me); bm.free()
ob = bpy.data.objects.new(name, me); ob.location = loc
bpy.context.scene.collection.objects.link(ob); bpy.context.view_layer.update()
return ob
P = cube("Panel_A", (0.6, 0.4, 0.06), (0, 0, 0)) # top at z = 0.03
top = HS.find_edges(P, min_angle=80, inside=((-1, -1, 0.029), (1, 1, 0.031)))
HS.set_edge_attr(P, top, 1.0) # design bevel on the top rim
HS.hard_surface_stack(P, micro_width=0.0015, micro_segments=3, design_width=0.012, design_segments=8)
for i, y in enumerate((-0.1, 0.1)): # stadium pockets, 12 mm deep
HS.make_cutter(P, "SLOT", (0.22, 0.05, 0.06), (-0.14, y, 0.03 - 0.012 + 0.03), vertices=12,
name=f"Panel_slot{i}")
for i in range(6): # vents, 8 mm deep, 4-segment corners
HS.make_cutter(P, "BOX", (0.012, 0.12, 0.06), (0.08 + 0.03 * i, 0.08, 0.03 - 0.008 + 0.03),
chamfer=0.004, chamfer_segments=4, name=f"Panel_vent{i}")
HS.make_cutter(P, "CYLINDER", (0.1, 0.1, 0.06), (0.17, -0.1, 0.03 - 0.015 + 0.03), vertices=64,
name="Panel_port") # round port, 15 mm deep
HS.make_cutter(P, "CYLINDER", (0.04, 0.04, 0.03), (0.17, -0.1, 0.026 - 0.015), vertices=48,
operation="UNION", name="Panel_boss") # boss, top 4 mm under the rim
groove = HS.make_cutter(P, "BOX", (0.7, 0.01, 0.006), (0, -0.2, 0), name="Panel_railgroove", add=False)
mi = groove.modifiers.new("Mirror", "MIRROR") # Gambrell: mirror the cutter
mi.use_axis = (False, True, False)
mi.mirror_object = P
HS.add_boolean(P, groove)
HS.make_cutter(P, "SLOT", (0.34, 0.004, 0.04), (-0.005, 0, 0.03 - 0.004 + 0.02), rotation_deg=(0, 0, 90),
vertices=8, name="Panel_line") # 4 x 4 mm line (> 2 x 1.5 mm bevel), ends 30 mm from the edge
r = HS.shading_audit(P)
print(HS.verdict(r)) # [] ; stack: Bevel_design, 12 booleans, Bevel_micro*, WeightedNormal*
```
Two variants recorded in the same test, both flagged by the audit:
- the panel line as a 3 mm box running across the design-beveled rim: cut on curvature, and Clamp
Overlap ratio 1.90, which narrowed the micro bevel on the whole panel (the closeups show thinner
highlights everywhere); 3 mm is also exactly 2x the width, so the crossing and the gap add up;
- the line rounded with 6 segments per half circle (30-degree facets = the bevel angle limit):
`beveled_narrow_facet_edges` 34, clamp 0.45, `clamp_culprits == ["Bool_PanelB_line"]`.
## P2. Cutter recipes
Verified on 5.2.1, `test_01`, `test_03`.
```python
# box, rounded vertical corners (4+ segments per 90 degrees keeps facets under a 30-degree bevel limit)
HS.make_cutter(target, "BOX", (0.3, 1.0, 0.3), (x, y, z), chamfer=0.03, chamfer_segments=6, name="Cut_slot")
# cylinder: vertex count now, half-segment phase by default (phase_deg=... to override)
HS.make_cutter(target, "CYLINDER", (0.12, 0.12, 0.5), (x, y, z), vertices=40, name="Cut_hole")
# horizontal cylinder (eye socket, grip bar): rotate the local Z axis
HS.make_cutter(target, "CYLINDER", (0.12, 0.12, 1.0), (x, y, z), rotation_deg=(0, 90, 0), name="Cut_eye")
# stadium slot, `vertices` per half circle (8+ at a 30-degree bevel limit)
HS.make_cutter(target, "SLOT", (0.2, 0.07, 0.12), (x, y, z), vertices=16, name="Cut_recess")
# union part (boss, grip bar): same call, operation="UNION"
# custom shape (washer for a ring groove): build it, then apply the checklist and wire it
w = washer("Groove", r_in=0.19, r_out=0.26, h=0.012, segments=48, phase=0.5) # bmesh, see test_03
HS.as_cutter(w, target); HS.add_boolean(target, w)
# slice (Bool Tool's Slice): Difference on the target, Intersect on a single-user copy
piece = HS.slice_boolean(target, HS.make_cutter(target, "BOX", (0.02, 1, 1), (x, 0, 0), add=False))
```
`make_cutter` returns a cutter at scale 1, smooth shaded, linked only to `Cutters`,
`display_type='WIRE'`, `hide_render=True`, parented to the target with keep-transform, wired with
`solver='EXACT'` just after the last boolean and above the micro-bevel tail. Parented cutters follow
a moved target (verified). `HS.hide_cutters(target)` hides them in the viewport; booleans keep
evaluating (verified).
Copies share cutters: `game_version` and `slice_boolean` copies must stay in place (a moved copy
lost every cut in test_03). Use `HS.collapse` and move the collapsed mesh.
## P3. The stack by hand (no toolkit)
Verified on 5.2.1, `test_05_raw_snippets.py` R1 to R3.
```python
import bmesh, bpy, math
from mathutils import Matrix, Vector
bpy.ops.mesh.primitive_cube_add(size=1.0)
ob = bpy.context.active_object
ob.scale = (0.6, 0.4, 0.2)
ob.data.transform(Matrix.Diagonal(ob.scale.to_4d())) # data-level Apply Scale (mesh not shared)
ob.scale = (1, 1, 1)
for p in ob.data.polygons:
p.use_smooth = True
me = ob.data # design bevel weights on the top rim
w = me.attributes.get("bevel_weight_edge") or me.attributes.new("bevel_weight_edge", "FLOAT", "EDGE")
for e in me.edges:
a, b = (me.vertices[i].co for i in e.vertices)
if a.z > 0.09 and b.z > 0.09:
w.data[e.index].value = 1.0
d = ob.modifiers.new("Bevel_design", "BEVEL")
d.limit_method = "WEIGHT"; d.width = 0.02; d.segments = 8; d.miter_outer = "MITER_ARC"
m = ob.modifiers.new("Bevel_micro", "BEVEL")
m.limit_method = "ANGLE"; m.angle_limit = math.radians(30); m.width = 0.003; m.segments = 3
m.harden_normals = True; m.miter_outer = "MITER_ARC" # Clamp Overlap stays on (default)
wn = ob.modifiers.new("WeightedNormal", "WEIGHTED_NORMAL")
wn.keep_sharp = True
wn.use_pin_to_last = True # pin bottom-up: WN first ...
m.use_pin_to_last = True # ... then the micro bevel
cut_me = bpy.data.meshes.new("Cut_slot") # cutter: built at size, smooth, own collection
bm = bmesh.new()
bmesh.ops.create_cube(bm, size=1.0)
bmesh.ops.scale(bm, vec=Vector((0.12, 0.6, 0.12)), verts=bm.verts)
vert_edges = [e for e in bm.edges if abs((e.verts[1].co - e.verts[0].co).normalized().z) > 0.99]
bmesh.ops.bevel(bm, geom=vert_edges, offset=0.02, segments=4, profile=0.5, affect="EDGES", clamp_overlap=True)
for f in bm.faces:
f.smooth = True
bm.to_mesh(cut_me); bm.free()
cutter = bpy.data.objects.new("Cut_slot", cut_me)
cutter.location = (0, 0, 0.1)
cols = bpy.data.collections.get("Cutters") or bpy.data.collections.new("Cutters")
if cols.name not in bpy.context.scene.collection.children:
bpy.context.scene.collection.children.link(cols)
cols.objects.link(cutter)
cutter.display_type = "WIRE"; cutter.hide_render = True
cutter.parent = ob; cutter.matrix_parent_inverse = ob.matrix_world.inverted()
bo = ob.modifiers.new("Bool_slot", "BOOLEAN") # lands above the pinned pair
bo.object = cutter; bo.operation = "DIFFERENCE"; bo.solver = "EXACT"
# stack: Bevel_design, Bool_slot, Bevel_micro, WeightedNormal ; evaluated mesh has custom normals
```
Reordering a pinned stack (R2): `modifiers.move` silently refuses moves that involve pinned
modifiers.
```python
t = ob.modifiers.new("Triangulate", "TRIANGULATE"); t.keep_custom_normals = True # lands above pins
for x in ob.modifiers:
x.use_pin_to_last = False
ob.modifiers.move(ob.modifiers.find("Triangulate"), len(ob.modifiers) - 1)
for name in ("Triangulate", "WeightedNormal", "Bevel_micro"): # re-pin from the bottom up
ob.modifiers[name].use_pin_to_last = True
```
Smooth by Angle (R3, replaces Auto Smooth; optional when Harden Normals and WN are present):
```python
with bpy.context.temp_override(object=ob, active_object=ob, selected_objects=[ob],
selected_editable_objects=[ob]):
bpy.ops.object.shade_auto_smooth(angle=math.radians(30))
# lands pinned at the top of the pinned group (above a pinned micro bevel, below the booleans)
```
`bmesh.ops.bevel` defaults are `segments=0, profile=0.0`: always pass both.
## P4. Gate after every cut
Verified on 5.2.1, `test_02_shading_audit.py` sections (c), (d), (k).
```python
c = HS.make_cutter(target, ...)
rep = HS.cutter_report(c, target) # scale_applied, smooth, only_in_cutters, parented_to_target,
# coplanar_faces, near_parallel_faces, thin_wall_faces/min/at, narrow_gap_faces/min,
# near_miss_verts, beveled_narrow_facet_edges, pokes_out
cl = HS.clamp_probe(target) # {"ratio": displacement / width, ...}; > 0.1 = clamping
if cl["ratio"] and cl["ratio"] > 0.1:
print(HS.clamp_culprits(target)) # booleans whose removal ends the clamping
print(HS.sliver_report(target)["per_boolean"]) # sub-width edges/faces next to beveled edges, with positions
HS.closeup(target, c.location, 0.1, "/abs/out/cut.png", mode="shiny")
```
Measured cases (micro width 0.02 on a 1 x 0.6 x 0.4 block unless noted):
| Case | clamp ratio | cutter_report | Verdict |
| ------------------------------------------------------------------------------- | ------------------- | ------------------------- | ----------------------------------------------------------- |
| pocket floor 0.1 below the top | 0.0 | clean | clean |
| pocket floor 1 mm below the top | 1.38 | near_parallel 1 | flagged |
| side wall 1.5 mm from the end face | 1.36 | near_parallel 1 | flagged |
| same slot extended past the end | 0.0 | clean | clean |
| eye-socket cylinder 3 mm under a sloped top (bevel 8 mm) | 0.0 | thin_wall 7, min 3.0 mm | flagged; visible notch in the top bevel |
| same, plus a support cut through the hole (plane moved between cutter vertices) | 1.16 | | worse: rim bevel collapsed |
| eye lowered so the wall is 44 mm | 0.0 | clean | clean |
| groove 2.0 / 2.9 / 3.1 / 4.0 mm wide, micro 1.5 mm | 0.47 / 0.05 / 0 / 0 | narrow_gap on 2.0 and 2.9 | flagged / flagged (16 hard edges unbeveled) / clean / clean |
| 24-segment cylinder, slot rim 0.7 degrees from a facet edge | 3.80 | | the port elsewhere lost its rim bevel (3.8 mm displacement) |
Clamp Overlap is global: the peak displacement (`worst_at`) is often not at the cause, hence
`clamp_culprits`.
## P5. Shading audit and triage loop
Verified on 5.2.1, `test_02` (a), (g), (h).
```python
r = HS.shading_audit(ob)
for line in HS.verdict(r):
print(line)
# r["normals"]: smear_faces / smear_area_pct / smear_worst_deg (corner normals leaning off flat faces),
# smoothed_hard_edges (dihedral 45 to 150 deg with continuous normals), folded_edges (> 150 deg)
# r["nonplanar_base_faces"], r["eval_flat_shaded_faces"], r["sharp_edges_on_smooth_surface"],
# r["cuts_on_curvature"], r["clamp"], r["clamp_culprits"], r["booleans"], r["cutters"], r["slivers"]
```
Measured smear on a slot box: no Harden and no WN 10 flat faces, worst 12.0 degrees; Harden only
0.02; WN only 1.91 (under the 3-degree tolerance); both 0.02. Shade Smooth with no bevel or WN: 12
hard edges smoothed across, 0 after `hard_surface_stack`. A flat-shaded cutter: flat-shaded faces in
the result and the cutter named.
## P6. Non-planar faces (Gambrell case 1)
Verified on 5.2.1, `test_02` (b).
```python
bad = HS.nonplanar_faces(ob) # [(face index, deviation in meters)], tolerance 1e-4 x size
left = HS.flatten_faces(ob) # projects vertices onto face planes, 5 passes; returns max deviation left
```
A box with one top corner raised 6 mm: 1 non-planar face (0.0015 m). With Harden and WN the corner
normals stayed consistent (smear metric 0), so the measurable gate here is planarity, not the normal
check; judge the stretch in a closeup. `flatten_faces` returned 0.0.
## P7. Cuts on curved surfaces (Gambrell case 2)
Verified on 5.2.1, `test_02` (f), (l); `test_03` barrels.
```python
print(HS.cuts_on_curvature(ob)) # [{"cutter": ..., "curved_faces_hit": n}], pre-boolean state incl. design bevel
dt = HS.transfer_normals(ob) # after WN, distance-scoped, source = uncut curved faces
```
Raw settings (R7):
```python
dt = dome.modifiers.new("NormalTransfer", "DATA_TRANSFER") # AFTER Weighted Normal
dt.object = src; dt.use_object_transform = True # src: clean uncut copy, smooth
dt.use_loop_data = True; dt.data_types_loops = {"CUSTOM_NORMAL"}
dt.loop_mapping = "POLYINTERP_LNORPROJ"; dt.mix_factor = 1.0
dt.use_max_distance = True; dt.max_distance = 1e-4 * max(dome.dimensions) # only corners ON the skin
```
Measured normal error against the true surface (p95, degrees):
| Part | Harden + WN | + transfer_normals | DT placed before WN |
| ------------------------------------------- | ----------- | ------------------ | ------------------- |
| 32 x 16 sphere, box port | 2.79 | 0.34 | 2.79 (no effect) |
| 24-segment cylinder with design bevel, port | 3.26 | 0.07 | 3.26 |
| 48-segment cylinder, port | 0.61 | 0.07 | 0.61 |
No new smear on flat faces with the toolkit version. The cylinder cuts already looked clean without
it in shiny closeups; the sphere got visibly smoother. What failed first (recorded so nobody retries
it): scoping by vertex group (EXACT gives cutter-made vertices the target's weights, 1.0 measured,
so the port walls took the cylinder normals and shaded wrong); a source cropped around the cut (its
border normals made a seam); a whole-object source with sharp borders, and a curved-only source,
both let the cap corners on the design-bevel border take the strip normals (2 caps smeared, 7.4
degrees; fixed by also dropping the curved ring that meets a flat face smoothly); a plain smooth
source tilted the long side faces of the cylinder by 3.75 degrees where they meet the design-bevel
round (fixed by the source's own Weighted Normal, chosen automatically when neighboring face areas
differ more than 4x; a sphere keeps plain smoothing, where WN would give back the 2.79-degree
error).
## P8. Mirror seam butterfly (Gambrell case 3)
Verified on 5.2.1 (props), `test_02` (j), `test_05` R4. The artifact itself was not reproduced.
```python
HS.mirror_seam_fix(ob)
# raw: bevel.face_strength_mode = "FSTR_AFFECTED"; wn.use_face_influence = True; wn.weight = 100
```
## P9. School B: case with a recessed handle
Verified on 5.2.1, `test_03` (Case_B: 14-face cage, 100% quads, 8 valence-3 poles, 4,032 tris at
level 2; grip 576).
```python
CB = cube("Case_B", (0.4, 0.25, 0.3), (1.0, 0.6, 0))
bm = bmesh.new(); bm.from_mesh(CB.data); bm.faces.ensure_lookup_table()
topf = max(bm.faces, key=lambda f: f.calc_center_median().z)
bmesh.ops.inset_region(bm, faces=[topf], thickness=0.09, depth=0.0)
ext = bmesh.ops.extrude_face_region(bm, geom=[topf])
bmesh.ops.translate(bm, vec=(0, 0, -0.06), verts=[e for e in ext["geom"] if isinstance(e, bmesh.types.BMVert)])
bmesh.ops.delete(bm, geom=[topf], context="FACES") # extrude_face_region keeps the original face
bm.to_mesh(CB.data); bm.free()
a = CB.data.attributes.new("bevel_weight_edge", "FLOAT", "EDGE")
for e in CB.data.edges:
p, q = (CB.data.vertices[i].co for i in e.vertices)
vertical_outer = abs(p.x - q.x) < 1e-6 and abs(p.y - q.y) < 1e-6 and abs(abs(p.x) - 0.2) < 1e-6
a.data[e.index].value = 1.0 if vertical_outer else 0.15 # round corners, crisp rims (lower = sharper)
HS.subd_stack(CB, support_width=0.04, levels=2) # Bevel Weight 2 seg profile 1.0 > Subsurf, Keep Corners
# grip: a separate 12-segment subD cylinder (rileyb3d: dense details as their own object)
```
Gotcha: `extrude_face_region` leaves the original face in place: delete it or the cage is
non-manifold (checked: 0 non-manifold edges after the delete). The audit flags the 4 inset ring
faces as long rectangles (aspect over 2.5): only matters if the cage will be sculpted or subdivided
further (PzThree).
## P10. School B: barrel as a lathe cage
Verified on 5.2.1, `test_03` (Barrel_B: 314 faces, 99.4% quads, 2 flat n-gon caps, verdict empty,
25,728 tris at level 2).
```python
import math
def ring_mesh(name, profile, segments):
me = bpy.data.meshes.new(name); bm = bmesh.new()
rings = [[bm.verts.new((r * math.cos(2 * math.pi * i / segments), r * math.sin(2 * math.pi * i / segments), z))
for i in range(segments)] for r, z in profile]
for a, b in zip(rings, rings[1:]):
for i in range(segments):
j = (i + 1) % segments
bm.faces.new((a[i], a[j], b[j], b[i]))
bm.faces.new(list(reversed(rings[0]))); bm.faces.new(rings[-1])
bmesh.ops.recalc_face_normals(bm, faces=bm.faces)
bm.to_mesh(me); bm.free()
ob = bpy.data.objects.new(name, me); bpy.context.scene.collection.objects.link(ob)
return ob
prof = [(0.2, -0.3), (0.2, -0.24), (0.2, -0.186), (0.19, -0.186), (0.19, -0.174), (0.2, -0.174),
(0.2, -0.06), (0.2, 0.06), (0.2, 0.174), (0.19, 0.174), (0.19, 0.186), (0.2, 0.186),
(0.2, 0.24), (0.2, 0.3)] # sharp profile; rings every ~0.06 keep quads squarer
BB = ring_mesh("Barrel_B", prof, 24)
w = BB.data.attributes.new("bevel_weight_edge", "FLOAT", "EDGE")
bm = bmesh.new(); bm.from_mesh(BB.data); bm.edges.ensure_lookup_table()
for e in bm.edges:
if len(e.link_faces) == 2 and math.degrees(e.calc_face_angle(0.0)) > 60:
zs = [v.co.z for v in e.verts]
w.data[e.index].value = 1.0 if abs(abs(zs[0]) - 0.3) < 1e-6 else 0.1 # round caps, crisp grooves
bm.free()
HS.subd_stack(BB, support_width=0.02, levels=2)
```
## P11. Support loops, creases
Verified on 5.2.1, `test_05` R5, R6; `support_loops` in the module (bmesh bevel, even segments
enforced).
```python
HS.support_loops(ob, edge_indices, offset=0.005, segments=2) # destructive holding edges, profile 1.0
# raw bevel-before-subdivision (Lampel):
sup = sb.modifiers.new("Support", "BEVEL")
sup.limit_method = "WEIGHT"; sup.width = 0.03; sup.segments = 2; sup.profile = 1.0
ss = sb.modifiers.new("Subdivision", "SUBSURF")
ss.levels = 2; ss.render_levels = 2; ss.uv_smooth = "PRESERVE_CORNERS"
# creases, small details only:
c = sb.data.attributes.get("crease_edge") or sb.data.attributes.new("crease_edge", "FLOAT", "EDGE")
c.data[0].value = 1.0 # Subdivision.use_creases is True by default
```
The weighted 0.5 m box kept 0.5 m under subdivision (support holds the shape).
## P12. Game routes
Verified on 5.2.1, `test_03`, `test_04`.
```python
g, tris = HS.game_version(ob, micro_segments=1, design_segments=2) # in place; Triangulate keep custom normals, last
bpy.ops.export_scene.gltf(filepath="/abs/x.glb", export_format="GLB", use_selection=True,
export_apply=True, export_normals=True) # select g first
```
- Barrel mid-poly: 7,308 tris (render stack) to 3,716 (1-segment micro, 2-segment design); glTF
re-import matched 100% of (position, normal) pairs, custom normals present.
- Planar low poly from booleans (Augusto): collapse a stack without bevels, `DECIMATE`
`decimate_type='DISSOLVE'`, `angle_limit=radians(1)`, `delimit={'SHARP','MATERIAL','SEAM'}`
(values are a suggestion): 1,268 to 1,260 tris on the panel, manifold, same size. Little to gain
on a boolean n-gon mesh; the big savings come from dropping bevel segments.
- Live segment halving (Ryuu's even segments): panel micro bevel 4 segments 7,266 tris, 2 segments
4,166.
- SubD high to low (rileyb3d): same object at level 1: 25,728 to 6,432 tris; dissolve non-silhouette
loops and bake in scenario-blender-uv-baking.
- Multires bake, Bevel-shader-node bake and painted 32-bit height (PzThree, Augusto): see
scenario-blender-uv-baking; not run here.
## P13. PzThree iteration: apply one subdivision level per pass
Verified on 5.2.1, `test_04` (14 to 56 faces).
```python
col = bpy.data.collections.new("pass_1"); bpy.context.scene.collection.children.link(col)
it = cage.copy(); it.data = cage.data.copy(); it.name = cage.name + "_pass1"; col.objects.link(it)
ss = next(m for m in it.modifiers if m.type == "SUBSURF"); ss.levels = 1
with bpy.context.temp_override(object=it, active_object=it, selected_objects=[it]):
bpy.ops.object.modifier_apply(modifier=ss.name)
# delete faces on the mirror plane first so edges do not taper (PzThree [00:12:00])
```
## P14. Review renders
Verified on 5.2.1, `test_03`, `test_02`; `test_05` R10 lists the matcaps.
```python
HS.review([ob], "/abs/out/dir", views=("front", "threequarter", "top", "low"),
modes=("matcap", "hsgrey", "reflect", "wire"), res=560) # evaluated copies, cutters never render
HS.closeup(ob, point, radius, "/abs/out/cut.png", direction=(-0.6, -1, 0.8), mode="shiny", res=480)
```
Modes: `matcap` (clay_studio), `normals` (check_normal+y), `wire`, `silhouette` from bx_review, plus
`shiny` (metal_carpaint), `reflect` (check_reflection_horizontal), `hsgrey` (hard_surface_gray),
`fullmetal`. `review` bakes the evaluated mesh into temporary objects, so the wire row shows the
real boolean topology (Exact's split edges across n-gons are normal).
## P15. Collapse for rigging or export (du Mont)
Verified on 5.2.1, `test_01`, `test_05` R9.
```python
col = HS.collapse(ob, "Part_collapsed") # new object, modifiers baked, custom normals kept
# raw:
dg = bpy.context.evaluated_depsgraph_get()
baked = bpy.data.meshes.new_from_object(ob.evaluated_get(dg), preserve_all_data_layers=True, depsgraph=dg)
```
Keep the editable original hidden in its own collection (du Mont's "frogbot editable").
## Not run here
- Interactive tools (Bool Tool shortcuts, Add Cube tool on a surface, curve Draw, loop cut and
slide): replaced by the calls above; `scene.ray_cast` gives a surface point and normal to place a
cutter (digest).
- `bpy.ops.mesh.extrude_manifold`, `select_similar`, `select_edge_ring_multi`: op ids verified by
the distiller, behavior not run.
- Sculpted damage on multires (PzThree): GUI with bx_gui, see scenario-blender-sculpting.
references/sources.md
# Sources
Nine videos, transcribed and distilled (notes in `notes/hard-surface/`, digest
`_digest_hard_surface.md`). No frame sheets existed for this batch: visual details come from speech.
Several videos use paid add-ons (HardOps, BoxCutter, MESHmachine, UV Packmaster, Fluent, SimpleBake,
KIT OPS); only their stock-reproducible ideas are kept.
| Video | Expert and credential | Date, length, Blender | Best for | Best timestamps |
| ---------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| [Hard-Surface Modeling for Beginners (Frogbot)](https://www.youtube.com/watch?v=zxhaCD-Zlfw) `zxhaCD-Zlfw` | Louis du Mont, 3D artist and instructor, CG Boost | 2026-05-13, 2:18:23, 5.x (likely 5.1) | the stock non-destructive stack on a real prop: two bevels, boolean order, pin to last, bevel-shrink diagnosis, rig prep | [00:12:25] design bevel, [00:14:40] micro bevel, [00:19:46] order, [00:20:18] to [00:21:58] clamp and proximity, [00:30:20] support loop fix, [00:39:36] collar, [00:52:18] creased cylinder, [01:07:29] collapse |
| [COMPLETE Beginners Guide to Hard Surface](https://www.youtube.com/watch?v=1qVbGr_ie30) `1qVbGr_ie30` | Josh Gambrell, founder of Blender Bros, hard-surface instructor, about 10 years in Blender | 2021-12-10, 40:04, 2.9x/3.0 | plain rules of the n-gon school: bevel defaults, order, clamp as a diagnostic, cutter shading, mirror the cutter | [00:05:48] chamfer then bevel, [00:14:53] mirror the cutter, [00:17:20] bevel defaults, [00:20:11] order, [00:25:26] smooth cutter, [00:26:00] clamp overlap, [00:34:15] detail placement |
| [Why ngons are BETTER than quads for hard surface](https://www.youtube.com/watch?v=svS9V2iAesA) `svS9V2iAesA` | Josh Gambrell (as above) | 2021-02-16, 27:07, 2.91/2.92 | same part in both schools: deciding conditions, game argument, when quads are required | [00:02:42] WN over Harden, [00:10:24] boolean then retopo, [00:13:41] pinching, [00:14:44] subD debugging, [00:18:01] time ratio, [00:19:36] games, [00:20:41] subD advantage, [00:24:35] quad-only pipelines |
| [Why Your Shading Is Broken](https://www.youtube.com/watch?v=qB1eg3ef5vs) `qB1eg3ef5vs` | Josh Gambrell (as above) | 2022-01-10, 8:43, 3.0 | three-case shading triage: non-planar faces, cuts on curves, mirror seam | [00:01:06] non-planar test, [00:02:12] curved-surface n-gons, [00:03:53] Data Transfer, [00:05:29] vertex-group scope, [00:06:57] face strength + WN face influence |
| [Blender Hard Surface for Beginners, No Paid Addons](https://www.youtube.com/watch?v=mEPUWH_xooM) `mEPUWH_xooM` | Ryuu, Blender Bros instructor | 2022-10-21, 33:53, 3.3 | boolean failure modes: Z-fighting, Exact dropping the mesh, flipped faces, clamp doubles; stack with WN and Mirror; even segments | [00:05:09] cutter overlap, [00:10:09] even segments, [00:15:13] Exact vanishing, [00:19:01] which verts to move, [00:21:11] face orientation, [00:26:26] WN at the bottom, [00:28:37] late mirror, detail clusters |
| [Hardsurface Modeling Techniques for Games (BCON23)](https://www.youtube.com/watch?v=UlUuwrXoWis) `UlUuwrXoWis` | Pedro Augusto, hard-surface artist, about 21 to 22 years in 3D, Ten Square Games (Wings of Heroes) | 2023-10-28, 33:52, 3.6/4.0 | a shipped no-high-poly game pipeline: booleans, decimate, cleanup, Bevel-node bake, painted height, budget and UV priority | [00:07:37] blueprints, [00:11:21] booleans then decimate, [00:11:48] budget, [00:14:45] UV priority, [00:18:03] Bevel node, [00:19:07] 32-bit height |
| [Mastering SubD Modeling in Blender](https://www.youtube.com/watch?v=EYtYCIGSSDc) `EYtYCIGSSDc` | Riley (rileyb3d), author of free Blender courses and extensions | 2025-04-17, 39:43, 4.3/4.4 | subD judgment on a curved prop (few control points, pinch avoidance, bracing) and the subD high to game low reduction | [00:01:01] three levels, [00:04:20] few points, [00:06:00] pinching, [00:11:08] bracing, [00:19:34] UV Smooth All, [00:32:54] low poly from high, [00:37:16] bake settings |
| [SubD Workflow (subD to multires)](https://www.youtube.com/watch?v=iNYJ3wVeysg) `iNYJ3wVeysg` | PzThree, hard-surface and sci-fi artist, sells assets and add-ons | 2022-09-27, 1:20:40, 3.2/3.3 | iterative apply-one-level modeling, square quads, per-part multires baked into one texture | [00:03:15] square quads, [00:04:19] late sharpness, [00:11:28] save each pass, [00:12:00] mirror-plane faces, [00:25:55] volume-holding level, [00:42:25] outset ring, [00:57:32] multires bake |
| [When NOT to use creases: Loops vs Bevels vs Creases](https://www.youtube.com/watch?v=R9g7Fmrc900) `R9g7Fmrc900` | Jonathan Lampel, CG Cookie instructor | 2021-09-14, 13:12, 2.93/3.0 | quantified choice of sharpening method for subD, bevel before subdivision and its UV traps | [00:01:58] profile 1.0, [00:05:28] 3,600 vs 36,800 tris, [00:06:45] tiny details, [00:08:06] bevel before subdivision, [00:11:15] even segments + Keep Corners |
## Version translation
- All 2021 to 2022 videos use Auto Smooth (removed in 4.1) and the "Fast" solver (renamed Float in
5.0; Manifold added in 4.5); du Mont (2026) is current.
- Bool Tool, LoopTools, F2, Extra Objects are extensions since 4.2; LoopTools Circle, Flatten and
Space are built in since 5.2.
- Authoritative list: `sources/blender-version-deltas.md` and scenario-blender-expert
`references/blender-5.2-deltas.md`.
scripts/bx_hardsurface.py
"""
bx_hardsurface: hard-surface building blocks and shading QA for an agent with no mouse
(stock Blender 5.2.1 LTS, headless-safe, no add-ons).
Tested by tests/code/blender-hard-surface/test_*.py (run with
`blender --background --factory-startup --python-exit-code 1 --python <test>`).
import sys; sys.path.append("<skills>/scenario-blender-hard-surface/scripts"); import bx_hardsurface as HS
All sizes are in scene units (metres) and in WORLD space. Apply scale first (apply_scale):
bevel, solidify and array read object-local distances, so unapplied scale skews them.
SETUP
apply_scale(ob, rotation=False) data-level Ctrl+A that keeps children in place
find_edges(ob, min_angle=..., inside=(lo, hi), parallel=axis) edge indices, no mouse
set_edge_attr(ob, edges, value, attr) bevel_weight_edge (design bevel) / crease_edge
support_loops(ob, edges, offset) destructive holding edges (even segments, profile 1.0)
support_cut(ob, point, normal, avoid) plane cut through a boolean region (du Mont's fix;
measure before/after, it can make things worse)
nonplanar_faces(ob) / flatten_faces(ob) find / project non-planar faces onto their plane
CUTTERS AND BOOLEANS (School A: n-gons + booleans + bevel + custom normals)
make_cutter(target, kind, size, location, rotation_deg, ...) BOX (optional chamfer),
CYLINDER (half-segment phase), SLOT (stadium)
as_cutter(ob, target) apply the cutter checklist to an existing object
add_boolean(target, cutter, operation, solver) lands above the micro bevel / normals tail
slice_boolean(target, cutter) Slice = Difference on target + Intersect on a copy
cutters_of(ob) / hide_cutters(ob) list / view-layer hide (booleans keep evaluating)
STACKS
hard_surface_stack(ob, micro_width, ...) [Mirror] > Bevel_design(WEIGHT) > (booleans) >
Bevel_micro(ANGLE, harden, arc) > [Smooth by
Angle] > WeightedNormal(keep sharp), pinned
subd_stack(ob, support_width, levels) School B: [Bevel WEIGHT even seg profile 1.0] > Subsurf
order_stack(ob, pin=True) canonical order; unpin, reorder, re-pin bottom-up
stack_problems(ob) order and settings errors as readable strings
transfer_normals(ob) Data Transfer custom normals from the uncut curved
surface (cuts on curved skins), after WN
mirror_seam_fix(ob) Face Strength Affected + WN Face Influence 100
QA
shading_audit(ob) -> verdict(report) one dict / a list of readable problems (empty = clean):
stack, scale, custom normals, non-planar faces,
flat-face normal smear, smoothed hard edges, folds,
flat-shaded faces, sharp edges on smooth surfaces,
cuts on curvature, clamp probe + culprits, slivers,
boolean effect, cutter hygiene, subD cage metrics
cutter_report(cutter, target) scale, smooth, display, collection, manifold,
coplanar / near-parallel faces, thin walls, narrow gaps,
near-miss verts, rounding facets the bevel will catch,
pokes out
boolean_report(ob, try_solvers=False) per boolean: changed, emptied, operand manifold, solvers
clamp_probe(ob) / clamp_culprits(ob) displacement clamp on vs off / booleans that cause it
sliver_report(ob) sub-bevel-width edges and faces each boolean adds
cuts_on_curvature(ob) cutters that end on a curved surface
subd_cage_report(ob) quads %, quad aspect, long rectangles, crease length
REVIEW
review(objs, out_dir, views, modes) bx_review contact sheet of the EVALUATED mesh (cutters
never show, wire = real boolean topology); extra modes
shiny, reflect, hsgrey, fullmetal
closeup(ob, point, radius, out_path, direction, mode) one framed close-up of a cut
GAME
tri_count(ob) evaluated triangles
game_version(ob, micro_segments=1) copy (in place) with fewer segments + Triangulate
(keep custom normals); shares cutters, do not move it
collapse(ob, name) new object from the evaluated mesh (keeps custom normals)
"""
__version__ = "0.1" # Blender Expert Skills v0.1 (2026-09-24)
import math
import os
import sys
import bmesh
import bpy
import numpy as np
from mathutils import Euler, Matrix, Vector
from mathutils.bvhtree import BVHTree
_HERE = os.path.dirname(os.path.abspath(__file__))
_EXPERT = os.path.normpath(os.path.join(_HERE, "..", "..", "scenario-blender-expert", "scripts"))
if _EXPERT not in sys.path:
sys.path.append(_EXPERT)
CUTTERS = "Cutters"
HELPERS = "HS_Helpers"
TAIL_RANK = {"micro_bevel": 0, "smooth_by_angle": 1, "weighted_normal": 2,
"normal_transfer": 3, "triangulate": 4}
# --------------------------------------------------------------------------------------
# small helpers
# --------------------------------------------------------------------------------------
def _size(ob):
return max(ob.dimensions) or 1.0
def _link_only(ob, col):
for c in list(ob.users_collection):
if c != col:
c.objects.unlink(ob)
if ob.name not in col.objects:
col.objects.link(ob)
def _collection(name, scene=None):
sc = scene or bpy.context.scene
col = bpy.data.collections.get(name) or bpy.data.collections.new(name)
if col.name not in sc.collection.children and col != sc.collection:
sc.collection.children.link(col)
return col
def _parent_keep(child, parent):
mw = child.matrix_world.copy()
child.parent = parent
child.matrix_parent_inverse = parent.matrix_world.inverted()
child.matrix_world = mw
def _eval_copy(ob):
"""Independent mesh datablock of the evaluated object (caller removes it)."""
dg = bpy.context.evaluated_depsgraph_get()
return bpy.data.meshes.new_from_object(ob.evaluated_get(dg), preserve_all_data_layers=True,
depsgraph=dg)
def _free(me):
if me is not None and me.users == 0:
bpy.data.meshes.remove(me)
def _coords(me):
a = np.empty(len(me.vertices) * 3, dtype=np.float64)
me.vertices.foreach_get("co", a)
return a.reshape(-1, 3)
def _bm(me, matrix=None):
bm = bmesh.new()
bm.from_mesh(me)
if matrix is not None:
bm.transform(matrix)
bm.verts.ensure_lookup_table()
bm.edges.ensure_lookup_table()
bm.faces.ensure_lookup_table()
bm.normal_update()
return bm
def _thickness(f):
"""Area over longest edge: the width of a strip, the side of a square."""
longest = max(e.calc_length() for e in f.edges)
return f.calc_area() / longest if longest > 0 else 0.0
def _dihedral(e):
if len(e.link_faces) != 2:
return None
return math.degrees(e.calc_face_angle(0.0))
def role(m):
"""Role of a modifier in a hard-surface stack."""
t = m.type
if t == "BOOLEAN":
return "boolean"
if t == "BEVEL":
return "micro_bevel" if (m.limit_method == "ANGLE" or m.harden_normals) else "design_bevel"
if t == "WEIGHTED_NORMAL":
return "weighted_normal"
if t == "NODES" and m.node_group and m.node_group.name.startswith("Smooth by Angle"):
return "smooth_by_angle"
if t == "DATA_TRANSFER" and m.use_loop_data and "CUSTOM_NORMAL" in m.data_types_loops:
return "normal_transfer"
if t == "TRIANGULATE":
return "triangulate"
if t == "MIRROR":
return "mirror"
if t == "SUBSURF":
return "subsurf"
return "other"
def _micro(ob):
return [m for m in ob.modifiers if m.type == "BEVEL" and role(m) == "micro_bevel"]
# --------------------------------------------------------------------------------------
# setup
# --------------------------------------------------------------------------------------
def apply_scale(ob, rotation=False):
"""Data-level Apply Scale (and optionally Rotation). Makes a shared mesh single-user,
flips winding for negative scale (as Blender 5.1+ does) and keeps children in place."""
if ob.type != "MESH":
raise TypeError(f"{ob.name} is not a mesh")
if ob.data.users > 1:
ob.data = ob.data.copy()
kids = [(c, c.matrix_world.copy()) for c in ob.children]
loc, rot, sca = ob.matrix_basis.decompose()
m = Matrix.Diagonal(sca.to_4d())
if rotation:
m = rot.to_matrix().to_4x4() @ m
ob.data.transform(m)
if sca.x * sca.y * sca.z < 0:
bm = _bm(ob.data)
bmesh.ops.reverse_faces(bm, faces=bm.faces)
bm.to_mesh(ob.data)
bm.free()
keep_rot = Matrix.Identity(4) if rotation else rot.to_matrix().to_4x4()
ob.matrix_basis = Matrix.Translation(loc) @ keep_rot
ob.data.update()
bpy.context.view_layer.update()
for c, mw in kids: # re-parent with keep-transform: the old parent inverse held the scale
c.matrix_parent_inverse = ob.matrix_world.inverted()
c.matrix_world = mw
bpy.context.view_layer.update()
return ob
def find_edges(ob, min_angle=None, max_angle=None, inside=None, parallel=None, tol_deg=2.0,
boundary_only=False):
"""Indices of base-mesh edges matching all given filters.
min_angle/max_angle: dihedral in degrees (90 for box edges); inside: (lo, hi) world box,
both vertices inside; parallel: 'X'/'Y'/'Z' or a vector, within tol_deg."""
bpy.context.view_layer.update()
bm = _bm(ob.data, ob.matrix_world)
axis = None
if parallel is not None:
axis = Vector({"X": (1, 0, 0), "Y": (0, 1, 0), "Z": (0, 0, 1)}[parallel]
if isinstance(parallel, str) else parallel).normalized()
out = []
for e in bm.edges:
if boundary_only and not e.is_boundary:
continue
a = _dihedral(e)
if min_angle is not None and (a is None or a < min_angle):
continue
if max_angle is not None and (a is None or a > max_angle):
continue
if inside is not None:
lo, hi = Vector(inside[0]), Vector(inside[1])
if not all(all(lo[i] <= v.co[i] <= hi[i] for i in range(3)) for v in e.verts):
continue
if axis is not None:
d = (e.verts[1].co - e.verts[0].co)
if d.length == 0 or math.degrees(d.normalized().angle(axis)) > tol_deg and \
math.degrees(d.normalized().angle(-axis)) > tol_deg:
continue
out.append(e.index)
bm.free()
return out
def set_edge_attr(ob, edges, value=1.0, attr="bevel_weight_edge"):
"""Write a float edge attribute (bevel_weight_edge for Limit Method Weight, crease_edge
for Subdivision creases). Returns the number of edges written."""
me = ob.data
a = me.attributes.get(attr) or me.attributes.new(attr, "FLOAT", "EDGE")
for i in edges:
a.data[i].value = value
me.update()
return len(edges)
def support_loops(ob, edges, offset, segments=2):
"""Destructive holding edges for School B (Lampel): bevel the edges with an even segment
count and profile 1.0 so the original edge stays where it was."""
if segments % 2:
raise ValueError("support loops need an even segment count (odd loses the original edge)")
bm = _bm(ob.data)
geom = [bm.edges[i] for i in edges]
bmesh.ops.bevel(bm, geom=geom, offset=offset, segments=segments, profile=1.0,
affect="EDGES", clamp_overlap=True)
bm.to_mesh(ob.data)
bm.free()
ob.data.update()
return len(ob.data.edges)
def support_cut(ob, point, normal, avoid=None):
"""du Mont's bevel-shrink fix: add an edge loop on the TARGET through the middle of a
boolean region (plane through `point` with `normal`, world space). avoid=<cutter>: shift
the plane to lie midway between two cutter vertices, because a plane through cutter
vertices creates the very short edges it is meant to prevent (verified in 5.2.1).
Measure with sliver_report / clamp_probe before and after; undo if it got worse.
Returns (edges added, plane offset applied)."""
n = Vector(normal).normalized()
shift = 0.0
if avoid is not None:
ds = sorted({round((avoid.matrix_world @ v.co - Vector(point)).dot(n), 7)
for v in avoid.data.vertices})
below = [d for d in ds if d <= 0]
above = [d for d in ds if d > 0]
if below and above:
lo, hi = below[-1], above[0]
if abs(lo) < 1e-6: # plane sits on a vertex row: move to the nearest gap middle
prev = below[-2] if len(below) > 1 else None
gaps = [(hi - lo, (lo + hi) / 2)] + ([(lo - prev, (lo + prev) / 2)] if prev is not None else [])
shift = min(gaps, key=lambda g: abs(g[1]))[1]
else:
shift = (lo + hi) / 2
co = ob.matrix_world.inverted() @ (Vector(point) + n * shift)
no = (ob.matrix_world.to_3x3().transposed() @ n).normalized() # plane normal to local
bm = _bm(ob.data)
before = len(bm.edges)
geom = list(bm.verts) + list(bm.edges) + list(bm.faces)
bmesh.ops.bisect_plane(bm, geom=geom, dist=1e-6, plane_co=co, plane_no=no)
added = len(bm.edges) - before
bm.to_mesh(ob.data)
bm.free()
ob.data.update()
return added, shift
def nonplanar_faces(ob, tol=None):
"""(face index, deviation) for base faces with 4+ vertices off their plane by > tol
(default 1e-4 x object size). Deviation is in scene units."""
me = ob.data
tol = tol if tol is not None else 1e-4 * _size(ob)
s = ob.matrix_world.to_scale()
scale = max(abs(s.x), abs(s.y), abs(s.z))
out = []
for p in me.polygons:
if p.loop_total < 4:
continue
n, c = p.normal, p.center
dev = max(abs((me.vertices[v].co - c).dot(n)) for v in p.vertices) * scale
if dev > tol:
out.append((p.index, dev))
out.sort(key=lambda t: -t[1])
return out
def flatten_faces(ob, faces=None, iterations=5, tol=None):
"""Gambrell's `S <axis> 0`: project the vertices of non-planar faces onto the face plane
(repeated, since faces share vertices). Returns remaining max deviation."""
faces = faces if faces is not None else [i for i, _ in nonplanar_faces(ob, tol)]
for _ in range(iterations):
bm = _bm(ob.data)
for i in faces:
f = bm.faces[i]
n, c = f.normal.copy(), f.calc_center_median()
for v in f.verts:
v.co -= n * (v.co - c).dot(n)
bm.normal_update()
bm.to_mesh(ob.data)
bm.free()
ob.data.update()
left = nonplanar_faces(ob, tol)
return left[0][1] if left else 0.0
# --------------------------------------------------------------------------------------
# cutters and booleans
# --------------------------------------------------------------------------------------
def _prism(bm, outline, depth):
"""Closed prism from a 2D outline (counter-clockwise, in XY), centred on z = 0."""
bot = [bm.verts.new((x, y, -depth / 2)) for x, y in outline]
top = [bm.verts.new((x, y, depth / 2)) for x, y in outline]
bm.faces.new(list(reversed(bot)))
bm.faces.new(top)
n = len(outline)
for i in range(n):
j = (i + 1) % n
bm.faces.new((bot[i], bot[j], top[j], top[i]))
def make_cutter(target, kind="BOX", size=(0.1, 0.1, 0.1), location=(0, 0, 0),
rotation_deg=(0, 0, 0), name=None, chamfer=0.0, chamfer_segments=1,
chamfer_edges="VERTICAL", vertices=32, phase_deg=None, display="WIRE",
parent=True, operation="DIFFERENCE", solver="EXACT", add=True):
"""Build a cutter that follows the checklist (Gambrell, Ryuu, du Mont): built at size
(scale 1), smooth shaded, only in the Cutters collection, wire/bounds display, hidden
from render, parented to the target, and (add=True) wired as a Boolean on the target.
kind 'BOX': size = full dimensions (chamfer rounds its vertical or all edges);
'CYLINDER': size = (diameter x, diameter y, depth), axis = local Z, `vertices` fixed now
(you cannot change it later, Ryuu); 'SLOT': stadium / rounded slot, size = (length,
width, depth), `vertices` per half circle.
phase_deg rotates the ring about its axis: default half a segment, so its radial edges do
not land exactly on the edges of a target with the same segment count (same phase gave
10 non-manifold edges with EXACT in 5.2.1).
Make the cutter extend well past every surface it enters, and keep its edges far from
target edges (or exactly coincident): run cutter_report afterwards."""
name = name or f"Cutter_{kind.title()}"
me = bpy.data.meshes.new(name)
bm = bmesh.new()
if kind == "BOX":
bmesh.ops.create_cube(bm, size=1.0)
bmesh.ops.scale(bm, vec=Vector(size), verts=bm.verts)
elif kind == "CYLINDER":
ph = math.radians(phase_deg) if phase_deg is not None else math.pi / vertices
rx, ry = size[0] / 2, size[1] / 2
_prism(bm, [(rx * math.cos(ph + 2 * math.pi * i / vertices),
ry * math.sin(ph + 2 * math.pi * i / vertices)) for i in range(vertices)], size[2])
elif kind == "SLOT":
L, W, D = size
r = W / 2
k = max(2, vertices)
pts = [(L / 2 - r + r * math.cos(-math.pi / 2 + math.pi * i / k),
r * math.sin(-math.pi / 2 + math.pi * i / k)) for i in range(k + 1)]
pts += [(-L / 2 + r + r * math.cos(math.pi / 2 + math.pi * i / k),
r * math.sin(math.pi / 2 + math.pi * i / k)) for i in range(k + 1)]
_prism(bm, pts, D)
else:
raise ValueError("kind must be BOX, CYLINDER or SLOT (use as_cutter for custom shapes)")
if chamfer > 0:
if chamfer_edges == "VERTICAL":
edges = [e for e in bm.edges
if abs((e.verts[1].co - e.verts[0].co).normalized().z) > 0.99]
else:
edges = list(bm.edges)
bmesh.ops.bevel(bm, geom=edges, offset=chamfer, segments=chamfer_segments,
profile=0.5, affect="EDGES", clamp_overlap=True)
for f in bm.faces:
f.smooth = True
bm.to_mesh(me)
bm.free()
ob = bpy.data.objects.new(name, me)
ob.location = Vector(location)
ob.rotation_euler = Euler([math.radians(a) for a in rotation_deg])
_collection(CUTTERS).objects.link(ob)
ob.display_type = display
ob.hide_render = True
bpy.context.view_layer.update()
if parent and target is not None:
_parent_keep(ob, target)
if add and target is not None:
add_boolean(target, ob, operation=operation, solver=solver)
return ob
def as_cutter(ob, target=None, display="WIRE", parent=True):
"""Apply the cutter checklist to an existing mesh object."""
apply_scale(ob)
for p in ob.data.polygons:
p.use_smooth = True
_link_only(ob, _collection(CUTTERS))
ob.display_type = display
ob.hide_render = True
if parent and target is not None and ob.parent != target:
_parent_keep(ob, target)
return ob
def add_boolean(target, cutter, operation="DIFFERENCE", solver="EXACT", name=None):
"""Boolean modifier placed right after the last existing boolean (or at the end of the
head of the stack), always above the micro bevel / normals tail. Solver policy: EXACT
by default; MANIFOLD when both operands are manifold and speed matters; FLOAT only to
diagnose (it breaks on coplanar faces in 5.2.1)."""
m = target.modifiers.new(name or f"Bool_{cutter.name}", "BOOLEAN")
m.object = cutter
m.operation = operation
m.solver = solver
order_stack(target, pin=any(x.use_pin_to_last for x in target.modifiers))
bools = [i for i, x in enumerate(target.modifiers) if x.type == "BOOLEAN" and x != m]
if bools:
pinned = [x for x in target.modifiers if x.use_pin_to_last]
for x in pinned:
x.use_pin_to_last = False
cur = target.modifiers.find(m.name)
dest = bools[-1] + 1 if bools[-1] < cur else bools[-1]
if cur != dest:
target.modifiers.move(cur, dest)
for x in reversed(pinned):
x.use_pin_to_last = True
return m
def slice_boolean(target, cutter, solver="EXACT"):
"""Slice (Bool Tool's term) with stock modifiers: Difference on the target and a copy of
the target (single-user mesh, same stack) with Intersect. Returns the slice object."""
piece = target.copy()
piece.data = target.data.copy()
piece.name = target.name + "_slice"
for c in target.users_collection:
c.objects.link(piece)
add_boolean(target, cutter, "DIFFERENCE", solver)
for m in list(piece.modifiers):
if m.type == "BOOLEAN" and m.object == cutter:
piece.modifiers.remove(m)
add_boolean(piece, cutter, "INTERSECT", solver)
return piece
def cutters_of(ob):
return [m.object for m in ob.modifiers if m.type == "BOOLEAN" and m.object is not None]
def hide_cutters(ob, hidden=True):
"""Hide the cutters in the viewport (view-layer hide). Booleans still evaluate."""
for c in cutters_of(ob):
c.hide_set(hidden)
# --------------------------------------------------------------------------------------
# stacks
# --------------------------------------------------------------------------------------
def order_stack(ob, pin=True):
"""Canonical order: everything else keeps its relative order (mirror, design bevel,
booleans, solidify ...), then the tail: micro bevel(s) > Smooth by Angle >
Weighted Normal > normal Data Transfer > Triangulate. Pinning is done bottom-up because
5.2.1 keeps the relative order inside the pinned group (verified)."""
mods = list(ob.modifiers)
for m in mods:
m.use_pin_to_last = False
tail = [m for m in mods if role(m) in TAIL_RANK]
head = [m for m in mods if role(m) not in TAIL_RANK]
tail.sort(key=lambda m: (TAIL_RANK[role(m)], mods.index(m)))
for i, m in enumerate(head + tail):
cur = ob.modifiers.find(m.name)
if cur != i:
ob.modifiers.move(cur, i)
if pin:
for m in reversed(tail):
m.use_pin_to_last = True
return [m.name for m in ob.modifiers]
def hard_surface_stack(ob, micro_width=None, micro_segments=3, angle=30.0, harden=True,
weighted_normal=True, design_width=None, design_segments=12,
mirror_axes=None, mirror_object=None, smooth_by_angle=False,
face_strength=False, clamp=True, pin=True):
"""School A stack on a mesh with applied scale. All faces are set smooth (the bevel and
custom normals do the hard look). Defaults: micro bevel angle 30 degrees, 3 segments
(Gambrell's render default), Harden Normals, Miter Outer Arc, Weighted Normal Keep Sharp.
micro_width default = 0.5% of the largest dimension [added]; prefer a real-world value
(du Mont: about 3 mm on a small robot body, 1 mm on small parts, 0.1 mm on a collar).
design_width: adds a weight-limited design bevel first (edges from set_edge_attr)."""
s = ob.scale
if max(abs(s.x - 1), abs(s.y - 1), abs(s.z - 1)) > 1e-4:
apply_scale(ob)
for p in ob.data.polygons:
p.use_smooth = True
if "sharp_face" in ob.data.attributes:
ob.data.attributes.remove(ob.data.attributes["sharp_face"])
out = {}
if mirror_axes:
mi = ob.modifiers.new("Mirror", "MIRROR")
for i, ax in enumerate("XYZ"):
mi.use_axis[i] = ax in mirror_axes
mi.use_clip = True
mi.use_mirror_merge = True
if mirror_object is not None:
mi.mirror_object = mirror_object
out["mirror"] = mi
if design_width:
d = ob.modifiers.new("Bevel_design", "BEVEL")
d.limit_method = "WEIGHT"
d.width = design_width
d.segments = design_segments
d.miter_outer = "MITER_ARC"
d.use_clamp_overlap = clamp
out["design"] = d
mb = ob.modifiers.new("Bevel_micro", "BEVEL")
mb.limit_method = "ANGLE"
mb.angle_limit = math.radians(angle)
mb.width = micro_width if micro_width else 0.005 * _size(ob)
mb.segments = micro_segments
mb.harden_normals = harden
mb.miter_outer = "MITER_ARC"
mb.use_clamp_overlap = clamp
out["micro"] = mb
if smooth_by_angle:
with bpy.context.temp_override(object=ob, active_object=ob, selected_objects=[ob],
selected_editable_objects=[ob]):
bpy.ops.object.shade_auto_smooth(angle=math.radians(angle))
out["smooth_by_angle"] = next(m for m in ob.modifiers if role(m) == "smooth_by_angle")
if weighted_normal:
wn = ob.modifiers.new("WeightedNormal", "WEIGHTED_NORMAL")
wn.keep_sharp = True
out["weighted_normal"] = wn
if face_strength:
mirror_seam_fix(ob)
order_stack(ob, pin=pin)
return out
def subd_stack(ob, support_width=None, support_segments=2, levels=2, render_levels=None,
uv_smooth=None, weight_all_edges=False):
"""School B: optional Bevel (Limit Method Weight, even segments, profile 1.0) as editable
support loops before Subdivision Surface (Lampel). Without support_width the cage must
carry its own holding edges (support_loops) or creases. uv_smooth defaults to
PRESERVE_CORNERS (Keep Corners) when the bevel is present, SMOOTH_ALL otherwise."""
if support_segments % 2:
raise ValueError("bevel before subdivision needs even segments (Lampel)")
s = ob.scale
if max(abs(s.x - 1), abs(s.y - 1), abs(s.z - 1)) > 1e-4:
apply_scale(ob)
for p in ob.data.polygons:
p.use_smooth = True
out = {}
if support_width:
if weight_all_edges:
set_edge_attr(ob, range(len(ob.data.edges)), 1.0)
b = ob.modifiers.new("Support", "BEVEL")
b.limit_method = "WEIGHT"
b.width = support_width
b.segments = support_segments
b.profile = 1.0
out["support"] = b
ss = ob.modifiers.new("Subdivision", "SUBSURF")
ss.levels = levels
ss.render_levels = render_levels if render_levels is not None else levels
ss.uv_smooth = uv_smooth or ("PRESERVE_CORNERS" if support_width else "SMOOTH_ALL")
out["subsurf"] = ss
return out
def mirror_seam_fix(ob):
"""Gambrell's fix for the butterfly streak on a mirrored boolean mesh: bevel Face
Strength = Affected, Weighted Normal Face Influence on, weight 100."""
for m in _micro(ob):
m.face_strength_mode = "FSTR_AFFECTED"
wns = [m for m in ob.modifiers if m.type == "WEIGHTED_NORMAL"]
if not wns:
wns = [ob.modifiers.new("WeightedNormal", "WEIGHTED_NORMAL")]
wns[0].keep_sharp = True
order_stack(ob, pin=True)
for w in wns:
w.use_face_influence = True
w.weight = 100
return wns[0]
def transfer_normals(ob, source=None, max_distance=None, mix=1.0, angle=30.0, weighting="AUTO"):
"""Gambrell's fix for cuts on a curved surface: Data Transfer of custom normals from a
clean, uncut copy of the curved surface, placed after Weighted Normal (before it, WN
overwrites it; verified).
source=None builds it from the target's pre-boolean state (mirror and design bevel
applied, no cuts), keeping only curved faces (minus any ring that meets a flat face
smoothly), smooth; hidden, parented. weighting: 'ANGLE' = plain smooth normals (domes,
doubly curved skins), 'AREA' = a Weighted Normal on the source so big faces win (long
extrusions meeting a design-bevel round, where plain smoothing tilts the whole face),
'AUTO' = AREA when neighbouring curved faces differ in area by more than 4x.
max_distance (default 1e-4 x size) limits the transfer to corners lying ON that surface,
so cut walls and flat faces keep their own normals [added].
Verified in 5.2.1 (tests/code/blender-hard-surface/test_02_shading_audit.py), normal
error vs the true surface, p95: 32x16 sphere with a port 2.79 -> 0.34 deg; 24-segment
cylinder with a design bevel 3.26 -> 0.07; 48-segment 0.61 -> 0.07; no new smear on the
caps. Harden + WN alone already rendered acceptably on the cylinders: render first.
Scopes that failed: a vertex group (EXACT gives cutter-made faces the target's weights,
the cut walls took the cylinder normals), a patch cropped around the cut (its border
normals made a seam). Where a curved region meets a flat face through a smooth design
bevel, corners on that border can take the border normal: re-run shading_audit (smear).
The object must not move away from its source. Returns the modifier or None."""
if source is None:
me = _state_before(ob, None)
me.name = ob.name + "_NormalSrc"
bm = _bm(me)
thick = {f: _thickness(f) for f in bm.faces}
flat = []
for f in bm.faces:
curved = False
for e in f.edges:
a = _dihedral(e)
if a is not None and 1.0 < a <= angle:
other = next(x for x in e.link_faces if x != f)
if thick[other] > 0.3 * thick[f]:
curved = True
break
if not curved:
flat.append(f)
flat_set = set(flat)
# also drop curved faces that meet a flat face smoothly (a design-bevel ring next to a
# cap): otherwise the flat face's border corners sit ON the source and take its normal
ring = {f for f in bm.faces if f not in flat_set and any(
(_dihedral(e) or 180.0) <= angle and any(x in flat_set for x in e.link_faces) for e in f.edges)}
bmesh.ops.delete(bm, geom=list(flat_set | ring), context="FACES")
for f in bm.faces:
f.smooth = True
ratio = 1.0
for e in bm.edges:
a = _dihedral(e)
e.smooth = a is None or a <= angle
if a is not None and a <= angle:
a1, a2 = (f.calc_area() for f in e.link_faces)
ratio = max(ratio, max(a1, a2) / max(min(a1, a2), 1e-12))
bm.to_mesh(me)
bm.free()
if weighting == "AUTO":
weighting = "AREA" if ratio > 4.0 else "ANGLE"
if not len(me.polygons):
bpy.data.meshes.remove(me)
return None
source = bpy.data.objects.new(me.name, me)
_collection(HELPERS).objects.link(source)
source.matrix_world = ob.matrix_world.copy()
source.hide_render = True
source.display_type = "WIRE"
bpy.context.view_layer.update()
_parent_keep(source, ob)
if weighting == "AREA": # big faces win: no gradient down a long face that
wn = source.modifiers.new("WeightedNormal", "WEIGHTED_NORMAL") # meets a round
wn.keep_sharp = True
source.hide_set(True)
dt = ob.modifiers.new("HS_NormalTransfer", "DATA_TRANSFER")
dt.object = source
dt.use_object_transform = True
dt.use_loop_data = True
dt.data_types_loops = {"CUSTOM_NORMAL"}
dt.loop_mapping = "POLYINTERP_LNORPROJ"
dt.mix_factor = mix
dt.use_max_distance = True
dt.max_distance = max_distance if max_distance is not None else 1e-4 * _size(ob)
order_stack(ob, pin=any(x.use_pin_to_last for x in ob.modifiers))
return dt
def stack_problems(ob):
"""Order and settings errors in plain words (empty list = stack is sane)."""
probs = []
mods = [m for m in ob.modifiers if m.show_viewport]
roles = [role(m) for m in mods]
s = ob.scale
if any(m.type in {"BEVEL", "SOLIDIFY", "ARRAY"} for m in mods) and \
max(abs(s.x - 1), abs(s.y - 1), abs(s.z - 1)) > 1e-4:
probs.append(f"scale not applied {tuple(round(x, 3) for x in s)}: bevel/solidify/array skew")
micro_idx = [i for i, r in enumerate(roles) if r == "micro_bevel"]
for i, r in enumerate(roles):
if r == "boolean" and micro_idx and i > min(micro_idx):
probs.append(f"{mods[i].name} is below {mods[min(micro_idx)].name}: its cut edges are not beveled")
if r == "boolean" and mods[i].solver == "FLOAT":
probs.append(f"{mods[i].name} uses the FLOAT solver: breaks on coplanar faces; use EXACT")
if r == "boolean" and mods[i].object is None and mods[i].operand_type == "OBJECT":
probs.append(f"{mods[i].name} has no cutter object")
if "weighted_normal" in roles:
wi = max(i for i, r in enumerate(roles) if r == "weighted_normal")
after = [roles[j] for j in range(wi + 1, len(roles))]
bad = [mods[wi + 1 + k].name for k, r in enumerate(after)
if r not in {"triangulate", "normal_transfer"}]
if bad:
probs.append(f"Weighted Normal is not last: {bad} evaluate after it")
if "triangulate" in roles and "weighted_normal" in roles:
ti = roles.index("triangulate")
if ti < roles.index("weighted_normal"):
probs.append("Triangulate above Weighted Normal: compare both orders in a matcap")
if not mods[ti].keep_custom_normals:
probs.append("Triangulate without keep_custom_normals drops the custom normals")
for i, m in enumerate(mods):
if m.type == "BEVEL":
if abs(m.width - 0.1) < 1e-6:
probs.append(f"{m.name} width left at the 0.1 m default")
nxt = roles[i + 1:]
if "subsurf" in nxt and role(m) == "design_bevel":
if m.segments % 2:
probs.append(f"{m.name} before Subdivision has odd segments ({m.segments}): original edge lost, UV stretch")
if abs(m.profile - 1.0) > 1e-3:
probs.append(f"{m.name} before Subdivision: profile {m.profile:.2f}, use 1.0 to keep the edge")
ss = mods[i + 1 + nxt.index("subsurf")]
if ss.uv_smooth != "PRESERVE_CORNERS":
probs.append(f"{ss.name} uv_smooth {ss.uv_smooth}: Keep Corners when a bevel precedes it")
if m.type == "MIRROR" and "weighted_normal" in roles and i > roles.index("weighted_normal"):
probs.append(f"{m.name} is below Weighted Normal: move it above")
return probs
# --------------------------------------------------------------------------------------
# QA
# --------------------------------------------------------------------------------------
def clamp_probe(ob, bevel=None):
"""Evaluate with Clamp Overlap on and off. A displacement above ~10% of the bevel width
[added threshold] means clamping is biting: a geometry-proximity defect (du Mont)."""
bevel = bevel or (_micro(ob) or [None])[0]
if bevel is None:
return None
old = bevel.use_clamp_overlap
try:
bevel.use_clamp_overlap = True
a = _eval_copy(ob)
bevel.use_clamp_overlap = False
b = _eval_copy(ob)
finally:
bevel.use_clamp_overlap = old
try:
if len(a.vertices) != len(b.vertices):
return {"bevel": bevel.name, "topology_changed": True,
"verts_clamped": len(a.vertices), "verts_unclamped": len(b.vertices),
"ratio": None}
if not len(a.vertices):
return {"bevel": bevel.name, "max_disp": 0.0, "ratio": 0.0}
d = np.linalg.norm(_coords(a) - _coords(b), axis=1)
s = max(ob.matrix_world.to_scale())
return {"bevel": bevel.name, "topology_changed": False,
"max_disp": round(float(d.max()) * s, 6),
"ratio": round(float(d.max()) * s / bevel.width, 3) if bevel.width else None,
"worst_at": [round(x, 4) for x in ob.matrix_world @ Vector(_coords(a)[int(d.argmax())])]}
finally:
_free(a)
_free(b)
def clamp_culprits(ob, bevel=None, threshold=0.1):
"""Clamp Overlap limits the bevel over the WHOLE object (one bad cut shrinks every bevel,
verified), so the displacement peak is not where the cause is. Disable each boolean in
turn: the ones whose removal brings the clamp ratio under `threshold` are the cause."""
bevel = bevel or (_micro(ob) or [None])[0]
out = []
for m in [m for m in ob.modifiers if m.type == "BOOLEAN" and m.show_viewport]:
m.show_viewport = False
try:
c = clamp_probe(ob, bevel)
finally:
m.show_viewport = True
ratio = 0.0 if c is None else (99.0 if c.get("topology_changed") else (c.get("ratio") or 0.0))
if ratio < threshold:
out.append(m.name)
return out
def _toggle_eval(ob, off):
"""Evaluated copy with the modifiers in `off` disabled."""
saved = [(m, m.show_viewport) for m in off]
try:
for m in off:
m.show_viewport = False
return _eval_copy(ob)
finally:
for m, v in saved:
m.show_viewport = v
def _short_stats(me, mw, length_tol, thick_tol, angle=None):
"""Short edges / thin faces. With `angle`, only unbeveled edges (and faces) touching an
edge the angle-limited bevel will bevel: the bevel offset slides along those neighbours,
so one shorter than the width limits it. Short beveled edges are left to clamp_probe."""
bm = _bm(me, mw)
if angle is not None:
hard_e = {e for e in bm.edges if (_dihedral(e) or 0.0) >= angle}
hard = {v for e in hard_e for v in e.verts}
near_hard = lambda vs: any(v in hard for v in vs)
else:
hard_e = set()
near_hard = lambda vs: True
short = [e for e in bm.edges
if e.calc_length() < length_tol and e not in hard_e and near_hard(e.verts)]
thin = [f for f in bm.faces if _thickness(f) < thick_tol and near_hard(f.verts)]
pts = [(e.verts[0].co + e.verts[1].co) / 2 for e in short] + [f.calc_center_median() for f in thin]
r = (len(short), len(thin), [p.copy() for p in pts])
bm.free()
return r
def _near_cutter(pts, cutter, pad):
lo, hi = _bbox_world(cutter)
lo = lo - Vector((pad, pad, pad))
hi = hi + Vector((pad, pad, pad))
return sum(1 for p in pts if all(lo[i] <= p[i] <= hi[i] for i in range(3)))
def _bbox_world(ob):
pts = [ob.matrix_world @ Vector(c) for c in ob.bound_box]
lo = Vector([min(p[i] for p in pts) for i in range(3)])
hi = Vector([max(p[i] for p in pts) for i in range(3)])
return lo, hi
def sliver_report(ob):
"""Thin polygons and short edges created by the booleans, where they hurt the bevel.
pre_bevel: boolean result BEFORE the micro bevel, edges shorter than the micro width and
faces thinner than it (the bevel cannot fit: clamping or overlap). post_bevel: final mesh,
edges under half the width (the digest's detector, 12 vs 304 on the verified case).
Each count is compared with the same stack with that boolean disabled."""
micro = _micro(ob)
bools = [m for m in ob.modifiers if m.type == "BOOLEAN" and m.show_viewport]
if not micro or not bools:
return {"skipped": "needs a micro bevel and at least one boolean"}
w = micro[0].width
ang = math.degrees(micro[0].angle_limit) if micro[0].limit_method == "ANGLE" else 30.0
first = list(ob.modifiers).index(micro[0])
tail = [m for m in list(ob.modifiers)[first:] if m.show_viewport]
mw = ob.matrix_world
out = {"micro_width": w, "per_boolean": []}
pre_all = _toggle_eval(ob, tail)
post_all = _eval_copy(ob)
try:
pa = _short_stats(pre_all, mw, w, w, ang)
qa = _short_stats(post_all, mw, 0.5 * w, 0.25 * w)
out["pre_bevel_short_edges"], out["pre_bevel_thin_faces"] = pa[0], pa[1]
out["post_bevel_short_edges"], out["post_bevel_thin_faces"] = qa[0], qa[1]
for b in bools:
pre_wo = _toggle_eval(ob, tail + [b])
post_wo = _toggle_eval(ob, [b])
try:
pb = _short_stats(pre_wo, mw, w, w, ang)
qb = _short_stats(post_wo, mw, 0.5 * w, 0.25 * w)
finally:
_free(pre_wo)
_free(post_wo)
c = b.object
entry = {"boolean": b.name, "cutter": c.name if c else None,
"pre_short_added": pa[0] - pb[0], "pre_thin_added": pa[1] - pb[1],
"post_short_added": qa[0] - qb[0], "post_thin_added": qa[1] - qb[1]}
if c is not None:
lo, hi = _bbox_world(c)
pad = Vector((2 * w,) * 3)
near = [p for p in pa[2] if all(lo[i] - pad[i] <= p[i] <= hi[i] + pad[i] for i in range(3))]
entry["pre_defects_near_cutter"] = len(near)
entry["pre_defect_sample"] = [[round(x, 4) for x in p] for p in near[:4]]
out["per_boolean"].append(entry)
finally:
_free(pre_all)
_free(post_all)
return out
def _state_before(target, modifier=None):
"""Evaluated target as it is just before `modifier` (that modifier and everything after it
disabled). modifier=None: all booleans and the micro-bevel/normals tail disabled."""
mods = list(target.modifiers)
if modifier is not None and modifier in mods:
off = mods[mods.index(modifier):]
else:
off = [m for m in mods if m.type == "BOOLEAN" or role(m) in TAIL_RANK]
return _toggle_eval(target, off)
def _inside(tree, p):
loc, no, _, _ = tree.find_nearest(p)
return loc is not None and (p - loc).dot(no) < 0
def _face_samples(bm, k=4):
"""Points strictly inside every face (fan triangles, k(k+1)/2 barycentric points each)."""
out = []
for f in bm.faces:
vs = [v.co for v in f.verts]
for i in range(1, len(vs) - 1):
a, b, c = vs[0], vs[i], vs[i + 1]
for u in range(k):
for w in range(k - u):
p = a + (b - a) * ((u + 1 / 3) / k) + (c - a) * ((w + 1 / 3) / k)
out.append((p, f.normal, f.index))
return out
def cutter_report(cutter, target, near=None, eps=1e-4):
"""The cutter checklist, measured. near = band in which a surface counts as too close:
default 2 x micro bevel width (a wall, step or gap thinner than that makes the two bevels
on it overlap [added rule]), else 2% of target size.
coplanar_faces: cutter faces lying on a target face (Z-fighting; FLOAT breaks, EXACT ok).
near_parallel_faces: cutter faces parallel to a target face within the band (thin steps).
thin_wall: for DIFFERENCE, material left between the cut and a roughly parallel target
surface (within 45 degrees) thinner than the band (ray along the cutter normal).
narrow_gap: for DIFFERENCE, the cut itself narrower than the band inside the target
(groove of 2 mm with 1.5 mm bevels clamped 0.47; 3.1 mm was clean, verified).
near_miss_verts: cutter vertices within the band of the target surface but not on it."""
mb = _micro(target)
near = near or (2 * mb[0].width if mb else 0.02 * _size(target))
op = next((m.operation for m in target.modifiers
if m.type == "BOOLEAN" and m.object == cutter), "DIFFERENCE")
r = {"cutter": cutter.name, "operation": op}
ps = cutter.matrix_basis.to_scale()
r["scale_applied"] = max(abs(ps.x - 1), abs(ps.y - 1), abs(ps.z - 1)) < 1e-4
r["smooth"] = all(p.use_smooth for p in cutter.data.polygons)
r["display"] = cutter.display_type
r["hide_render"] = cutter.hide_render
r["collections"] = [c.name for c in cutter.users_collection]
r["only_in_cutters"] = r["collections"] == [CUTTERS]
r["parented_to_target"] = cutter.parent == target
cme = _eval_copy(cutter)
cbm = _bm(cme, cutter.matrix_world)
_free(cme)
r["non_manifold_edges"] = sum(1 for e in cbm.edges if not e.is_manifold)
if mb and mb[0].limit_method == "ANGLE":
lim = math.degrees(mb[0].angle_limit)
# rounding facets whose edges reach the bevel's angle limit get beveled; on facets
# narrower than 2 x width those bevels overlap and clamp the WHOLE bevel (verified)
r["beveled_narrow_facet_edges"] = sum(
1 for e in cbm.edges if len(e.link_faces) == 2 and (_dihedral(e) or 0) >= lim - 0.5
and max(_thickness(f) for f in e.link_faces) < 2 * mb[0].width)
bmod = next((m for m in target.modifiers if m.type == "BOOLEAN" and m.object == cutter), None)
ref = _state_before(target, bmod)
tbm = _bm(ref, target.matrix_world)
_free(ref)
tree = BVHTree.FromBMesh(tbm)
tbm.free()
ctree = BVHTree.FromBMesh(cbm)
cop, nearf, thin, gap = set(), set(), set(), set()
wall_min, wall_at, gap_min = None, None, None
for p, n, fi in _face_samples(cbm):
loc, tn, ti, d = tree.find_nearest(p)
if loc is None:
continue
parallel = abs(abs(tn.dot(n)) - 1) < 1e-3
if parallel and d < eps:
cop.add(fi)
elif parallel and d < near:
nearf.add(fi)
if op == "DIFFERENCE" and (p - loc).dot(tn) < -eps: # sample inside the target
hit = tree.ray_cast(p + n * 1e-6, n, near)
# a slab, not the knife edge every cut wall makes where it meets a curved skin
if hit[0] is not None and hit[3] > eps and abs(hit[1].dot(n)) > 0.7:
thin.add(fi)
if wall_min is None or hit[3] < wall_min:
wall_min, wall_at = hit[3], p.copy()
# the cut itself too narrow: the cutter's opposite face closer than the band
own = ctree.ray_cast(p - n * 1e-6, -n, near)
if own[0] is not None and own[3] > eps and own[1].dot(n) < -0.7:
gap.add(fi)
gap_min = own[3] if gap_min is None else min(gap_min, own[3])
r["coplanar_faces"] = len(cop)
r["near_parallel_faces"] = len(nearf)
r["thin_wall_faces"] = len(thin)
r["thin_wall_min"] = round(wall_min, 5) if wall_min is not None else None
r["thin_wall_at"] = [round(x, 4) for x in wall_at] if wall_at is not None else None
r["narrow_gap_faces"] = len(gap)
r["narrow_gap_min"] = round(gap_min, 5) if gap_min is not None else None
dists, inside = [], 0
for v in cbm.verts:
loc, no, idx, d = tree.find_nearest(v.co)
if loc is None:
continue
dists.append(d)
if (v.co - loc).dot(no) < 0:
inside += 1
r["verts_inside_target"] = inside
# a long bar can have every vertex buried and still cross the surface: test samples too
r["pokes_out"] = inside < len(cbm.verts) or any(
not _inside(tree, p) for p, _, _ in _face_samples(cbm, 3))
r["near_miss_verts"] = sum(1 for d in dists if eps < d < near)
r["near_band"] = round(near, 6)
cbm.free()
return r
def boolean_report(ob, try_solvers=False):
"""Per boolean: does it change the mesh, did it empty it (Exact drops the whole result on
a broken operand, Ryuu), operand manifold. try_solvers: evaluate EXACT/MANIFOLD/FLOAT
and report vertex and non-manifold counts (diagnose, then repair the operand)."""
out = []
full = _eval_copy(ob)
n_full = len(full.vertices)
_free(full)
for m in [m for m in ob.modifiers if m.type == "BOOLEAN"]:
e = {"boolean": m.name, "solver": m.solver, "operation": m.operation,
"enabled": m.show_viewport}
wo = _toggle_eval(ob, [m])
n_wo = len(wo.vertices)
e["changes_mesh"] = n_full != n_wo
e["result_empty"] = n_full == 0 and n_wo > 0
_free(wo)
if m.object is not None:
cme = _eval_copy(m.object)
cbm = _bm(cme)
e["operand_non_manifold"] = sum(1 for x in cbm.edges if not x.is_manifold)
cbm.free()
_free(cme)
if try_solvers:
old = m.solver
e["solvers"] = {}
for s in ("EXACT", "MANIFOLD", "FLOAT"):
m.solver = s
me = _eval_copy(ob)
bm = _bm(me)
e["solvers"][s] = {"verts": len(me.vertices), "cut_applied": len(me.vertices) != n_wo,
"non_manifold": sum(1 for x in bm.edges if not x.is_manifold)}
bm.free()
_free(me)
m.solver = old
out.append(e)
return out
def _curved_faces_pre(ob, angle):
"""Faces of the pre-boolean evaluated mesh (mirror, design bevel, solidify applied) that
sit on a curved region: a neighbour at 1 to `angle` degrees that is not a thin strip."""
me = _state_before(ob, None)
bm = _bm(me, ob.matrix_world)
_free(me)
curved = set()
for f in bm.faces:
t = _thickness(f)
for e in f.edges:
a = _dihedral(e)
if a is None or not (1.0 < a <= angle):
continue
other = next(x for x in e.link_faces if x != f)
if _thickness(other) > 0.3 * t:
curved.add(f.index)
break
return bm, curved
def cuts_on_curvature(ob, angle=30.0):
"""Cutters whose volume meets curved base faces: the cut will end on a curved surface and
its beveled n-gons cannot shade cleanly (Gambrell). Fix: end the cut on a flat area, or
transfer_normals()."""
bm, curved = _curved_faces_pre(ob, angle)
out = []
if curved:
ttree = BVHTree.FromBMesh(bm)
for c in cutters_of(ob):
cme = _eval_copy(c)
cbm = _bm(cme, c.matrix_world)
_free(cme)
ctree = BVHTree.FromBMesh(cbm)
hits = {a for a, b in ttree.overlap(ctree)} & curved
for i in curved - hits: # faces fully inside the cutter
if _inside(ctree, bm.faces[i].calc_center_median()):
hits.add(i)
cbm.free()
if hits:
out.append({"cutter": c.name, "curved_faces_hit": len(hits)})
bm.free()
return out
def _normal_checks(ob, me, panel_min, flat_tol, hard_deg, curve_max, fold_area=0.0):
"""Flat-face smear and smoothed hard edges on the evaluated mesh (world space)."""
mw = ob.matrix_world
nl = len(me.loops)
cn = np.empty(nl * 3)
me.corner_normals.foreach_get("vector", cn)
cn = cn.reshape(-1, 3)
rot = np.array(mw.to_3x3().inverted().transposed())
cn = cn @ rot.T
cn /= np.maximum(np.linalg.norm(cn, axis=1, keepdims=True), 1e-12)
bm = _bm(me, mw)
loop_start = [p.loop_start for p in me.polygons]
thick = [_thickness(f) for f in bm.faces]
panel_area = bad_area = 0.0
bad, worst = [], 0.0
ngon_curved = 0
for f in bm.faces:
t = thick[f.index]
curved = False
for e in f.edges:
a = _dihedral(e)
if a is not None and 1.0 < a <= curve_max:
other = next(x for x in e.link_faces if x != f)
if thick[other.index] > 0.3 * t:
curved = True
break
if curved:
if len(f.verts) > 4:
ngon_curved += 1
continue
if t < panel_min:
continue
n = np.array(f.normal)
ls = loop_start[f.index]
dots = np.clip(cn[ls:ls + len(f.loops)] @ n, -1, 1)
dev = float(np.degrees(np.arccos(dots.min())))
area = f.calc_area()
panel_area += area
if dev > flat_tol:
bad_area += area
bad.append((dev, f.calc_center_median().copy()))
worst = max(worst, dev)
smoothed_hard, folds, tiny_folds = [], [], 0
for e in bm.edges:
a = _dihedral(e)
if a is None or a < hard_deg:
continue
f1, f2 = e.link_faces
if a > 150.0: # folded-back faces: degenerate, not a hard edge
if min(f1.calc_area(), f2.calc_area()) > fold_area:
folds.append((e.verts[0].co + e.verts[1].co) / 2)
else:
tiny_folds += 1 # bevel-corner specks, invisible at viewing distance
continue
for v in e.verts:
l1 = next(l for l in f1.loops if l.vert == v)
l2 = next(l for l in f2.loops if l.vert == v)
i1 = loop_start[f1.index] + list(f1.loops).index(l1)
i2 = loop_start[f2.index] + list(f2.loops).index(l2)
if float(cn[i1] @ cn[i2]) > math.cos(math.radians(5.0)):
smoothed_hard.append((e.verts[0].co + e.verts[1].co) / 2)
break
bm.free()
bad.sort(key=lambda t: -t[0])
return {
"panel_faces_checked_area": round(panel_area, 6),
"smear_faces": len(bad),
"smear_area_pct": round(100 * bad_area / panel_area, 2) if panel_area else 0.0,
"smear_worst_deg": round(worst, 2),
"smear_sample": [[round(d, 1)] + [round(x, 4) for x in c] for d, c in bad[:8]],
"smoothed_hard_edges": len(smoothed_hard),
"smoothed_hard_sample": [[round(x, 4) for x in p] for p in smoothed_hard[:8]],
"folded_edges": len(folds), "folded_sample": [[round(x, 4) for x in p] for p in folds[:8]],
"tiny_folded_edges": tiny_folds,
"ngons_on_curvature_eval": ngon_curved,
}
def subd_cage_report(ob, aspect_max=2.5):
"""School B cage metrics [added thresholds]: quads %, quad aspect (longer over shorter
mid-edge span), share of long rectangles excluding support strips (short side under a
quarter of the median edge), creased edge length."""
me = ob.data
bm = _bm(me, ob.matrix_world)
lens = sorted(e.calc_length() for e in bm.edges)
med = lens[len(lens) // 2] if lens else 0.0
quads = [f for f in bm.faces if len(f.verts) == 4]
asp, rect = [], 0
for f in quads:
v = [x.co for x in f.verts]
a = ((v[0] + v[1]) / 2 - (v[2] + v[3]) / 2).length
b = ((v[1] + v[2]) / 2 - (v[3] + v[0]) / 2).length
lo, hi = min(a, b), max(a, b)
if lo <= 0:
continue
r = hi / lo
asp.append(r)
if r > aspect_max and lo >= 0.25 * med:
rect += 1
cr = me.attributes.get("crease_edge")
crease_len = 0.0
if cr:
vals = [d.value for d in cr.data]
crease_len = sum(bm.edges[i].calc_length() for i, x in enumerate(vals) if x > 0.9)
asp.sort()
out = {
"quads_pct": round(100 * len(quads) / len(bm.faces), 1) if bm.faces else 0.0,
"aspect_p50": round(asp[len(asp) // 2], 2) if asp else None,
"aspect_p90": round(asp[int(0.9 * (len(asp) - 1))], 2) if asp else None,
"long_rectangles": rect,
"full_crease_length": round(crease_len, 4),
"full_crease_length_rel": round(crease_len / _size(ob), 3),
}
bm.free()
return out
def shading_audit(ob, flat_tol=3.0, hard_deg=45.0, planar_tol=None, panel_min=None,
cutters=True, slivers=True):
"""Everything code can say about hard-surface shading before you look at a render.
Thresholds: flat_tol = degrees a corner normal may lean off its flat face [added],
hard_deg = dihedral above which smoothing across an edge is an error [added]."""
r = {"object": ob.name}
r["stack"] = [m.name + ("*" if m.use_pin_to_last else "") for m in ob.modifiers]
r["stack_problems"] = stack_problems(ob)
s = ob.matrix_basis.to_scale()
r["scale_applied"] = max(abs(s.x - 1), abs(s.y - 1), abs(s.z - 1)) < 1e-4
mods = [m for m in ob.modifiers if m.show_viewport]
micro = [m for m in mods if role(m) == "micro_bevel"]
has_subsurf = any(m.type == "SUBSURF" for m in mods)
r["school"] = "B (subdivision)" if has_subsurf else "A (bevel + custom normals)"
expects_custom = any(role(m) in {"weighted_normal", "normal_transfer"} for m in mods) or \
any(m.harden_normals for m in micro)
me = _eval_copy(ob)
try:
r["eval_verts"], r["eval_faces"] = len(me.vertices), len(me.polygons)
me.calc_loop_triangles()
r["eval_tris"] = len(me.loop_triangles)
r["custom_normals"] = bool(me.has_custom_normals)
r["custom_normals_expected"] = expects_custom
bm = _bm(me)
r["eval_non_manifold_edges"] = sum(1 for e in bm.edges if not e.is_manifold)
r["eval_ngons"] = sum(1 for f in bm.faces if len(f.verts) > 4)
bm.free()
r["eval_flat_shaded_faces"] = sum(1 for p in me.polygons if not p.use_smooth)
if not has_subsurf and len(me.polygons):
w = micro[0].width if micro else 0.0
pm = panel_min if panel_min is not None else (2.5 * w if w else 0.02 * _size(ob))
curve_max = math.degrees(micro[0].angle_limit) if micro else 45.0
r["normals"] = _normal_checks(ob, me, pm, flat_tol, hard_deg, curve_max,
fold_area=(w if w else 0.005 * _size(ob)) ** 2)
finally:
_free(me)
base = ob.data
r["base_flat_shaded_faces"] = sum(1 for p in base.polygons if not p.use_smooth)
sh = base.attributes.get("sharp_edge")
sharp_on_smooth = 0
if sh:
bmb = _bm(base, ob.matrix_world)
for e in bmb.edges:
a = _dihedral(e)
if sh.data[e.index].value and a is not None and a < 5.0:
sharp_on_smooth += 1
bmb.free()
r["sharp_edges_on_smooth_surface"] = sharp_on_smooth
npf = nonplanar_faces(ob, planar_tol)
r["nonplanar_base_faces"] = len(npf)
r["nonplanar_worst"] = round(npf[0][1], 6) if npf else 0.0
r["nonplanar_sample"] = [i for i, _ in npf[:10]]
r["base_ngons"] = sum(1 for p in base.polygons if p.loop_total > 4)
angle = math.degrees(micro[0].angle_limit) if micro else 30.0
r["cuts_on_curvature"] = cuts_on_curvature(ob, angle) if cutters else []
r["has_normal_transfer"] = any(role(m) == "normal_transfer" for m in mods)
if micro:
r["clamp"] = [clamp_probe(ob, m) for m in micro]
if any(c and (c.get("topology_changed") or (c.get("ratio") or 0) > 0.1) for c in r["clamp"]):
r["clamp_culprits"] = clamp_culprits(ob)
if cutters and any(m.type == "BOOLEAN" for m in mods):
r["booleans"] = boolean_report(ob)
r["cutters"] = [cutter_report(c, ob) for c in cutters_of(ob)]
if slivers:
r["slivers"] = sliver_report(ob)
if has_subsurf:
r["subd_cage"] = subd_cage_report(ob)
return r
def verdict(r, clamp_ratio=0.1):
"""Readable problems from shading_audit (empty list = nothing code can find)."""
out = list(r.get("stack_problems", []))
if not r.get("scale_applied", True):
out.append("object scale not applied")
if r.get("custom_normals_expected") and not r.get("custom_normals"):
out.append("custom normals expected (harden/WN) but absent on the evaluated mesh")
if r.get("eval_non_manifold_edges"):
out.append(f"{r['eval_non_manifold_edges']} non-manifold edges in the result")
if r.get("eval_flat_shaded_faces"):
out.append(f"{r['eval_flat_shaded_faces']} flat-shaded faces (cutter or base not shaded smooth): facets on bevels/cut walls")
if r.get("sharp_edges_on_smooth_surface"):
out.append(f"{r['sharp_edges_on_smooth_surface']} sharp-marked edges on a smooth surface: visible seam")
if r.get("nonplanar_base_faces"):
out.append(f"{r['nonplanar_base_faces']} non-planar base faces (worst {r['nonplanar_worst']}): flatten_faces()")
n = r.get("normals") or {}
if n.get("smear_faces"):
out.append(f"normal smear on {n['smear_faces']} flat faces ({n['smear_area_pct']}% of flat area, worst {n['smear_worst_deg']} deg): "
"Harden Normals / Weighted Normal; if they change nothing, the face is not planar")
if n.get("folded_edges"):
out.append(f"{n['folded_edges']} folded-back faces (dihedral > 150 deg) near {n['folded_sample'][:2]}: degenerate geometry, check a closeup")
if n.get("smoothed_hard_edges"):
out.append(f"{n['smoothed_hard_edges']} hard edges smoothed across: micro bevel, Smooth by Angle or WN Keep Sharp")
if not r.get("has_normal_transfer"):
for c in r.get("cuts_on_curvature", []):
out.append(f"{c['cutter']} ends on a curved surface ({c['curved_faces_hit']} faces): check a closeup; "
"if it stretches, end it on a flat area or transfer_normals()")
for c in r.get("clamp") or []:
if c is None:
continue
if c.get("topology_changed"):
out.append(f"{c['bevel']}: Clamp Overlap changes topology: overlapping geometry near a cut")
elif c.get("ratio") is not None and c["ratio"] > clamp_ratio:
out.append(f"{c['bevel']}: Clamp Overlap moves verts by {c['ratio']} x width (the whole bevel shrinks); "
f"caused by {r.get('clamp_culprits') or 'several cuts together'}")
for b in r.get("booleans", []):
if b.get("enabled") and not b.get("changes_mesh"):
out.append(f"{b['boolean']} changes nothing (cutter misses the target, or Exact dropped the result)")
if b.get("result_empty"):
out.append(f"{b['boolean']} empties the mesh: broken operand, diagnose with try_solvers")
if b.get("operand_non_manifold"):
out.append(f"{b['boolean']}: operand has {b['operand_non_manifold']} non-manifold edges")
for c in r.get("cutters", []):
if not c["scale_applied"]:
out.append(f"{c['cutter']}: scale not applied")
if not c["smooth"]:
out.append(f"{c['cutter']}: not smooth shaded (facets transfer into the cut)")
if not c["hide_render"] or c["display"] not in ("WIRE", "BOUNDS"):
out.append(f"{c['cutter']}: visible in render or solid display")
if not c["only_in_cutters"]:
out.append(f"{c['cutter']}: linked outside the Cutters collection {c['collections']}")
if c["coplanar_faces"]:
out.append(f"{c['cutter']}: {c['coplanar_faces']} faces coplanar with the target (Z-fighting; keep EXACT or offset it)")
if c["near_parallel_faces"] or c["near_miss_verts"]:
out.append(f"{c['cutter']}: {c['near_parallel_faces']} faces / {c['near_miss_verts']} verts within {c['near_band']} of a target surface: "
"thin polygons; move it clearly past or make it coincident")
if c.get("narrow_gap_faces"):
out.append(f"{c['cutter']}: cuts a gap of {c['narrow_gap_min']} (under 2 x bevel width): the rim bevels meet; widen it or thin the bevel")
if c.get("thin_wall_faces"):
out.append(f"{c['cutter']}: leaves a wall of {c['thin_wall_min']} near {c['thin_wall_at']} (under 2 x bevel width): "
"move or resize the cut, or thin the bevel")
if c.get("beveled_narrow_facet_edges"):
out.append(f"{c['cutter']}: {c['beveled_narrow_facet_edges']} rounding edges reach the bevel angle limit on facets "
"narrower than 2 x width: use more segments (facet angle well under the limit)")
if not c["pokes_out"]:
out.append(f"{c['cutter']}: entirely inside the target (does not break the surface)")
sl = r.get("slivers") or {}
clamping = any(c and (c.get("topology_changed") or (c.get("ratio") or 0) > clamp_ratio)
for c in r.get("clamp") or [])
for b in sl.get("per_boolean", []) if clamping else []: # locate the cause of clamping
if b["pre_short_added"] > 0 or b["pre_thin_added"] > 0:
out.append(f"{b['boolean']}: adds {b['pre_short_added']} edges / {b['pre_thin_added']} faces smaller than the micro bevel "
f"next to beveled edges ({b.get('pre_defects_near_cutter', '?')} near the cutter, e.g. {b.get('pre_defect_sample', [])[:2]})")
sc = r.get("subd_cage")
if sc:
if sc["long_rectangles"]:
out.append(f"subD cage: {sc['long_rectangles']} long rectangles (aspect > 2.5): square them if the cage will be "
"sculpted or subdivided further (PzThree)")
if sc["full_crease_length_rel"] > 0.5:
out.append("subD: long fully creased edges; creases cost about 10x the triangles of support loops at equal quality (Lampel)")
return out
# --------------------------------------------------------------------------------------
# review
# --------------------------------------------------------------------------------------
EXTRA_MODES = {"shiny": "metal_carpaint.exr", "reflect": "check_reflection_horizontal.exr",
"hsgrey": "hard_surface_grey.exr", "fullmetal": "fullmetal.exr"}
def _collapsed_copies(objs):
copies = []
for o in objs:
me = _eval_copy(o)
c = bpy.data.objects.new(o.name + "_HSreview", me)
c.matrix_world = o.matrix_world.copy()
bpy.context.scene.collection.objects.link(c)
copies.append(c)
bpy.context.view_layer.update()
return copies
def _remove_copies(copies):
for c in copies:
me = c.data
bpy.data.objects.remove(c)
_free(me)
def review(objs, out_dir, views=("front", "threequarter", "top", "low"),
modes=("matcap", "hsgrey", "reflect", "wire"), res=640):
"""bx_review contact sheet of the EVALUATED meshes (booleans, bevels, custom normals
baked into temporary copies, so the wire row shows the real boolean topology and the
cutters never appear). Extra modes on top of bx_review's: shiny (metal_carpaint),
reflect (check_reflection_horizontal), hsgrey (hard_surface_grey), fullmetal."""
import bx_review
orig = bx_review._set_mode
def patched(sc, world, mode, o, wires):
if mode in EXTRA_MODES:
orig(sc, world, "matcap", o, wires)
sc.display.shading.studio_light = EXTRA_MODES[mode]
else:
orig(sc, world, mode, o, wires)
copies = _collapsed_copies(objs)
bx_review._set_mode = patched
try:
return bx_review.review(copies, out_dir, views=views, modes=modes, res=res)
finally:
bx_review._set_mode = orig
_remove_copies(copies)
def closeup(ob, point, radius, out_path, direction=(-0.6, -1.0, 0.8), mode="shiny", res=640,
lens=50.0):
"""One perspective close-up of the evaluated object around `point` (world), framing a
sphere of `radius`. Use it on each cut: smears and bevel shrink are too small to judge
on a whole-object sheet."""
import bx_review
copies = _collapsed_copies([ob])
sc, cam, world = bx_review._setup_scene(copies, res)
try:
if mode in EXTRA_MODES:
bx_review._set_mode(sc, world, "matcap", copies, [])
sc.display.shading.studio_light = EXTRA_MODES[mode]
else:
bx_review._set_mode(sc, world, mode, copies, [])
cam.data.type, cam.data.lens = "PERSP", lens
fov = 2 * math.atan(cam.data.sensor_width / (2 * lens))
dist = radius / math.sin(fov / 2)
cam.data.clip_start, cam.data.clip_end = dist * 0.01, dist * 20
bx_review._look_at(cam, Vector(point), Vector(direction).normalized(), dist)
sc.render.filepath = out_path
bpy.ops.render.render(write_still=True, scene=sc.name)
finally:
bx_review._cleanup(sc, cam, world)
_remove_copies(copies)
return out_path
# --------------------------------------------------------------------------------------
# game
# --------------------------------------------------------------------------------------
def tri_count(ob):
me = _eval_copy(ob)
me.calc_loop_triangles()
n = len(me.loop_triangles)
_free(me)
return n
def game_version(ob, name=None, micro_segments=1, design_segments=None, triangulate=True):
"""Gambrell's game route: a copy with fewer bevel segments, Triangulate (keep custom
normals) after Weighted Normal. Returns (copy, evaluated tris). Check it in a matcap and
against the budget; bake-based routes live in scenario-blender-retopology / scenario-blender-uv-baking."""
g = ob.copy()
g.data = ob.data.copy()
g.name = name or ob.name + "_game"
for c in ob.users_collection:
c.objects.link(g)
for m in g.modifiers:
if m.type == "BEVEL":
if role(m) == "micro_bevel":
m.segments = micro_segments
elif design_segments is not None:
m.segments = design_segments
if triangulate and not any(m.type == "TRIANGULATE" for m in g.modifiers):
t = g.modifiers.new("Triangulate", "TRIANGULATE")
t.keep_custom_normals = True
order_stack(g, pin=True)
return g, tri_count(g)
def collapse(ob, name=None, link=True):
"""du Mont's rig/export prep without operators: a new object from the evaluated mesh
(modifiers baked, custom normals kept), same transform and collections."""
me = _eval_copy(ob)
me.name = (name or ob.name + "_collapsed")
c = bpy.data.objects.new(me.name, me)
c.matrix_world = ob.matrix_world.copy()
if link:
for col in ob.users_collection:
col.objects.link(c)
return c
SKILL.md
---
name: scenario-blender-hard-surface
description: "Use when modeling hard-surface objects in Blender (sci-fi panels, props, weapons, vehicles, robots, machined or product parts) with booleans and cutters, bevel modifiers, weighted normals, or subdivision with support loops or creases. Also when shading is broken (smears on flat faces, stretching near a cut, pinching), a bevel shrinks or vanishes after a boolean, a boolean empties the mesh or leaves holes, choosing n-gons vs quads, or making a game-ready mid-poly."
license: MIT
---
# Blender hard surface
Expert hard surface is controlled light on edges: every edge catches a highlight from a bevel or a
support loop, flat faces read perfectly flat, and cuts stay editable until the design is settled.
The experts disagree on method (n-gons and booleans vs quad cages and subdivision), not on the
checks: order of operations, cutter hygiene, planar faces, a shiny matcap orbit. The agent builds
the stack with the toolkit and lets the measurable gates find what a human finds by orbiting. If a
sibling skill named here is missing from your available skills, load it with `skill_get` (Scenario
MCP) or ask the user to install it (https://github.com/scenario-labs/skills#install); unattended,
proceed from tool schemas and flag the gap.
**REQUIRED BACKGROUND:** scenario-blender-expert (execution channel, review loop, 5.2 API changes).
Toolkit: [`scripts/bx_hardsurface.py`](scripts/bx_hardsurface.py)
(`import sys; sys.path.append("<skills>/scenario-blender-hard-surface/scripts"); import bx_hardsurface as HS`),
world-space meters, stock Blender only.
## Stance (the expert delta)
- **Cut first, bevel after, normals last.** Booleans above the angle-limited micro bevel, Weighted
Normal at the very bottom, a late Mirror above it (Ryuu [00:26:26], [00:28:37]; du Mont
[00:19:46]). In 5.2 a pinned modifier keeps its relative order inside the pinned group and new
modifiers land above it, so pin bottom-up: `HS.order_stack` does it and `HS.add_boolean` always
lands above the tail (verified).
- **Two bevels, two jobs** (du Mont [00:12:25] to [00:15:15]): a weight-limited design bevel (8 to
12 segments) on chosen silhouette edges before the cuts, and an angle-limited micro bevel (30
degrees, Harden Normals, Miter Outer Arc) after every cut, because no real edge is perfectly sharp
(Gambrell, du Mont, Ryuu).
- **A bevel that shrinks after a boolean is geometry, not a setting** (du Mont [00:20:18],
[00:30:20]). Move the cutter well away from target edges or make it exactly coincident; keep Clamp
Overlap on. Clamp Overlap limits the bevel over the whole object: a slot running 0.7 degrees off a
facet edge (clamp ratio 3.8) wiped out the 3 mm bevel on a port elsewhere on the part [verified in
5.2.1]. `HS.clamp_culprits` names the cut.
- **Every wall, step, gap and rounding facet must be wider than 2x the micro bevel width** [added,
verified]: a 1 mm pocket floor, a 1.5 mm wall, a 2 mm groove with 1.5 mm bevels (a 3.1 mm one was
clean) and a slot rounded in 30-degree facets all clamped the whole object's bevel.
- **If Harden Normals or Weighted Normal changes nothing, the face is not flat** (Gambrell
[00:01:06]). Flatten it; more normal modifiers will not help.
- **Pick the school by the part, not by taste.** Planar machined parts with many cuts: n-gons +
booleans + bevel + custom normals, about a quarter of the time and stays editable (Gambrell
[00:18:01]). Continuous curvature, non-uniform roundness, a sculpt or bake high: quad cage +
subdivision (rileyb3d, PzThree). Sharpen subD edges with support loops, not creases: 36,800 vs
3,600 triangles at equal quality (Lampel [00:05:28]).
- **EXACT is the solver.** Gambrell's 2021 "switch to Fast" predates 5.x: Float left 16 non-manifold
edges on a coplanar cutter where Exact and Manifold were clean [verified]. When Exact misbehaves
the operand is broken (Ryuu [00:15:13]): an open cutter gave Exact 19 non-manifold edges and
Manifold silently skipped the cut [verified].
- **Judge in a shiny matcap and close up on every cut** (Gambrell [00:00:33], rileyb3d [00:06:32]).
Whole-object sheets hide smears and shrunken bevels.
## Establish first
| Input | Why it changes the plan | Default when silent |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| Purpose | render: 3+ micro segments (Gambrell); game mid-poly: 1 to 2 segments, triangulated, custom normals; bake: subD or boolean high + separate low | render still |
| Real size | bevel widths are absolute: du Mont uses about 3 mm on a small robot body, 1 mm on small parts, 0.1 mm on a collar | model at real size; if unknown, micro width 0.5% of the largest dimension [added] |
| Surface | mostly planar vs curved vs organic-mechanical | School A for planar parts, B for curved shells, hybrid when mixed |
| Budget and engine | Augusto: 15k tris, one 2K map; a quad-only pipeline forces School B (Gambrell [00:24:35]) | no budget: report tris |
| Later stages | sculpted damage or multires detail needs a quad cage (PzThree); rigging needs collapsed copies (du Mont) | none |
| Symmetry | mirror the target when the part is symmetric; mirror the cutter around the target when only the cut is (Gambrell [00:14:53]) | ask the reference |
## Workflow
1. **Blockout at real size.** Primitives or bmesh, then `HS.apply_scale(ob)` (keeps children in
place, flips winding on negative scale). Check proportions against a human-scale box through a
long lens (du Mont [00:10:01]). GATE: scale is 1 on every object with a bevel, solidify or array;
silhouette sheet reads (`bx_review.review(..., modes=("silhouette",))`).
2. **Pick the school and build the base stack.**
- A: design edges with `HS.find_edges(ob, min_angle=80, inside=...)` +
`HS.set_edge_attr(ob, edges, 1.0)`, then
`HS.hard_surface_stack(ob, micro_width=..., design_width=...)`.
- B: quad cage in bmesh (few control points, square quads, rileyb3d / PzThree), then
`HS.subd_stack(ob, support_width=...)` (Bevel Weight, even segments, profile 1.0, Subdivision
with UV Smooth Keep Corners) or explicit `HS.support_loops`. GATE:
`HS.stack_problems(ob) == []`; A has custom normals on the evaluated mesh.
3. **Cut (School A).** `HS.make_cutter(target, "BOX"|"CYLINDER"|"SLOT", size, location, ...)` builds
a cutter that meets the checklist (scale 1, smooth, Cutters collection only, wire, hidden from
render, parented, EXACT, wired above the tail). `HS.as_cutter` for custom shapes,
`HS.slice_boolean` for Slice. Rules: extend past every surface entered; keep cutter edges far
from target edges or exactly on them; offset a round cutter by half a segment against a target
with the same count (same phase gave 10 non-manifold edges and streaks [verified]; `make_cutter`
default); round cutter corners in facets well under the bevel angle (4+ segments per 90 degrees).
GATE after each cut: `HS.cutter_report(c, target)` clean,
`HS.clamp_probe(target)["ratio"] < 0.1`, a `HS.closeup` of the cut looked at.
4. **Design pass.** Cluster details, leave calm areas, align with neighboring features (Ryuu
[00:28:37], Gambrell [00:34:15]); hide perfect intersections behind a collar (du Mont
[00:39:36]); union parts that must share a bevel (Gambrell [00:18:57]). GATE: a review sheet
shows intent, not noise.
5. **Shading pass.** `r = HS.shading_audit(ob); HS.verdict(r)`, then
`HS.review([ob], out, modes=("matcap","reflect","hsgrey","wire"))` and closeups. Triage with
[`references/critique.md`](references/critique.md): smear on flat faces, non-planar faces, cuts
on curvature (render first: Harden + WN already shaded 24/48-segment cylinder cuts cleanly; on a
sphere `HS.transfer_normals` cut the normal error p95 from 2.79 to 0.34 degrees [verified]),
mirror seam (`HS.mirror_seam_fix`). GATE: verdict empty or every remaining line explained, and
the closeups agree.
6. **School B detail and pinch check.** Even bevel segments, profile 1.0, Keep Corners (Lampel
[00:11:15]); brace both sides of a ridge equally (rileyb3d [00:11:08]); outset a border ring
around inset circles (PzThree [00:42:25]); turn subdivision off to debug a cage artifact
(Gambrell [00:14:44]). GATE: `r["subd_cage"]` quads near 100%, no long rectangles on a cage that
will be sculpted, shiny-matcap closeups free of pinches.
7. **Game or export.** Mid-poly: `g, tris = HS.game_version(ob, micro_segments=1)` (Triangulate with
keep custom normals after WN; stays in place because it shares the cutters); glTF kept the custom
normals 100% [verified]. Baked asset: the low poly and bake go to scenario-blender-retopology and
scenario-blender-uv-baking. Rig or export: `HS.collapse(ob)` on a copy (du Mont [01:07:29]).
GATE: tri count within budget, triangles only, matcap closeup of the game copy.
8. **Deliver** renders, the verdict, tris per object, and what was not verified.
## Numbers
| Value | Setting | Relative to / source |
| ------------------------ | ------------------------------------------------------------------------------------------- | --------------------------------------- |
| Micro bevel | angle 30 degrees, 3 segments render, 1 to 2 game, Harden Normals, Miter Outer Arc | Gambrell [00:17:20], du Mont [00:14:40] |
| Micro width | about 3 mm body, 1 mm small parts, 0.1 mm collar | real size, du Mont |
| Design bevel | Limit Method Weight, 8 to 12 segments | du Mont [00:13:32] |
| Big rolling curve | 46 to 50 segments; cutter edge bevels about 8 to 10 | Ryuu [00:12:53], [00:05:42] |
| Min feature | wall, step, groove, facet width > 2 x micro width | [added, verified] |
| Clamp probe | displacement over 10% of width = clamping | [added] |
| Solver | EXACT; MANIFOLD for speed with manifold operands; FLOAT only to diagnose | verified in 5.2.1 |
| Round cutters | vertex count set at creation (40 for screw holes); half-segment phase vs target | Ryuu [00:29:43]; [verified] |
| Bevel before Subdivision | 2 or 4 segments, profile 1.0, Weight, UV Smooth Keep Corners | Lampel [00:08:06], [00:11:15] |
| Creases | fine for small details or rounding a coarse cylinder at level 1; 10x tris for primary edges | Lampel, du Mont [00:52:18] |
| SubD cage | circle 18 verts body, 36 on a denser part; square quads | rileyb3d [00:01:34], PzThree [00:03:15] |
| Mirror seam fix | bevel Face Strength Affected + WN Face Influence, weight 100 | Gambrell [00:06:57] |
| Game budget | 15k tris, one 2K map, animated parts separate | Augusto [00:11:48] |
| Cost measured | case 1,984 tris (A) vs 4,608 (B, level 2); barrel 7,308 (A), 25,728 (B), 3,716 (A game) | test_03 [added] |
## Quality gates
**In code** (all in `HS.shading_audit`, readable through `HS.verdict`): scale applied;
`stack_problems` empty; custom normals present when expected; 0 non-manifold edges; 0 flat-shaded
faces; 0 non-planar base faces (tolerance 1e-4 x size); no smear (corner normal leaning over 3
degrees on a flat face [added]); no hard edge (over 45 degrees) smoothed across; clamp ratio under
0.1; every boolean changes the mesh; cutter reports clean (coplanar, near-parallel, thin wall,
narrow gap, near-miss, rounding facets, pokes out, hygiene). School B: quads %, quad aspect, crease
length. Game: `HS.tri_count`, triangles only.
**Visual:** `HS.review` rows matcap (forms), reflect (`check_reflection_horizontal`: stripes run
continuous over bevels and rounds), hsgrey (bevel highlights), wire (real boolean topology); views
front, threequarter, top, low. Then one `HS.closeup(ob, point, radius, ...)` per cut in `shiny`
(metal_carpaint). Look for gradient smears on flat faces, a bevel highlight broken or narrower at a
cut, stretch at cut corners on curves, a butterfly on a mirror seam, subD pinches, facets on cut
walls. A straight-down shiny view saturates white: use matcap or hsgrey from the top.
## Common mistakes
| Mistake | What it looks like | Fix |
| ----------------------------------------------------------- | -------------------------------------------- | -------------------------------------------------------------------------- |
| Boolean below the micro bevel | cut edges razor sharp, rest beveled | `HS.order_stack(ob)` |
| Weighted Normal added after pinning the bevel | WN lands above the bevel, smears return | pin bottom-up (`order_stack`) |
| Cutter near but not on a target face or edge | whole-object bevel narrower, thin steps | move clearly away or snap exactly; `cutter_report`, `clamp_culprits` |
| Groove or wall thinner than 2x bevel | clamped bevels everywhere | widen the feature or thin the bevel |
| Round cutter in phase with the target facets | streaks, black patches, non-manifold edges | half-segment phase (default in `make_cutter`) |
| Rounded cutter corners at 30-degree facets | micro bevel catches facet edges, clamps | more segments (`beveled_narrow_facet_edges`) |
| Flat-shaded cutter | facets on the cut walls | cutters smooth (`make_cutter` does it) |
| "Switch to Fast" | non-manifold result on coplanar faces | EXACT; repair the operand instead |
| Unapplied scale | uneven bevel widths | `HS.apply_scale` |
| Stacking normal fixes on a non-planar face | stretch that nothing fixes | `HS.flatten_faces` |
| Data Transfer placed before WN, or scoped by a vertex group | no effect / cut walls shaded like the skin | `HS.transfer_normals` (after WN, distance-scoped) |
| du Mont's support cut placed through a round cut | rim edges halved, clamp 0 to 1.2 [verified] | only where `sliver_report` shows boolean slivers; measure before and after |
| Creases on primary subD edges | soft, pinched, heavy | support loops or bevel before subdivision |
| Moving a game copy or slice | cuts vanish (cutters stay with the original) | keep it in place, or `HS.collapse` then move |
## Blender 5.2 notes
- Auto Smooth is gone (4.1): `bpy.ops.object.shade_auto_smooth` adds a pinned Smooth by Angle node
modifier (works headless with `temp_override`); WN, Harden Normals and Data Transfer need no Auto
Smooth.
- Boolean solvers: `'EXACT'` (default), `'MANIFOLD'` (4.5), `'FLOAT'` (the old Fast, renamed 5.0).
- Bevel weights and creases are attributes `bevel_weight_edge` / `crease_edge` (4.0); the Bevel
modifier reads any attribute through `edge_weight`.
- `ob.modifiers.move(i, j)` silently refuses moves that involve pinned modifiers;
`modifier_move_to_index` returns CANCELED. Unpin, move, re-pin bottom-up.
- `bmesh.ops.bevel` defaults are `segments=0, profile=0.0`: always pass both.
- EXACT gives cutter-made vertices the target's vertex-group weights (verified), so vertex groups
cannot keep a Data Transfer off cut walls.
- Bool Tool, LoopTools, Extra Objects are extensions (4.2); LoopTools Circle/Flatten/Space are built
in (`mesh.circularize`, `mesh.flatten`, `mesh.space_edge_loops_evenly`); Relax and Round Cube are
not.
## References
- [`references/procedures.md`](references/procedures.md): full tested code for the three parts
(panel, case both schools, barrel both schools), every audit case, game routes. Load before
scripting.
- `references/critique.md`: the rubric and triage table to judge a render and a verdict. Load at
every shading pass.
- [`references/expert-notes.md`](references/expert-notes.md): the experts' principles, disagreements
and deciding conditions, with timestamps. Load when a choice is not covered here.
- [`references/sources.md`](references/sources.md): the nine videos, who, what each is best for,
best timestamps.