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
Install
npx skills add https://github.com/scenario-labs/skills/tree/main/skills/dcc/blender/scenario-blender-hard-surface
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install scenario-labs-skills@llmmart
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_stackdoes it andHS.add_booleanalways 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_culpritsnames 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
- 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",))). - 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), thenHS.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 explicitHS.support_loops. GATE:HS.stack_problems(ob) == []; A has custom normals on the evaluated mesh.
- A: design edges with
- 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_cutterfor custom shapes,HS.slice_booleanfor 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_cutterdefault); 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, aHS.closeupof the cut looked at. - 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.
- Shading pass.
r = HS.shading_audit(ob); HS.verdict(r), thenHS.review([ob], out, modes=("matcap","reflect","hsgrey","wire"))and closeups. Triage withreferences/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 sphereHS.transfer_normalscut 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. - 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. - 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. - 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_smoothadds a pinned Smooth by Angle node modifier (works headless withtemp_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 throughedge_weight. ob.modifiers.move(i, j)silently refuses moves that involve pinned modifiers;modifier_move_to_indexreturns CANCELED. Unpin, move, re-pin bottom-up.bmesh.ops.beveldefaults aresegments=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.
Reviews (0)
No reviews yet.
No comments yet.