Claude Skill

scenario-blender-hard-surface

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 f

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download scenario-labs-skills-skills_dcc_blender_scenario-blender-hard-surface-dc592af.zip · 50 KB
Part of scenario-labs/skills — 97 skills

Install

skills CLI npx skills add https://github.com/scenario-labs/skills/tree/main/skills/dcc/blender/scenario-blender-hard-surface
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install scenario-labs-skills@llmmart
Git git clone https://github.com/scenario-labs/skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole scenario-labs/skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

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, ask the user to install it (npx skills add scenario-labs/skills --skill <name>); 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 (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: 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: 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: the experts' principles, disagreements and deciding conditions, with timestamps. Load when a choice is not covered here.
  • references/sources.md: the nine videos, who, what each is best for, best timestamps.
Files (skills)
  • references
    • critique.md 14.1 KB
      # 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).
      
    • expert-notes.md 17.5 KB
      # 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.
      
    • procedures.md 24.7 KB
      # 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.
      
    • sources.md 7.2 KB
      # 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 67.9 KB
      """
      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 
  • SKILL.md 17.9 KB
    ---
    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, ask the user to install it (`npx skills add scenario-labs/skills --skill <name>`); 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.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related