shipping-crate
A procedural shipping crate through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.
A procedural mid-century utility tractor standing in the ruts of a muddy farmyard with a puddle between its wheels — a cast-iron backbone (engine, bell housing, gearbox, rear axle housing and trumpets); a front axle oscillating on a pivot pin through the front support's lugs and the beam's two bushings, kingpins, spindles and a tie rod; 1.240 m rear tyres with 44 chevron bars rooted in the carcass, handed for forward travel and wrapped round the shoulder, mud packed between them, on dished rims with clamp lugs and cast-iron half-moon weights; ribbed front tyres; a grille shell with radiator and headlamps, bonnet, fuel tank and dash; a vertical exhaust stack with a hinged rain cap and an oil-bath pre-cleaner bowl; a raked steering column, a pan seat on a leaf spring, pedals and footplates; mudguards with a tail lamp and a work lamp; a three-point linkage with lift arms, lift rods, lower links, a stowed top link and drawbar, and a PTO stub under its shield; worn paint chipped to primer at its edges, rust-streaked and mud-splashed — through UVs, bake, LOD, collider, and Unity glTF, asserting recomputed budgets rather than an API contract.
Rendered headless by the showcase piece itself — click to zoom.
category Vehicles
blender --background --python showcase/farm-tractor/farm_tractor.py --
A showcase piece, not an example, and the ninth in the vehicles category: things that move. It builds a mid-century utility farm tractor (generic, no marks, text or maker's livery) standing in the ruts of a muddy farmyard:
It asserts budget conformance of the generated result. It does not witness an API contract. "It rendered without error" is not a check.
Composes skills mesh-editing-and-bmesh, bake-high-to-low, depsgraph-and-evaluated-data, engine-export-presets, and snippets bake_normal_high_to_low.py, setup_bake_target_image.py, lod_chain.py / decimate_to_budget.py, convex_hull_collider.py, export_preset_unity.py (helpers copied inline, not imported).
Intended size: wheelbase 1.780 m, rear track 1.320 m (52 in) and front track 1.220 m (48 in), tyre centre to tyre centre; rear tyres 1.240 m over the lugs (11.2-28 class), fronts 0.690 m (4.00-19 class); the rain cap 1.73 m over the slab's underside. The origin is under the patch at the slab's underside.
Every budget is declared as a named constant. Every gate recomputes its value from the mesh, materials, UVs, evaluated LOD, collider, or export file. Shells are found by a part face attribute; every measured value is read from their vertices.
| Axis | Declared | Measured (5.2.1) |
|---|---|---|
| Base triangles | 123100–125700 | 124384 |
| LOD1 ratio | 0.32–0.62 of base | 0.5000 |
| LOD2 ratio | 0.10–0.35 of base | 0.2198 |
| Materials | exactly 12 distinct; ≥8080 body paint, ≥8590 wheel paint, ≥15260 rubber, ≥6890 cast iron, ≥10000 steel, ≥2350 zinc, ≥2660 black enamel, ≥790 glass, ≥1060 exhaust, ≥50 radiator core, ≥11660 soil, ≥128 water faces | 12 slots; 8792 / 9344 / 16592 / 7498 / 10876 / 2556 / 2900 / 868 / 1160 / 56 / 12676 / 140 |
| UVs | in 0..1, AABB overlap ≤ 1e-5 | in range, overlap 0 |
| Outer AABB | (3.5839, 2.0869, 1.7330) m ± 0.01, read off the vertices | (3.5839, 2.0869, 1.7330), zmin 0 |
| Collider tris | ≤ 1040 | 944 |
| Export | written, size > 0, removed after measuring | 9499928 bytes |
Every falsifier leaves the triangle count at 124384 and the envelope at (3.5839, 2.0869, 1.7330): they move parts, never add or remove them.
DECIMATE COLLAPSE triangle counts are not identical across Blender series, so the LOD gate is a ratio band, not an exact count. Bake pixels are stochastic, so the bake gate is has_data plus operator FINISHED, not byte-identity. Construction uses no RNG; two default runs print identical measurements, and 4.5.11 and 5.1.2 print the same measurements as 5.2.1 (LOD2 decimates to 27364 triangles there against 27340, ratio 0.2200 against 0.2198).
| Axis | Declared | Measured |
|---|---|---|
| Non-manifold edges | 0 | 0 |
| Loose verts / edges | 0 / 0 | 0 / 0 |
| Doubles merged at 1e-5 | 0 | 0 |
| Zero-area faces | 0 | 0 |
| N-gons | 0 | 0 |
| Coplanar cross-shell face pairs (KD range 0.05 m, plane ε 1e-4) | 0 | 0 |
Grounded: zmin | within 1e-4 of 0 | 0.0000 |
| Tyres: each tyre's deepest vertex (carcass and bars) under the mud, read by a ray down onto the soil shell | 4 tyres; 18–45 mm | 4; 28.9–30.1 mm |
The first draft measured 2471 coplanar pairs, none of them in a part: eleven identical grille bars side by side shared their faces' planes (each bar now steps back and down from the centre out, so neighbours differ and mirrored bars still match); three equal louvres shared their long faces (each now leans out at the top, so a level offset changes its plane, and is a step longer than the last); clamp-lug bolt heads on the lugs' faces; a weight bolt's head on the plane of the weight's cast grip; wheel nuts on the hub's end cap; the head's front on the bell housing's flange; exhaust port flanges of one size in a row; two spring bolts' undersides; a stalk's end on its collar; the steering shaft's end on its hub's. Stepping the bars to stop the pairs first broke the bonnet's mirror by 4.7 mm; the step now counts from the centre out.
| Axis | Declared | Measured |
|---|---|---|
| Joint fit: each front hub against its spindle and each rear hub against its half-shaft (axis of revolution and centre against the host's principal axis); the pivot pin against both bushings | 4 hubs, 2 bushings; ≤ 0.3 mm off, ≤ 0.3° | 4, 2; 0.0 mm, 0.0° |
| Joint fit: every link eye on a pin (lower links, lift arms, lift rods, top link, tie rod): centre on the pin's axis, axis along it, the pin past both faces | 14 eyes; ≤ 0.5 mm, ≤ 1.0°, ≥ 2 mm past | 14; 0.0 mm, 0.0°, ≥ 8.5 mm |
| Tyre seat: per tyre, per angular segment, the bead vertices against the rim's bead seat (a ray toward the axis along each vertex's own radial) | 4 tyres, each on a rim; 0.4–2.0 mm | 0.683–0.962 mm |
| Lugs: each bar's deepest root vertex inside the carcass, its crown proud of it (inside or out by a ray onto the carcass along the vertex's radial; how far by the nearest carcass face, since down the shoulder the carcass normal runs nearly along the axle) | 88 bars; 2.0–6.0 mm; ≥ 30 mm proud | 88; 3.985–3.999 mm; 34.289 mm |
| Mirror: every body-paint vertex against its partner across the centre plane between the half-shafts (KD-tree) | ≤ 0.5 mm | 0.0001 mm |
| Wheels: front and rear carcasses paired, centres and extents compared | ≤ 0.5 mm | 0.0001 mm |
| Size: wheelbase and both tracks from the carcasses' centres | 1.780 / 1.220 / 1.320 m ± 4 mm | 1.7800 / 1.2200 / 1.3200 |
| Size: each rear tyre's diameter, twice its farthest vertex from the rim's axis | 1.240 m ± 6 mm | 1.2400, 1.2400 |
| Tread pitch: per rear tyre and half, the bars' angles round the axle | every gap within 0.25° of 360/22 | 0.0000° |
| Handing: every rear bar's apex ahead of its shoulder end in forward rolling (about +Y, the same for both wheels) | ≥ 5° | 10.886° |
| Stance: mass centre (per-shell volume × a per-material density) inside the support triangle: the two rear contact patches and the front-axle pivot | ≥ 0.330 m (a quarter of the rear track) | 0.4120 m (1539 kg) |
| One connected assembly (union of shells whose BVH trees overlap, the patch included) | 1 component | 1 (505 shells) |
The support is a triangle, not the four tyres' rectangle: the front axle oscillates on its pin, so sideways the front wheels carry nothing until the axle reaches its stop. That is the tractor's real stability story — a mass centre that moves toward the narrow front of the triangle, or a pivot that moves off the centre line, tips it.
A mirror image of a rear tyre across the tractor's centre plane does not reverse its chevrons (the bars are symmetric about the tyre's own mid-plane); what reverses them is building the tread about the wheel's own outboard axis, which on the right-hand wheel turns the tyre round. Both tyres are built about the world's +Y, the axis forward travel turns them about, and the handing budget reads each bar's apex and shoulder angles about that same axis. Densities are effective: castings, tanks and tyres are modelled solid but are hollow.
Each falsifier violates one named budget. Every one was run on 5.2.1, 4.5.11 and 5.1.2 and exited its declared code, with the triangle count and envelope unchanged and every budget checked before the target green.
| Flag | Budget violated | Exit |
|---|---|---|
--skip-decimate | LOD1 ratio band (measured 1.0000) | 9 |
--stray-vert | loose vertex count is 0 (measured 1, placed inside the envelope) | 15 |
--lift-z | bounding box zmin is 0 (measured 0.05000) | 16 |
--float-tyre | every tyre pressed into the mud (the left front lifted 40 mm: −9.889 mm) | 16 |
--cock-hub | hubs coaxial (the left rear hub 1.5 mm off its half-shaft: 1.5 mm) | 17 |
--cant-pin | pivot pin coaxial with its bushings (the pin turned 1.5° in plan: 1.894 mm, 1.5°) | 17 |
--unpin-link | link eyes on their pins (the top link 8 mm short: its rear eye 8.0 mm off the stowage pin) | 17 |
--sink-tyre | tyre bead seat (the right rear bead 3 mm into its seat: 2.762 mm) | 18 |
--float-lugs | bars rooted in the carcass (the left rear bars lifted 7 mm off the carcass along its normal: −3.056 mm) | 18 |
--skew-wheel | rear wheels mirrored (the left rear wheel 6 mm aft of its hub: 6.000 mm) | 19 |
--wide-track | rear track at its stated size (each rear wheel 4 mm out: 1.3280 m) | 19 |
--tall-lugs | rear tyre diameter at its stated size (bars 8 mm taller: 1.256 m) | 19 |
--odd-fender | body mirror symmetry (the left mudguard bowed 3 mm out: 3.000 mm) | 19 |
--bunch-lugs | even bar pitch (every fourth bar of one right rear half turned 2°: 2.000°) | 20 |
--reverse-lugs | chevron handing (the right rear tread built about its outboard axis: −10.886°) | 21 |
--offset-pivot | stance (the pivot pin and bushings 0.30 m to the right: 0.3100 m) | 22 |
--loose-lamp | one connected assembly (the tail lamp 4 mm off its mudguard: 2 components, 4 and 501 shells) | 23 |
--float-tyre lifts the carcass alone while the rest of the tractor and the slab still ground the box. --float-lugs also makes the tyre 14 mm taller, but the bars' roots are read (18) before the diameter (19); --tall-lugs keeps the roots and changes only the crowns. --skew-wheel and --wide-track move the wheel (tyre, bars, rim, disc, weights) and leave the hub on its half-shaft, so the joint budget stays green. --cant-pin first turned the bushings with the pin, which stayed coaxial and exited 0; it now turns the pin alone. --loose-lamp first exited 0 because the lamp's bezel rested on the skirt; the lamp now stands clear on its bracket, which is its only contact.
blender --background --python farm_tractor.py --
blender --background --python farm_tractor.py -- --skip-decimate
blender --background --python farm_tractor.py -- --stray-vert
blender --background --python farm_tractor.py -- --lift-z
blender --background --python farm_tractor.py -- --float-tyre
blender --background --python farm_tractor.py -- --cock-hub
blender --background --python farm_tractor.py -- --cant-pin
blender --background --python farm_tractor.py -- --unpin-link
blender --background --python farm_tractor.py -- --sink-tyre
blender --background --python farm_tractor.py -- --float-lugs
blender --background --python farm_tractor.py -- --skew-wheel
blender --background --python farm_tractor.py -- --wide-track
blender --background --python farm_tractor.py -- --tall-lugs
blender --background --python farm_tractor.py -- --odd-fender
blender --background --python farm_tractor.py -- --bunch-lugs
blender --background --python farm_tractor.py -- --reverse-lugs
blender --background --python farm_tractor.py -- --offset-pivot
blender --background --python farm_tractor.py -- --loose-lamp
blender --background --python farm_tractor.py -- --output tractor.png
Smoke passes no flags.
The hero looks from the front right and above, so the grille, headlamp, bonnet, exhaust stack and pre-cleaner lead, the engine's right side (manifold, dynamo, starter, filter) shows under the bonnet, the right rear wheel with its weights stands behind, and the puddle lies in the rut between the wheels. The wall stands 6 m behind the patch and the warm wedge pools on it. Default stage; no deviation.
Tyres, lathed parts, sheet metal and the seat are smooth-shaded; chamfers, bars, lugs and every material boundary stay crisp through sharp edges above 35°. Every part carries a PartTone face attribute, so the four tyres and the two weight halves differ, and the lamp glass picks clear or red from it. An EdgeWear point attribute is 1 on square edges (a sheet's rolled rim, a plate's edge) and 0 on smooth surfaces and 45° chamfers; the paint chips through to red-oxide primer and bare steel in patches along those edges, and every part that carries wear has a vertex ring a few millimetres in from its square edges, so the wear stays on the edge rather than being interpolated across a face (the first draft's grille shell carried a rust band a quarter of its length wide). The paint also fades where it faces the sky, darkens with grime toward the ground, takes sparse scuffs and rust streaks, and is splashed with mud rising from the rut. Cast iron is near-black and rough, not chrome; zinc is kept to the caps, bezels, lamp housings and the bonnet strip. The mud is wet and glossy only on the up-facing rut floors. A TreadMud face attribute marks the rear carcasses' crowns; the rubber packs them with clumped, lumpy mud there, so the clean black bars stand out of it.
File-local. 9 is a valid check code. 10 is reserved for gallery_framing.check_framing on the --output path. 15–19 are the hygiene and joint-fit family. 20–23 are file-local. 24 is the asset-quality floor on the render path: check_asset_quality returns 11, which this piece already spends on the collider ceiling, so the call site remaps it.
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Uncaught exception (FATAL wrapper) |
| 2 | argparse / usage |
| 3 | Mesh did not build / no UV layer |
| 4 | Base triangle count outside range |
| 5 | Material count ≠ 12 distinct slots, or a face-count floor missed |
| 6 | UVs outside 0..1 |
| 7 | UV AABB overlap above tolerance |
| 8 | World AABB off declared outer size |
| 9 | LOD ratio band (--skip-decimate lands here) |
| 10 | Framing gate (render path only) |
| 11 | Collider triangle count above ceiling |
| 12 | Bake did not finish or image has no data |
| 13 | Export file missing or empty |
| 14 | --output produced no file |
| 15 | Mesh hygiene: loose, non-manifold, zero-area, doubles, n-gons, coplanar cross-shell pairs |
| 16 | Not grounded: bounding box zmin off 0, a tyre out of its sink band, or not 4 tyres (--lift-z, --float-tyre) |
| 17 | Joint fit: a hub off its spindle or half-shaft, the pivot pin off its bushings, a link eye off its pin, or a part missing (--cock-hub, --cant-pin, --unpin-link) |
| 18 | Seat: a tyre bead out of its seat band, a bar's root out of its band or its crown not proud, or a part missing (--sink-tyre, --float-lugs) |
| 19 | Mirror and size: body or wheels not mirrored, wheelbase, a track or a rear tyre's diameter off (--skew-wheel, --wide-track, --tall-lugs, --odd-fender) |
| 20 | Tread pitch: a bar out of step round its tyre (--bunch-lugs) |
| 21 | Chevron handing: a bar's apex not ahead of its shoulder in forward rolling (--reverse-lugs) |
| 22 | Stance: the mass centre too near an edge of the support triangle (--offset-pivot) |
| 23 | Assembly splits into more than one connected component (--loose-lamp) |
| 24 | Asset-quality floor (render path only; remapped from 11) |
"""Game-ready farm tractor in a muddy farmyard - a showcase piece, not an example. Asserts budget conformance of a procedural mid-century utility tractor after composing shipped pipeline pieces: bmesh construction, UVs, twelve materials, high-to-low normal bake, LOD chain, convex collider, Unity glTF export. A patch of farmyard mud, two wheel ruts running through it and a puddle in one of them. In the ruts stands a utility tractor (generic, no marks or text): a cast-iron backbone (engine block, bell housing, gearbox and rear axle centre housing with its trumpet housings); a pivoting front axle beam on a pin through the front support's lugs, kingpins, spindles, a tie rod; big rear wheels with chevron-lugged tyres on dished steel rims, clamp lugs and cast-iron wheel weights; small ribbed front tyres; a grille shell with its radiator and headlamps, a long bonnet over the engine, a fuel tank and filler cap, a dash with gauges; a vertical exhaust stack with a rain cap and an oil-bath pre-cleaner bowl; a steering wheel on a raked column; a pan seat on a leaf spring; rear mudguards with a tail lamp and a work lamp; footplates and pedals; a three-point linkage (lift arms, lift rods, lower links, top link) with its drawbar, and a PTO shaft stub under its shield. Budgets are declared below and recomputed from the generated result. They are not API-contract witnesses. Each falsifier violates one named budget: ``--skip-decimate`` the LOD band, ``--stray-vert`` mesh hygiene, ``--lift-z`` and ``--float-tyre`` grounding, ``--cock-hub``, ``--cant-pin`` and ``--unpin-link`` joint fit, ``--sink-tyre`` and ``--float-lugs`` seat conformance, ``--skew-wheel``, ``--wide-track``, ``--tall-lugs`` and ``--odd-fender`` mirror and size, ``--bunch-lugs`` the lug pitch, ``--reverse-lugs`` the chevron handing, ``--offset-pivot`` the stance and ``--loose-lamp`` one connected assembly. No RNG. Construction is closed-form. DECIMATE COLLAPSE triangle counts are not byte-identical across Blender versions - the LOD gate is a ratio band. blender --background --python farm_tractor.py -- blender --background --python farm_tractor.py -- --skip-decimate blender --background --python farm_tractor.py -- --output tractor.png """ import argparse import math import os import sys import tempfile import traceback import bmesh import bpy import numpy as np from mathutils import Matrix, Vector from mathutils.bvhtree import BVHTree from mathutils.kdtree import KDTree # Showcase lives at repo-root/showcase/, not under examples/. The framing # helper is the repo's only shared import and lives next to the examples; # resolve the repo root so we do not move gallery_framing.py. _REPO = os.path.abspath( os.path.join(os.path.dirname(os.path.abspath(__file__)), os.pardir, os.pardir) ) sys.path.insert(0, os.path.join(_REPO, "examples")) sys.dont_write_bytecode = True import gallery_framing # noqa: E402 import gallery_asset_quality # noqa: E402 # --- Farmyard patch (world frame) ----------------------------------------------- SOIL_T = 0.085 # the mud's nominal top over the patch's underside RUT_D = 0.028 # rut floor under the nominal field Z0 = SOIL_T - RUT_D # rut floor: the tractor frame's z origin PATCH_C = (-0.22, 0.0) PATCH_A = 1.70 # half length PATCH_B = 0.98 # half width TERRAIN_NU, TERRAIN_NV = 96, 58 RUT_FLAT = 0.160 # the ruts' compacted floor, half width RUT_WALL = 0.055 BERM_D = 0.245 # squeezed-out mud either side of each rut BERM_W = 0.040 BERM_H = 0.013 EDGE_DROP = 0.030 PUDDLE = (0.130, -0.66, 0.30, 0.12) # centre x, y and semi-axes of the dip PUD_D = 0.040 PUD_WL = 0.35 # water level, as a fraction of the dip's depth PUD_RIM = 0.72 # the water's outline, as a fraction of the semi-axes # --- Tractor layout (tractor frame: x forward, y left, z up from the rut floor) -- WHEELBASE = 1.780 TRACK_R = 1.320 # rear tyre centre to tyre centre (52 in) TRACK_F = 1.220 # front (48 in) AX_R = -0.5 * WHEELBASE AX_F = 0.5 * WHEELBASE R_RT = 0.620 # rear tyre over the lug tips: 1.240 m (11.2-28 class) LUG_H = 0.034 R_FT = 0.345 # front tyre: 0.690 m (4.00-19 class) SINK = 0.030 # every tyre pressed this far into the rut's mud Z_AR = R_RT - SINK Z_AF = R_FT - SINK RUT_Y = 0.5 * TRACK_R # Rear tyre carcass, the tread half of its profile: (distance from the # mid-plane, radius), over the crown and round the shoulder onto the sidewall. REAR_CROWN = ((0.000, 0.5860), (0.030, 0.5860), (0.070, 0.5845), (0.100, 0.5800), (0.120, 0.5722), (0.133, 0.5588), (0.1405, 0.5390), (0.1425, 0.5050)) LUGS_PER_HALF = 22 LUG_BITE = 0.004 # lug root below the carcass, along the carcass normal LUG_X = 0.010 # each bar crosses the centre line by this much LUG_END = 0.137 # the bar's sweep ends here, from the mid-plane LUG_R_END = 0.518 # the bar runs round the shoulder and down the sidewall to here LUG_SWEEP = 0.19 # the bar's run back round the tyre (rad), apex to shoulder LUG_BW, LUG_TW, LUG_CH = 0.021, 0.013, 0.003 LUG_ST = (-LUG_X, 0.012, 0.035, 0.070, 0.100, 0.120, 0.133, 0.1405) # stations (w), then the end LUG_TAPER = 0.45 # the bar's height lost from the shoulder to its end on the sidewall TYRE_SEGS_R, RIM_SEGS_R = 96, 64 TYRE_SEGS_F, RIM_SEGS_F = 72, 48 TYRE_BITE = 0.0012 # beads hooped this far onto the bead seats (a rim facet's # chord takes up to 0.43 mm of it) # Rims (bead seat, flange top, drop well, wall, flange inner face and outer face, # drop-well half width). RIM_R = dict(RS=0.3556, RFL=0.3806, RD=0.3250, t=0.005, Wf=0.127, Wo=0.133, well=0.060) RIM_F = dict(RS=0.2413, RFL=0.2573, RD=0.2220, t=0.004, Wf=0.038, Wo=0.042, well=0.012) DISC_R = ((0.072, -0.0575), (0.236, -0.0575), (0.262, -0.0505), (0.284, -0.0375), (0.299, -0.0255), (0.307, -0.0195)) DISC_F = ((0.050, -0.0045), (0.090, -0.0045), (0.099, 0.0000), (0.110, 0.0030), (0.122, 0.0005), (0.131, -0.0035), (0.150, -0.0020), (0.185, 0.0045), (0.2195, 0.0065)) PIN_R = 0.018 # front-axle pivot pin PIN_Z = Z_AF + 0.150 BEAM_Z = Z_AF + 0.083 KP_Y = 0.520 # kingpins, from the centre line BOL_Z0 = PIN_Z + 0.060 # front support's underside HW, HB, HT, HR, CROWN = 0.225, 0.850, 1.100, 0.085, 0.010 # bonnet section HX0, HX1 = 0.235, 0.965 COL_B = Vector((0.060, 0.0, 0.780)) COL_DIR = Vector((-math.sqrt(0.5), 0.0, math.sqrt(0.5))) COL_L = 0.665 WHEEL_RS = 0.205 R_F = 0.690 # mudguard inner radius, round the rear axle FENDER_TH = (-58.0, 102.0) # from the top, + forward (deg) # three-point linkage stations (tractor frame) LL_F = (-0.780, 0.300, 0.400) # lower-link front pin (x, |y|, z) LL_R = (-1.620, 0.400, 0.400) # lower-link rear eye, on the drawbar LIFT_X, LIFT_Z = -0.995, 0.905 # lift cross-shaft ARM_END = (-1.300, 0.225, 0.875) TL_F = Vector((-1.140, 0.0, 0.800)) # top link front pin TL_R = Vector((-1.600, 0.0, 0.490)) # top link stowed on the drawbar's bracket # --- Falsifier sizes ------------------------------------------------------------- FLOAT_TYRE = 0.040 # --float-tyre: left front tyre lifted out of the mud COCK_HUB = 0.0015 # --cock-hub: left rear hub off its half-shaft CANT_PIN = 1.5 # --cant-pin: pivot pin turned in plan (deg) UNPIN = 0.008 # --unpin-link: top link short, rear eye off its pin SINK_BITE = 0.0030 # --sink-tyre: right rear bead FLOAT_LUGS = 0.007 # --float-lugs: left rear lugs lifted off the carcass SKEW_WHEEL = 0.006 # --skew-wheel: left rear wheel moved aft WIDE_TRACK = 0.004 # --wide-track: each rear wheel outward TALL_LUGS = 0.008 # --tall-lugs: rear lug tips taller ODD_FENDER = 0.003 # --odd-fender: left mudguard bowed outward BUNCH_DEG = 2.0 # --bunch-lugs: every fourth right rear bar turned PIVOT_OFF = 0.300 # --offset-pivot: pin and bushings moved to the right LOOSE_LAMP = 0.004 # --loose-lamp: tail lamp off its mudguard BBOX_TOL = 0.01 # Fitted after locking geometry. Recomputed from the vertices. OUTER_SIZE = (3.5839, 2.0869, 1.7330) BASE_TRIS_MIN = 123100 BASE_TRIS_MAX = 125700 LOD1_RATIO_MIN = 0.32 LOD1_RATIO_MAX = 0.62 LOD2_RATIO_MIN = 0.10 LOD2_RATIO_MAX = 0.35 LOD1_TARGET = 0.50 LOD2_TARGET = 0.22 MATERIAL_COUNT = 12 UV_EPS = 1e-4 UV_OVERLAP_MAX = 1e-5 COLLIDER_TRIS_MAX = 1040 BAKE_RES = 1024 CAGE_EXTRUSION = 0.006 ZMIN_EPS = 1e-4 DOUBLES_EPS = 1e-5 AREA_EPS = 1e-10 COPLANAR_NORMAL_EPS = 1e-4 COPLANAR_PLANE_EPS = 1e-4 COPLANAR_CENTRE_MAX = 0.05 LIFT_Z = 0.05 # Supports: four tyres, each pressed into the mud in a band, read by rays # down onto the soil. TYRE_COUNT = 4 SINK_MIN = 0.018 SINK_MAX = 0.045 # Joint fit. COAX_MAX = 0.0003 COAX_DEG_MAX = 0.3 HUBS = 4 BUSHINGS = 2 EYES = 14 EYE_OFF_MAX = 0.0005 EYE_DEG_MAX = 1.0 EYE_PIN_PAST = 0.002 # a pin stands past both faces of its eye # Seats. TYRE_SEAT_MIN = 0.0004 TYRE_SEAT_MAX = 0.0020 LUG_BITE_MIN = 0.0020 LUG_BITE_MAX = 0.0060 LUG_PROUD_MIN = 0.030 LUGS = 4 * LUGS_PER_HALF # Mirror and size. MIRROR_EPS = 0.0005 WHEEL_MIRROR_EPS = 0.0005 SIZE_TOL = 0.004 DIAM_TOL = 0.006 REAR_DIAM = 2.0 * R_RT # Tread. PITCH_TOL_DEG = 0.25 HAND_MIN_DEG = 5.0 # Stance: mass centre inside the support triangle (the rear contact patches # and the front axle's pivot) by a quarter of the rear track. STANCE_MARGIN = 0.25 * TRACK_R HERO_YAW_DEG = 0.0 CAM_VIEW = (0.56, -0.83) CAM_DIST = 6.3 CAM_LENS = 50.0 CAM_LIFT = 1.55 AIM_OFFSET = (0.05, 0.0, -0.30) WALL_Y = 6.0 PAINT_IDX = 0 WHEEL_IDX = 1 RUBBER_IDX = 2 IRON_IDX = 3 STEEL_IDX = 4 ZINC_IDX = 5 BLACK_IDX = 6 GLASS_IDX = 7 EXHAUST_IDX = 8 CORE_IDX = 9 SOIL_IDX = 10 WATER_IDX = 11 FACE_FLOORS = { PAINT_IDX: 8080, WHEEL_IDX: 8590, RUBBER_IDX: 15260, IRON_IDX: 6890, STEEL_IDX: 10000, ZINC_IDX: 2350, BLACK_IDX: 2660, GLASS_IDX: 790, EXHAUST_IDX: 1060, CORE_IDX: 50, SOIL_IDX: 11660, WATER_IDX: 128, } MAT_LABELS = ("body paint", "wheel paint", "rubber", "cast iron", "steel", "zinc", "black enamel", "glass", "exhaust", "radiator core", "soil", "water") # Effective densities (kg/m^3): castings, tanks and tyres are modelled solid # but are hollow or partly so. DENSITY = (2000.0, 7850.0, 480.0, 2700.0, 7850.0, 7000.0, 2500.0, 2500.0, 2500.0, 2500.0, 0.0, 0.0) # Part tags: a face attribute naming which part a face belongs to, so the # audits can find the shells they measure. Every measured value is read from # the vertices, never from these constants. (T_NONE, T_SOIL, T_WATER, T_TYRE, T_LUG, T_RIM, T_HUB_F, T_HUB_R, T_STUB, T_AXLE, T_PIVOT, T_BUSH, T_EYE, T_PIN) = range(14) X = Vector((1.0, 0.0, 0.0)) Y = Vector((0.0, 1.0, 0.0)) Z = Vector((0.0, 0.0, 1.0)) TONE = "PartTone" WEAR = "EdgeWear" PACK = "TreadMud" def eevee_engine_id(): return "BLENDER_EEVEE" if bpy.app.version >= (5, 0, 0) else "BLENDER_EEVEE_NEXT" def fail(msg, code): print(f"ERROR: {msg}", file=sys.stderr) return code def triangle_count(mesh): mesh.calc_loop_triangles() return len(mesh.loop_triangles) def evaluated_triangle_count(obj): # Duplicated from snippets/lod_chain.py / decimate_to_budget.py (not a package). depsgraph = bpy.context.evaluated_depsgraph_get() eval_obj = obj.evaluated_get(depsgraph) eval_mesh = eval_obj.to_mesh() try: eval_mesh.calc_loop_triangles() return len(eval_mesh.loop_triangles) finally: eval_obj.to_mesh_clear() def smoothstep(e0, e1, x): t = min(max((x - e0) / (e1 - e0), 0.0), 1.0) return t * t * (3.0 - 2.0 * t) # -------------------------------------------------------------------------- # Construction helpers (copied from showcase/go-kart and showcase/planet-rover, # not imported) # -------------------------------------------------------------------------- class Build: """The bmesh under construction, its part-tag and tone layers and named vertex groups (for the falsifiers that move one assembly).""" def __init__(self, bm): self.bm = bm self.tag = bm.faces.layers.int.new("part") self.tone = bm.faces.layers.float.new(TONE) self.pack = bm.faces.layers.float.new(PACK) self.groups = {} def part(self, tag=T_NONE, tone=0.5, *groups): return _Part(self, tag, tone, groups) def verts(self, *names): out = [] for n in names: out.extend(self.groups.get(n, [])) return out class _Part: def __init__(self, b, tag, tone, groups): self.b, self.t, self.tone, self.g = b, tag, tone, groups def __enter__(self): self.nf = len(self.b.bm.faces) self.nv = len(self.b.bm.verts) return self def __exit__(self, *exc): bm = self.b.bm bm.faces.ensure_lookup_table() bm.verts.ensure_lookup_table() for i in range(self.nf, len(bm.faces)): bm.faces[i][self.b.tag] = self.t bm.faces[i][self.b.tone] = self.tone vs = [bm.verts[i] for i in range(self.nv, len(bm.verts))] for g in self.g: self.b.groups.setdefault(g, []).extend(vs) return False def _mark(faces, mat_idx): for f in faces: f.material_index = mat_idx return faces def frame(ez, ex_hint): """Rotation whose local Z is ``ez`` and local X is ``ex_hint`` made orthogonal to it (columns ex, ey, ez; right-handed).""" ez = Vector(ez).normalized() ex = Vector(ex_hint) ex = (ex - ez * ex.dot(ez)).normalized() ey = ez.cross(ex) return Matrix((ex, ey, ez)).transposed() def any_perp(v): v = Vector(v).normalized() return X if abs(v.x) < 0.9 else Y # (u, v, w) -> (y, z, x): a section in the (y, z) plane lofted along x ROT_X = Matrix(((0.0, 0.0, 1.0), (1.0, 0.0, 0.0), (0.0, 1.0, 0.0))) # (u, v, w) -> (x, z, y): a section in the (x, z) plane lofted along y ROT_Y = Matrix(((1.0, 0.0, 0.0), (0.0, 0.0, 1.0), (0.0, 1.0, 0.0))) I3 = Matrix.Identity(3) def add_lathe(bm, profile, segs, mat_idx, center=(0.0, 0.0, 0.0), rot=None, phase=0.0, solid=False): """Revolve a profile [(r, z), ...] about local Z. ``solid``: an open polyline closed by n-gon caps at its two ends; otherwise a closed polygon revolved into a ring shell.""" c = Vector(center) m = rot if rot is not None else Matrix.Identity(3) rings = [] for i in range(segs): a = phase + 2.0 * math.pi * i / segs ca, sa = math.cos(a), math.sin(a) rings.append([bm.verts.new(c + m @ Vector((r * ca, r * sa, z))) for r, z in profile]) n = len(profile) last = n - 1 if solid else n faces = [] for i in range(segs): r0, r1 = rings[i], rings[(i + 1) % segs] for j in range(last): k = (j + 1) % n faces.append(bm.faces.new((r0[j], r1[j], r1[k], r0[k]))) if solid: faces.append(bm.faces.new([rings[i][0] for i in reversed(range(segs))])) faces.append(bm.faces.new([rings[i][n - 1] for i in range(segs)])) _mark(faces, mat_idx) return [v for ring in rings for v in ring] def lathe_on(bm, profile, segs, mat_idx, center, axis, phase=0.0, solid=True, ref=None): axis = Vector(axis).normalized() return add_lathe(bm, profile, segs, mat_idx, center=center, rot=frame(axis, ref if ref is not None else any_perp(axis)), phase=phase, solid=solid) _HEX = [0] def add_hex(bm, center, axis, a, b, r, mat_idx, phase=0.0): """A hex head or nut on ``axis`` from a to b, its top edge chamfered. Every head is turned a further step: identical heads in a row would otherwise put their flats on shared planes.""" _HEX[0] += 1 ch = min(0.0010, 0.25 * (b - a)) return lathe_on(bm, [(r, a), (r, b - ch), (r * 0.86, b)], 6, mat_idx, center, axis, phase=phase + math.pi / 6.0 + 0.2113 * _HEX[0]) def add_rod(bm, center, axis, a, b, r, mat_idx, segs=12, ch=0.0): if ch > 0.0: prof = [(r - ch, a), (r, a + ch), (r, b - ch), (r - ch, b)] else: prof = [(r, a), (r, b)] return lathe_on(bm, prof, segs, mat_idx, center, axis) def add_tube(bm, pts, radius, sides, mat_idx, phase=0.0, ref=None): """Capped round bar swept along a polyline (parallel-transport frames).""" pts = [Vector(p) for p in pts] tans = [] for i in range(len(pts)): a = pts[max(i - 1, 0)] b = pts[min(i + 1, len(pts) - 1)] tans.append((b - a).normalized()) if ref is None: ref = Z if abs(tans[0].z) < 0.9 else X nrm = (Vector(ref) - tans[0] * Vector(ref).dot(tans[0])).normalized() rings = [] for p, t in zip(pts, tans): nrm = (nrm - t * nrm.dot(t)).normalized() bi = t.cross(nrm) rings.append([ bm.verts.new(p + radius * (nrm * math.cos(phase + 2.0 * math.pi * k / sides) + bi * math.sin(phase + 2.0 * math.pi * k / sides))) for k in range(sides) ]) faces = [] for r0, r1 in zip(rings, rings[1:]): for k in range(sides): m = (k + 1) % sides faces.append(bm.faces.new((r0[k], r0[m], r1[m], r1[k]))) faces.append(bm.faces.new(tuple(reversed(rings[0])))) faces.append(bm.faces.new(tuple(rings[-1]))) _mark(faces, mat_idx) return [v for ring in rings for v in ring] def rrect(ha, hb, rc, n_corner=4): """Rounded rectangle loop (counter-clockwise).""" rc = max(min(rc, ha - 1e-4, hb - 1e-4), 0.0006) pts = [] for k, (sx, sy) in enumerate(((1, 1), (-1, 1), (-1, -1), (1, -1))): cx, cy = sx * (ha - rc), sy * (hb - rc) a0 = 0.5 * math.pi * k for s in range(n_corner + 1): a = a0 + 0.5 * math.pi * s / n_corner pts.append((cx + rc * math.cos(a), cy + rc * math.sin(a))) return pts def add_rbox(bm, ha, hb, rc, profile, origin, rot, mat_idx, n_corner=4): """Loft of rounded rectangles along local Z: profile [(inset, z)], each loop inset from (ha, hb, rc); n-gon caps at both ends.""" o = Vector(origin) rings = [] for inset, z in profile: loop = rrect(ha - inset, hb - inset, rc - inset, n_corner) rings.append([bm.verts.new(o + rot @ Vector((x, y, z))) for x, y in loop]) n = len(rings[0]) faces = [] for r0, r1 in zip(rings, rings[1:]): for k in range(n): m = (k + 1) % n faces.append(bm.faces.new((r0[k], r0[m], r1[m], r1[k]))) faces.append(bm.faces.new(tuple(reversed(rings[0])))) faces.append(bm.faces.new(tuple(rings[-1]))) _mark(faces, mat_idx) return [v for ring in rings for v in ring] def chamfered(h, c): """A slab profile [(inset, z)] from 0 to h with chamfer c at both faces.""" return [(c, 0.0), (0.0, c), (0.0, h - c), (c, h)] def box(bm, lo, hi, mat_idx, rc=0.006, ch=0.002, n_corner=2): """An axis-aligned box from ``lo`` to ``hi``, rounded in plan, chamfered top and bottom.""" lo, hi = Vector(lo), Vector(hi) c = (lo + hi) * 0.5 return add_rbox(bm, 0.5 * (hi.x - lo.x), 0.5 * (hi.y - lo.y), rc, chamfered(hi.z - lo.z, ch), (c.x, c.y, lo.z), I3, mat_idx, n_corner) def add_prism(bm, outline, w0, w1, origin, rot, mat_idx, ch=0.0): """Planar outline [(u, v)] extruded along local Z from w0 to w1, both faces chamfered by ``ch`` (an inset ring) when it is non-zero.""" o = Vector(origin) if ch > 0.0: inner = inset_poly(outline, ch) layers = [(inner, w0), (outline, w0 + ch), (outline, w1 - ch), (inner, w1)] else: layers = [(outline, w0), (outline, w1)] rings = [[bm.verts.new(o + rot @ Vector((u, v, w))) for u, v in loop] for loop, w in layers] n = len(outline) faces = [] for r0, r1 in zip(rings, rings[1:]): for i in range(n): faces.append(bm.faces.new((r0[i], r0[(i + 1) % n], r1[(i + 1) % n], r1[i]))) faces.append(bm.faces.new(tuple(reversed(rings[0])))) faces.append(bm.faces.new(tuple(rings[-1]))) _mark(faces, mat_idx) return [v for ring in rings for v in ring] def add_plate(bm, outline, w0, w1, band, mat_idx): """A square-edged plate: the outline extruded from w0 to w1, each face carrying a band ring ``band`` inside its edge (the face stays flat).""" inner = inset_poly(outline, band) layers = [(inner, w0), (outline, w0), (outline, w1), (inner, w1)] rings = [[bm.verts.new(Vector((u, v, w))) for u, v in loop] for loop, w in layers] n = len(outline) faces = [] for r0, r1 in zip(rings, rings[1:]): for i in range(n): faces.append(bm.faces.new((r0[i], r0[(i + 1) % n], r1[(i + 1) % n], r1[i]))) faces.append(bm.faces.new(tuple(reversed(rings[0])))) faces.append(bm.faces.new(tuple(rings[-1]))) _mark(faces, mat_idx) return [v for ring in rings for v in ring] def poly_area(poly): n = len(poly) return 0.5 * sum(poly[i][0] * poly[(i + 1) % n][1] - poly[(i + 1) % n][0] * poly[i][1] for i in range(n)) def inset_poly(poly, d): """Every vertex of a (convex-ish) polygon moved ``d`` inward along its bisector.""" n = len(poly) sgn = 1.0 if poly_area(poly) > 0.0 else -1.0 out = [] for i in range(n): a, p, b = Vector(poly[i - 1]), Vector(poly[i]), Vector(poly[(i + 1) % n]) e0 = (p - a).normalized() e1 = (b - p).normalized() n0 = Vector((-e0.y, e0.x)) * sgn n1 = Vector((-e1.y, e1.x)) * sgn bis = (n0 + n1) if bis.length < 1e-9: bis = n0 bis.normalize() cosh = max(0.3, bis.dot(n0)) q = p + bis * (d / cosh) out.append((q.x, q.y)) return out def fillet_path(pts, rf, steps=4): pts = [Vector(p) for p in pts] out = [pts[0]] for i in range(1, len(pts) - 1): a, p, b = pts[i - 1], pts[i], pts[i + 1] r = min(rf, (a - p).length * 0.45, (b - p).length * 0.45) p0 = p + (a - p).normalized() * r p1 = p + (b - p).normalized() * r for k in range(steps + 1): t = k / steps out.append((1 - t) ** 2 * p0 + 2 * (1 - t) * t * p + t * t * p1) out.append(pts[-1]) return out def add_bar(bm, pts, wax, half_w, half_t, rc, mat_idx, fillet=0.008, filleted=False): """Flat bar: its width lies along ``wax``, its thickness across it; rounded-rectangle section.""" pts = [Vector(p) for p in pts] if filleted else fillet_path(pts, fillet) wax = Vector(wax).normalized() sec = rrect(half_w, half_t, rc, 2) rings = [] for i, p in enumerate(pts): a = pts[max(i - 1, 0)] b = pts[min(i + 1, len(pts) - 1)] t = (b - a).normalized() w = (wax - t * wax.dot(t)).normalized() th = t.cross(w) rings.append([bm.verts.new(p + w * x + th * y) for x, y in sec]) n = len(sec) faces = [] for r0, r1 in zip(rings, rings[1:]): for k in range(n): m = (k + 1) % n faces.append(bm.faces.new((r0[k], r0[m], r1[m], r1[k]))) faces.append(bm.faces.new(tuple(reversed(rings[0])))) faces.append(bm.faces.new(tuple(rings[-1]))) _mark(faces, mat_idx) return [v for ring in rings for v in ring] def add_loft(bm, loops, mat_idx): """Closed loops [[Vector]] of equal length lofted in order, n-gon caps.""" rings = [[bm.verts.new(p) for p in loop] for loop in loops] n = len(rings[0]) faces = [] for r0, r1 in zip(rings, rings[1:]): for j in range(n): m = (j + 1) % n faces.append(bm.faces.new((r0[j], r0[m], r1[m], r1[j]))) faces.append(bm.faces.new(tuple(reversed(rings[0])))) faces.append(bm.faces.new(tuple(rings[-1]))) _mark(faces, mat_idx) return [v for ring in rings for v in ring] def add_ring_loft(bm, loops, mat_idx): """Closed loops lofted in order and the last joined back to the first: a hollow tube (a shell with a wall), no caps.""" rings = [[bm.verts.new(p) for p in loop] for loop in loops] n = len(rings[0]) faces = [] for i in range(len(rings)): r0, r1 = rings[i], rings[(i + 1) % len(rings)] for j in range(n): m = (j + 1) % n faces.append(bm.faces.new((r0[j], r0[m], r1[m], r1[j]))) _mark(faces, mat_idx) return [v for ring in rings for v in ring] def add_sheet(bm, surf, nu, nv, thick, mat_idx): """A closed plate: ``surf(u, v) -> (point, normal)`` over the unit square, offset ``thick`` back along the normal, rims stitched round the edge.""" front, back = [], [] for j in range(nv + 1): rf, rb = [], [] for i in range(nu + 1): p, n = surf(i / nu, j / nv) rf.append(bm.verts.new(p)) rb.append(bm.verts.new(p - n * thick)) front.append(rf) back.append(rb) faces = [] for j in range(nv): for i in range(nu): faces.append(bm.faces.new((front[j][i], front[j][i + 1], front[j + 1][i + 1], front[j + 1][i]))) faces.append(bm.faces.new((back[j][i], back[j + 1][i], back[j + 1][i + 1], back[j][i + 1]))) rim = ([(0, i) for i in range(nu + 1)] + [(j, nu) for j in range(1, nv + 1)] + [(nv, i) for i in reversed(range(nu))] + [(j, 0) for j in reversed(range(1, nv))]) for k in range(len(rim)): (ja, ia), (jb, ib) = rim[k], rim[(k + 1) % len(rim)] faces.append(bm.faces.new((front[jb][ib], front[ja][ia], back[ja][ia], back[jb][ib]))) _mark(faces, mat_idx) return [v for row in front + back for v in row] def thick_profile(pts, t): """A polyline [(r, w)] thickened by ``t`` about its centre line into a closed polygon (for a pressed disc or a pan).""" n = len(pts) up, dn = [], [] for i in range(n): a = pts[max(i - 1, 0)] b = pts[min(i + 1, n - 1)] dr, dw = b[0] - a[0], b[1] - a[1] ln = math.hypot(dr, dw) nr, nw = -dw / ln, dr / ln up.append((pts[i][0] + nr * t * 0.5, pts[i][1] + nw * t * 0.5)) dn.append((pts[i][0] - nr * t * 0.5, pts[i][1] - nw * t * 0.5)) return up + list(reversed(dn)) def hull2d(pts): """Convex hull, counter-clockwise (monotone chain).""" pts = sorted(set((round(x, 9), round(z, 9)) for x, z in pts)) def cross(o, a, b): return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]) lower, upper = [], [] for p in pts: while len(lower) >= 2 and cross(lower[-2], lower[-1], p) <= 1e-12: lower.pop() lower.append(p) for p in reversed(pts): while len(upper) >= 2 and cross(upper[-2], upper[-1], p) <= 1e-12: upper.pop() upper.append(p) return lower[:-1] + upper[:-1] def circles_hull(circles, n=16): pts = [] for u, v, r in circles: for k in range(n): a = 2.0 * math.pi * (k + 0.5) / n pts.append((u + r * math.cos(a), v + r * math.sin(a))) return hull2d(pts) def triangulate_ngons(bm): faces = [f for f in bm.faces if len(f.verts) > 4] if faces: bmesh.ops.triangulate(bm, faces=faces) def pack_uvs(bm, margin=0.08): uv = bm.loops.layers.uv.new("UVMap") faces = list(bm.faces) n = len(faces) cols = max(1, math.ceil(math.sqrt(n))) rows = max(1, math.ceil(n / cols)) cell_w = 1.0 / cols cell_h = 1.0 / rows pad_u = margin * cell_w * 0.5 pad_v = margin * cell_h * 0.5 usable_w = cell_w - 2.0 * pad_u usable_h = cell_h - 2.0 * pad_v for i, face in enumerate(faces): col = i % cols row = i // cols nrm = face.normal ax, ay, az = abs(nrm.x), abs(nrm.y), abs(nrm.z) coords = [] for loop in face.loops: co = loop.vert.co if az >= ax and az >= ay: coords.append((co.x, co.y)) elif ax >= ay: coords.append((co.y, co.z)) else: coords.append((co.x, co.z)) xs = [c[0] for c in coords] ys = [c[1] for c in coords] minx, maxx = min(xs), max(xs) miny, maxy = min(ys), max(ys) dx = max(maxx - minx, 1e-8) dy = max(maxy - miny, 1e-8) origin_u = col * cell_w + pad_u origin_v = row * cell_h + pad_v for loop, (x, y) in zip(face.loops, coords): loop[uv].uv = ( origin_u + (x - minx) / dx * usable_w, origin_v + (y - miny) / dy * usable_h, ) def resample(pts, n): """``n`` points evenly spaced by arc length along a polyline.""" pts = [Vector(p) for p in pts] lens = [(pts[i + 1] - pts[i]).length for i in range(len(pts) - 1)] total = sum(lens) out = [] for k in range(n): s = total * k / (n - 1) acc = 0.0 for i, ln in enumerate(lens): if acc + ln >= s - 1e-12 or i == len(lens) - 1: t = 0.0 if ln < 1e-12 else min(1.0, max(0.0, (s - acc) / ln)) out.append(pts[i] + (pts[i + 1] - pts[i]) * t) break acc += ln return out def body_section(hw, zb, zt, rt, rb, crown, n, top_n=8): """A closed counter-clockwise loop in (y, z): top corners of radius ``rt``, bottom corners of radius ``rb``, the top crowned by an even term so the section is mirror-symmetric in y.""" rt = max(rt, 0.0006) rb = max(rb, 0.0006) flat = hw - rt pts = [] for k in range(n + 1): a = 0.5 * math.pi * k / n pts.append((flat + rt * math.cos(a), zt - rt + rt * math.sin(a))) for k in range(1, top_n): y = flat - 2.0 * flat * k / top_n pts.append((y, zt + crown * (1.0 - (y / flat) ** 2))) for k in range(n + 1): a = 0.5 * math.pi + 0.5 * math.pi * k / n pts.append((-flat + rt * math.cos(a), zt - rt + rt * math.sin(a))) for k in range(n + 1): a = math.pi + 0.5 * math.pi * k / n pts.append((-hw + rb + rb * math.cos(a), zb + rb + rb * math.sin(a))) for k in range(n + 1): a = 1.5 * math.pi + 0.5 * math.pi * k / n pts.append((hw - rb + rb * math.cos(a), zb + rb + rb * math.sin(a))) return pts def top_z(hw, zt, rt, crown, y): """The top boundary of ``body_section`` at ``y``.""" flat = hw - rt ay = abs(y) if ay <= flat: return zt + crown * (1.0 - (y / flat) ** 2) return zt - rt + math.sqrt(max(rt * rt - (ay - flat) ** 2, 0.0)) # -------------------------------------------------------------------------- # The farmyard patch # -------------------------------------------------------------------------- def wob(th): return (1.0 + 0.030 * math.cos(3 * th + 0.7) + 0.022 * math.cos(5 * th + 2.1) + 0.012 * math.cos(7 * th + 0.3)) def patch_m(x, y): lx = (x - PATCH_C[0]) / PATCH_A ly = (y - PATCH_C[1]) / PATCH_B th = math.atan2(ly, lx) return (lx ** 4 + ly ** 4) ** 0.25 / wob(th) def patch_point(u, v): """Grid (u, v) in [-1, 1]^2 onto the wobbled superellipse: each square ring of the grid lands on one superellipse ring (after planet-rover).""" m = max(abs(u), abs(v)) if m < 1e-12: return PATCH_C[0], PATCH_C[1] n4 = (u ** 4 + v ** 4) ** 0.25 px, py = u * m / n4, v * m / n4 w = wob(math.atan2(py, px)) return PATCH_C[0] + PATCH_A * px * w, PATCH_C[1] + PATCH_B * py * w def soil_low(x, y): return (0.009 * math.cos(1.3 * x + 0.4) * math.cos(1.6 * y - 0.7) + 0.006 * math.sin(2.9 * x - 1.7 * y + 0.5)) def soil_fine(x, y): return (0.0040 * math.cos(7.3 * x + 4.1 * y + 1.3) + 0.0030 * math.sin(9.9 * y - 6.7 * x + 1.1) + 0.0018 * math.cos(15.1 * x - 12.3 * y)) def floor_fine(x, y): return (0.0009 * math.cos(11.0 * x + 0.7) * math.cos(9.0 * y) + 0.0006 * math.sin(17.0 * x - 3.0 * y)) def rut_mask(y): d = abs(abs(y) - RUT_Y) return 1.0 - smoothstep(RUT_FLAT, RUT_FLAT + RUT_WALL, d), d def puddle_wob(th): return 1.0 + 0.12 * math.cos(2.0 * th + 0.5) + 0.07 * math.cos(3.0 * th + 1.3) def puddle_r2(x, y): """The dip's elliptical radius, squared, pulled out of round by a closed-form wobble so the waterline is not an ellipse.""" px, py, ax, ay = PUDDLE u, v = (x - px) / ax, (y - py) / ay return (u * u + v * v) / puddle_wob(math.atan2(v, u)) ** 2 def soil_top(x, y): """The mud: a closed-form field, two ruts pressed in along the rear track with a compacted, level floor (the tractor's z origin), berms of squeezed-out mud either side, and a dip in the right rut that holds a puddle.""" m, d = rut_mask(y) field = SOIL_T + soil_low(x, y) + soil_fine(x, y) floor = Z0 + floor_fine(x, y) berm = BERM_H * math.exp(-((d - BERM_D) / BERM_W) ** 2) * (1.0 - m) z = field * (1.0 - m) + floor * m + berm r2 = puddle_r2(x, y) if r2 < 1.0: z -= PUD_D * (1.0 - r2) ** 2 return z - EDGE_DROP * smoothstep(0.80, 1.0, patch_m(x, y)) ** 1.5 def track_warp(n): """n + 1 grid values over [-1, 1], closer together across the two ruts.""" vt = RUT_Y / PATCH_B k = 4000 xs = [-1.0 + 2.0 * i / k for i in range(k + 1)] dens = [1.0 + 1.2 * math.exp(-((abs(x) - vt) / 0.22) ** 2) for x in xs] cum = [0.0] for i in range(1, k + 1): cum.append(cum[-1] + 0.5 * (dens[i] + dens[i - 1]) * (xs[i] - xs[i - 1])) out, i = [], 0 for j in range(n + 1): target = cum[-1] * j / n while i < k - 1 and cum[i + 1] < target: i += 1 t = (target - cum[i]) / max(cum[i + 1] - cum[i], 1e-12) out.append(xs[i] + (xs[i + 1] - xs[i]) * min(max(t, 0.0), 1.0)) out[0], out[-1] = -1.0, 1.0 return out def build_soil(b): bm = b.bm nu, nv = TERRAIN_NU, TERRAIN_NV vs = track_warp(nv) with b.part(T_SOIL, 0.5): grid = [] for j in range(nv + 1): row = [] for i in range(nu + 1): x, y = patch_point(-1.0 + 2.0 * i / nu, vs[j]) row.append(bm.verts.new((x, y, soil_top(x, y)))) grid.append(row) faces = [] for j in range(nv): for i in range(nu): faces.append(bm.faces.new((grid[j][i], grid[j][i + 1], grid[j + 1][i + 1], grid[j + 1][i]))) ring = ([grid[0][i] for i in range(nu + 1)] + [grid[j][nu] for j in range(1, nv + 1)] + [grid[nv][i] for i in reversed(range(nu))] + [grid[j][0] for j in reversed(range(1, nv))]) # a rolled skirt down to the floor rings = [ring] for push, frac in ((0.024, 0.45), (0.030, 0.0)): nr = [] for v in ring: d = Vector((v.co.x - PATCH_C[0], v.co.y - PATCH_C[1], 0.0)).normalized() nr.append(bm.verts.new((v.co.x + d.x * push, v.co.y + d.y * push, v.co.z * frac))) rings.append(nr) n = len(ring) for r0, r1 in zip(rings, rings[1:]): for k in range(n): m = (k + 1) % n faces.append(bm.faces.new((r0[k], r0[m], r1[m], r1[k]))) cen = bm.verts.new((PATCH_C[0], PATCH_C[1], 0.0)) last = rings[-1] for k in range(n): faces.append(bm.faces.new((cen, last[(k + 1) % n], last[k]))) _mark(faces, SOIL_IDX) # rut floor shading uses the triangulation's own diagonals bmesh.ops.triangulate(bm, faces=[f for f in faces if len(f.verts) == 4], quad_method="SHORT_EDGE") def build_water(b): """The puddle: a thin slab whose rim lies buried in the dip's walls.""" px, py, ax, ay = PUDDLE wl = Z0 - PUD_WL * PUD_D outline = [] for k in range(48): th = 2.0 * math.pi * k / 48 w = PUD_RIM * puddle_wob(th) outline.append((px + w * ax * math.cos(th), py + w * ay * math.sin(th))) with b.part(T_WATER, 0.5): add_prism(b.bm, outline, wl - 0.003, wl, (0.0, 0.0, 0.0), I3, WATER_IDX) # -------------------------------------------------------------------------- # Backbone: rear axle centre housing, trumpets, gearbox, engine # -------------------------------------------------------------------------- TRUMPET = ((0.130, 0.170), (0.140, 0.182), (0.140, 0.212), (0.112, 0.222), (0.096, 0.300), (0.084, 0.400), (0.076, 0.470), (0.092, 0.474), (0.098, 0.480), (0.098, 0.496), (0.064, 0.502)) def trumpet_r(w): pts = TRUMPET[3:8] for (r0, w0), (r1, w1) in zip(pts, pts[1:]): if w0 <= w <= w1: return r0 + (r1 - r0) * (w - w0) / (w1 - w0) return pts[-1][0] _AXL = [0] def axle_lathe(bm, profile, segs, mat, s, solid=True): """A lathe on the rear axle's line; profile (r, w) with w = |y|. Every part is turned a further, non-commensurate step: equal radii on one axle would otherwise share their facet planes.""" _AXL[0] += 1 return lathe_on(bm, profile, segs, mat, Vector((AX_R, 0.0, Z_AR)), Y * s, phase=0.0371 * _AXL[0], solid=solid, ref=X) def build_rear_axle(b, flags): bm = b.bm with b.part(T_NONE, 0.45, "tr"): add_rbox(bm, 0.200, 0.225, 0.090, [(0.020, 0.0), (0.0, 0.020), (0.0, 0.380), (0.020, 0.400)], (AX_R, -0.200, 0.615), ROT_Y, IRON_IDX, n_corner=4) # rear cover plate with its bolts with b.part(T_NONE, 0.55, "tr"): add_rbox(bm, 0.160, 0.130, 0.050, chamfered(0.020, 0.004), (AX_R - 0.195, 0.0, 0.600), frame(-X, Z), IRON_IDX, n_corner=3) for k in range(8): a = 2.0 * math.pi * k / 8 + 0.2 p = Vector((AX_R - 0.211, 0.140 * math.cos(a), 0.600 + 0.110 * math.sin(a))) with b.part(T_NONE, 0.5, "tr"): add_hex(bm, p, -X, -0.0008, 0.008, 0.009, STEEL_IDX, phase=a) # hydraulic lift cover on top with b.part(T_NONE, 0.60, "tr"): add_rbox(bm, 0.150, 0.170, 0.040, [(0.0, 0.0), (0.0, 0.070), (0.012, 0.085)], (AX_R + 0.020, 0.0, 0.820), I3, IRON_IDX, n_corner=3) for s in (-1.0, 1.0): side = "L" if s > 0 else "R" with b.part(T_NONE, 0.40 if s > 0 else 0.50, "tr"): axle_lathe(bm, TRUMPET, 40, IRON_IDX, s) for k in range(8): a = 2.0 * math.pi * k / 8 + 0.3 p = Vector((AX_R + 0.125 * math.cos(a), s * 0.212, Z_AR + 0.125 * math.sin(a))) with b.part(T_NONE, 0.5, "tr"): add_hex(bm, p, Y * s, -0.0008, 0.0080, 0.0095, STEEL_IDX, phase=a) with b.part(T_AXLE, 0.5, "tr"): axle_lathe(bm, [(0.038, 0.488), (0.040, 0.490), (0.040, 0.638), (0.038, 0.640)], 24, STEEL_IDX, s) gh = "hub" + side with b.part(T_HUB_R, 0.5, "tr", gh): axle_lathe(bm, [(0.060, 0.507), (0.068, 0.512), (0.068, 0.585), (0.124, 0.588), (0.128, 0.592), (0.128, 0.600), (0.056, 0.6035)], 40, IRON_IDX, s) with b.part(T_NONE, 0.5, "tr", gh): axle_lathe(bm, [(0.050, 0.598), (0.050, 0.648), (0.046, 0.655), (0.030, 0.658)], 24, IRON_IDX, s) if s > 0 and flags["cock_hub"]: for v in b.groups[gh]: v.co.z += COCK_HUB # brake drum housing on the trumpet's inner end with b.part(T_NONE, 0.55, "tr"): axle_lathe(bm, [(0.100, 0.232), (0.150, 0.236), (0.156, 0.244), (0.156, 0.300), (0.148, 0.306), (0.098, 0.310)], 40, IRON_IDX, s) # fender stays from the trumpet up to the mudguard's inner edge for th in (18.0, -42.0): t = math.radians(th) top = Vector((AX_R + (R_F - 0.001) * math.sin(t), s * 0.490, Z_AR + (R_F - 0.001) * math.cos(t))) foot = Vector((AX_R + 0.040 * math.sin(t), s * 0.430, Z_AR + 0.060)) d = (top - foot).normalized() with b.part(T_NONE, 0.5, "tr"): add_bar(bm, [foot, foot + d * 0.12, top], d.cross(Y).normalized(), 0.016, 0.0045, 0.002, STEEL_IDX, fillet=0.04) def build_gearbox_engine(b): bm = b.bm # gearbox between the centre housing and the bell housing with b.part(T_NONE, 0.50, "tr"): add_rbox(bm, 0.165, 0.160, 0.050, chamfered(0.650, 0.012), (-0.720, 0.0, 0.560), ROT_X, IRON_IDX, n_corner=4) # top cover with the gear lever's turret with b.part(T_NONE, 0.62, "tr"): box(bm, (-0.470, -0.110, 0.705), (-0.200, 0.110, 0.738), IRON_IDX, rc=0.018, ch=0.004) for k, (dx, dy) in enumerate(((-0.455, -0.095), (-0.455, 0.095), (-0.215, -0.095), (-0.215, 0.095))): with b.part(T_NONE, 0.5, "tr"): add_hex(bm, Vector((dx, dy, 0.0)), Z, 0.7372, 0.7442 + 0.0002 * k, 0.0075, STEEL_IDX) with b.part(T_NONE, 0.5, "tr"): lathe_on(bm, [(0.040, 0.730), (0.040, 0.752), (0.030, 0.768), (0.020, 0.772)], 20, IRON_IDX, Vector((-0.330, 0.0, 0.0)), Z) # bell housing, flanged to the block with b.part(T_NONE, 0.45, "tr"): add_rbox(bm, 0.175, 0.175, 0.070, [(0.012, 0.0), (0.0, 0.012), (0.0, 0.200), (-0.008, 0.205), (-0.008, 0.222), (0.004, 0.230)], (-0.100, 0.0, 0.600), ROT_X, IRON_IDX, n_corner=4) # block, head, rocker cover, sump, timing cover with b.part(T_NONE, 0.55, "tr"): add_rbox(bm, 0.150, 0.170, 0.030, [(0.006, 0.0), (0.0, 0.006), (0.0, 0.504), (0.006, 0.510)], (0.110, 0.0, 0.630), ROT_X, IRON_IDX, n_corner=3) with b.part(T_NONE, 0.40, "tr"): add_rbox(bm, 0.135, 0.050, 0.015, chamfered(0.4665, 0.004), (0.1335, 0.0, 0.846), ROT_X, IRON_IDX, n_corner=3) with b.part(T_NONE, 0.35, "tr"): add_rbox(bm, 0.105, 0.036, 0.022, chamfered(0.410, 0.008), (0.160, 0.0, 0.916), ROT_X, IRON_IDX, n_corner=3) with b.part(T_NONE, 0.65, "tr"): add_rbox(bm, 0.125, 0.060, 0.030, [(0.010, 0.0), (0.0, 0.010), (0.0, 0.420), (0.010, 0.430)], (0.150, 0.0, 0.420), ROT_X, IRON_IDX, n_corner=3) with b.part(T_NONE, 0.60, "tr"): add_rbox(bm, 0.142, 0.009, 0.004, chamfered(0.450, 0.002), (0.140, 0.0, 0.4705), ROT_X, IRON_IDX, n_corner=2) with b.part(T_NONE, 0.5, "tr"): add_hex(bm, Vector((0.300, 0.0, 0.0)), -Z, -0.3605, -0.3480, 0.012, STEEL_IDX) with b.part(T_NONE, 0.45, "tr"): add_rbox(bm, 0.130, 0.170, 0.060, chamfered(0.040, 0.006), (0.615, 0.0, 0.600), ROT_X, IRON_IDX, n_corner=3) # core plugs and head bolts along the block's right side for k in range(3): with b.part(T_NONE, 0.5, "tr"): lathe_on(bm, [(0.022, -0.004), (0.022, 0.003), (0.018, 0.0045)], 20, STEEL_IDX, Vector((0.220 + 0.150 * k, -0.150, 0.600)), -Y) # steering box on the bell housing's top with b.part(T_NONE, 0.40, "tr"): box(bm, (0.020, -0.055, 0.700), (0.140, 0.055, 0.800), IRON_IDX, rc=0.020, ch=0.006) def build_front_support(b, flags): """The front support (bolster) bolted under the radiator, its two lugs, the pivot pin through them and the axle beam's boss with its two bushings; the beam, the kingpin bosses, knuckles, kingpins, spindles and steering arms.""" bm = b.bm with b.part(T_NONE, 0.50, "tr"): box(bm, (0.700, -0.125, BOL_Z0), (1.060, 0.125, BOL_Z0 + 0.065), IRON_IDX, rc=0.025, ch=0.008) for s in (-1.0, 1.0): with b.part(T_NONE, 0.50, "tr"): box(bm, (0.520, s * 0.130 - 0.024, 0.470), (0.745, s * 0.130 + 0.024, BOL_Z0 + 0.045), IRON_IDX, rc=0.012, ch=0.004) for k, x in enumerate((AX_F - 0.105, AX_F + 0.105)): lug = circles_hull([(0.0, PIN_Z, 0.038), (-0.070, BOL_Z0 + 0.020, 0.012), (0.070, BOL_Z0 + 0.020, 0.012)], 24) with b.part(T_NONE, 0.5, "tr"): add_prism(bm, lug, x - 0.014, x + 0.014, (0.0, 0.0, 0.0), ROT_X, IRON_IDX, ch=0.002) # the pin and its bushings (moved together by --offset-pivot) pc = Vector((AX_F, -PIVOT_OFF if flags["offset_pivot"] else 0.0, PIN_Z)) pin_ax = X if flags["cant_pin"]: pin_ax = Matrix.Rotation(math.radians(CANT_PIN), 3, "Z") @ X with b.part(T_PIVOT, 0.5, "tr"): lathe_on(bm, [(PIN_R - 0.0015, -0.150), (PIN_R, -0.1485), (PIN_R, 0.1485), (PIN_R - 0.0015, 0.150)], 20, STEEL_IDX, pc, pin_ax, ref=Z) with b.part(T_NONE, 0.5, "tr"): add_hex(bm, pc, pin_ax, 0.1180, 0.1310, 0.0270, STEEL_IDX) with b.part(T_NONE, 0.5, "tr"): add_hex(bm, pc, -pin_ax, 0.1180, 0.1305, 0.0265, STEEL_IDX) # the bushings sit in the boss on its own axis; --cant-pin turns the pin alone for sgn in (1.0, -1.0): with b.part(T_BUSH, 0.5, "tr"): lathe_on(bm, [(PIN_R - 0.0005, 0.060), (0.031, 0.060), (0.031, 0.0758), (0.037, 0.0763), (0.037, 0.0810), (PIN_R - 0.0005, 0.0810)], 24, STEEL_IDX, pc, X * sgn, solid=False, ref=Z) # the beam's boss, and the beam bc = Vector((AX_F, 0.0, PIN_Z)) with b.part(T_NONE, 0.40, "tr"): lathe_on(bm, [(0.040, -0.075), (0.045, -0.070), (0.045, 0.070), (0.040, 0.075)], 28, IRON_IDX, bc, X, ref=Z) with b.part(T_NONE, 0.40, "tr"): add_rbox(bm, 0.030, 0.035, 0.012, chamfered(2.0 * (KP_Y - 0.005), 0.006), (AX_F, -(KP_Y - 0.005), BEAM_Z), ROT_Y, IRON_IDX, n_corner=3) ends = {} for s in (-1.0, 1.0): K = Vector((AX_F, s * KP_Y, 0.0)) with b.part(T_NONE, 0.45, "tr"): lathe_on(bm, [(0.035, Z_AF + 0.036), (0.037, Z_AF + 0.039), (0.037, Z_AF + 0.122), (0.035, Z_AF + 0.125)], 20, IRON_IDX, K, Z) with b.part(T_NONE, 0.5, "tr"): lathe_on(bm, [(0.031, Z_AF + 0.029), (0.031, Z_AF + 0.037)], 20, STEEL_IDX, K, Z) with b.part(T_NONE, 0.55, "tr"): lathe_on(bm, [(0.034, Z_AF - 0.050), (0.036, Z_AF - 0.047), (0.036, Z_AF + 0.027), (0.034, Z_AF + 0.030)], 20, IRON_IDX, K, Z) with b.part(T_NONE, 0.5, "tr"): add_rod(bm, K, Z, Z_AF - 0.062, Z_AF + 0.134, 0.015, STEEL_IDX, segs=12, ch=0.002) with b.part(T_NONE, 0.5, "tr"): add_hex(bm, K, -Z, -(Z_AF - 0.0495), -(Z_AF - 0.0610), 0.0230, STEEL_IDX) with b.part(T_NONE, 0.5, "tr"): lathe_on(bm, [(0.022, Z_AF + 0.124), (0.022, Z_AF + 0.133), (0.016, Z_AF + 0.140), (0.006, Z_AF + 0.142)], 14, ZINC_IDX, K, Z) with b.part(T_STUB, 0.5, "tr"): lathe_on(bm, [(0.028, KP_Y), (0.028, 0.560), (0.024, 0.565), (0.024, 0.655), (0.020, 0.660)], 20, STEEL_IDX, Vector((AX_F, 0.0, Z_AF)), Y * s, ref=X) with b.part(T_HUB_F, 0.5, "tr"): lathe_on(bm, [(0.040, 0.557), (0.046, 0.561), (0.046, 0.592), (0.084, 0.594), (0.088, 0.598), (0.088, 0.6035), (0.036, 0.6055)], 32, IRON_IDX, Vector((AX_F, 0.0, Z_AF)), Y * s, ref=X) with b.part(T_NONE, 0.5, "tr"): lathe_on(bm, [(0.034, 0.600), (0.034, 0.648), (0.030, 0.660), (0.016, 0.664)], 20, ZINC_IDX, Vector((AX_F, 0.0, Z_AF)), Y * s, ref=X) # steering arm back from the knuckle, flat, its eye on a vertical pin za = Z_AF - 0.025 root = Vector((AX_F, s * KP_Y, za)) E = Vector((AX_F - 0.150, s * 0.470, za)) dirn = (E - root).normalized() wax = Vector((-dirn.y, dirn.x, 0.0)) with b.part(T_NONE, 0.5, "tr"): add_bar(bm, [root, E + dirn * 0.016], wax, 0.016, 0.007, 0.003, STEEL_IDX) ends[s] = E # tie rod: eyes on vertical pins at the arms' ends zc = Z_AF - 0.018 + 0.007 for s in (-1.0, 1.0): E = ends[s] with b.part(T_EYE, 0.5, "tr"): lathe_on(bm, [(0.015, Z_AF - 0.0185), (0.018, Z_AF - 0.0160), (0.018, Z_AF - 0.0070), (0.015, Z_AF - 0.0045)], 16, STEEL_IDX, Vector((E.x, E.y, 0.0)), Z) with b.part(T_PIN, 0.5, "tr"): lathe_on(bm, [(0.0070, Z_AF - 0.040), (0.0070, Z_AF + 0.004)], 10, STEEL_IDX, Vector((E.x, E.y, 0.0)), Z) with b.part(T_NONE, 0.5, "tr"): add_hex(bm, Vector((E.x, E.y, 0.0)), Z, Z_AF - 0.0050, Z_AF + 0.0015, 0.0110, STEEL_IDX) with b.part(T_NONE, 0.5, "tr"): add_hex(bm, Vector((E.x, E.y, 0.0)), -Z, -(Z_AF - 0.0315), -(Z_AF - 0.0385), 0.0110, STEEL_IDX) a, bb = Vector((ends[1.0].x, ends[1.0].y, zc)), Vector((ends[-1.0].x, ends[-1.0].y, zc)) with b.part(T_NONE, 0.5, "tr"): add_tube(bm, [a + (bb - a).normalized() * 0.006, bb - (bb - a).normalized() * 0.006], 0.011, 12, STEEL_IDX) for p, sg in ((a, 1.0), (bb, -1.0)): with b.part(T_NONE, 0.5, "tr"): add_hex(bm, p + (bb - a).normalized() * sg * 0.060, (bb - a).normalized(), -0.005, 0.005, 0.0140, STEEL_IDX) # -------------------------------------------------------------------------- # Wheels # -------------------------------------------------------------------------- def _crown_normal(i): """The carcass profile's outward normal (in w, r) at REAR_CROWN vertex i: the mean of its two segments' normals.""" ns = [] for j0, j1 in ((i - 1, i), (i, i + 1)): if 0 <= j0 and j1 < len(REAR_CROWN): (w0, r0), (w1, r1) = REAR_CROWN[j0], REAR_CROWN[j1] d = math.hypot(w1 - w0, r1 - r0) ns.append(((r0 - r1) / d, (w1 - w0) / d)) nw, nr = sum(n[0] for n in ns), sum(n[1] for n in ns) d = math.hypot(nw, nr) return nw / d, nr / d def lug_stations(): """The bar's stations over the carcass: (w, r) on the profile and its outward normal there, from over the centre line round the shoulder and down the sidewall to LUG_R_END.""" out = [] for w in LUG_ST: aw = abs(w) for i, ((w0, r0), (w1, r1)) in enumerate(zip(REAR_CROWN, REAR_CROWN[1:])): if aw <= w1 + 1e-9: f = (aw - w0) / (w1 - w0) r = r0 + (r1 - r0) * f if f > 1.0 - 1e-6: n = _crown_normal(i + 1) elif f < 1e-6: n = _crown_normal(i) else: d = math.hypot(w1 - w0, r1 - r0) n = ((r0 - r1) / d, (w1 - w0) / d) out.append((w, r, n[0] if w >= 0.0 else -n[0], n[1])) break (w0, r0), (w1, r1) = REAR_CROWN[-2], REAR_CROWN[-1] f = (r0 - LUG_R_END) / (r0 - r1) d = math.hypot(w1 - w0, r1 - r0) out.append((w0 + (w1 - w0) * f, LUG_R_END, (r0 - r1) / d, (w1 - w0) / d)) return out def rear_tyre_profile(bite): rim = RIM_R rs = rim["RS"] - bite rfl, wf = rim["RFL"], rim["Wf"] half = [(r, w) for w, r in REAR_CROWN[1:]] + [(0.4600, 0.1400), (0.4250, 0.1345), (0.4000, 0.1320), (rfl + 0.008, 0.1285), (rfl + 0.0015, wf - 0.0006), (rim["RS"] + 0.003, wf - 0.0012), (rs, wf - 0.004), (rs, wf - 0.020)] return half + [(r, -w) for r, w in reversed(half)] def front_tyre_profile(bite): rim = RIM_F rs = rim["RS"] - bite rfl, wf = rim["RFL"], rim["Wf"] half = [(0.3450, 0.006), (0.3440, 0.0085), (0.3340, 0.0105), (0.3335, 0.0145), (0.3410, 0.0165), (0.3420, 0.0290), (0.3395, 0.0350), (0.3330, 0.0415), (0.3210, 0.0475), (0.3020, 0.0520), (0.2820, 0.0525), (0.2700, 0.0480), (rfl + 0.004, 0.0440), (rfl + 0.0012, wf - 0.0006), (rim["RS"] + 0.002, wf - 0.0010), (rs, wf - 0.004), (rs, wf - 0.012)] return half + [(r, -w) for r, w in reversed(half)] def rim_ring_profile(rim): RS, RFL, RD, t = rim["RS"], rim["RFL"], rim["RD"], rim["t"] Wf, Wo, ww = rim["Wf"], rim["Wo"], rim["well"] plus = [(RFL - 0.002, Wo), (RFL, Wo - 0.002), (RFL, Wf + 0.001), (RFL - 0.0015, Wf), (RS + 0.003, Wf), (RS, Wf - 0.003), (RS, ww + 0.012), (RS - 0.006, ww + 0.004), (RD, ww)] outer = plus + [(r, -w) for r, w in reversed(plus)] inner = [(RS - t, -Wo), (RS - t, -(ww + 0.014)), (RD - t, -(ww - 0.002)), (RD - t, ww - 0.002), (RS - t, ww + 0.014), (RS - t, Wo)] return outer + inner def lug_theta(n, h): """The bar's apex (centre-end) angle round the spin axis: the halves are staggered by half a pitch.""" pitch = 2.0 * math.pi / LUGS_PER_HALF phase = math.pi + LUG_SWEEP * (0.85 * 0.20 + 0.15 * 0.04) return phase + (n + (0.5 if h < 0 else 0.0)) * pitch def build_lugs(b, C, spin, groups, lift=0.0, tall=0.0, bunch=False): """Chevron bars on a rear tyre. The spin axis is the world's +Y for both tyres: forward travel turns both wheels the same way. Each bar runs from its apex over the centre line back round the tyre to the shoulder, so the apex meets the ground first. ``spin`` -1 builds the tread about the wheel's own outboard axis instead, which on the right-hand wheel turns the tyre round. Past the sweep the bar runs round the shoulder and down the sidewall, each section set out along the carcass normal, so the bars' ends stand as a ring of teeth round the tyre seen from the side.""" bm = b.bm stations = lug_stations() w_sh = REAR_CROWN[4][0] for h in (1.0, -1.0): for n in range(LUGS_PER_HALF): th_a = lug_theta(n, h) if bunch and h < 0 and n % 4 == 1: th_a -= math.radians(BUNCH_DEG) rings = [] for k, (ws, rc, nw, nr) in enumerate(stations): t = min(1.0, (ws + LUG_X) / (LUG_END + LUG_X)) u = 1.0 if k == len(stations) - 1 else (smoothstep(w_sh, LUG_ST[-1], ws) * 0.55) hgt = LUG_H * (1.0 - LUG_TAPER * u) + tall th = th_a - LUG_SWEEP * (0.85 * t + 0.15 * t * t) sec = [(-LUG_BW, -LUG_BITE), (LUG_BW, -LUG_BITE), (LUG_TW, hgt - LUG_CH), (LUG_TW - LUG_CH, hgt), (-LUG_TW + LUG_CH, hgt), (-LUG_TW, hgt - LUG_CH)] ring = [] for dt, dn in sec: rr = rc + nr * (dn + lift) w = h * (ws + nw * (dn + lift)) a = th + dt / rc ring.append(C + Vector((spin * rr * math.sin(a), spin * w, rr * math.cos(a)))) rings.append(ring) with b.part(T_LUG, 0.5, *groups): add_loft(bm, rings, RUBBER_IDX) def add_valve(b, C, rot, w, groups, r_well): av = math.radians(38.0) base = C + rot @ Vector((0.0, r_well - 0.002, w)) vdir = rot @ Vector((0.0, -math.sin(av), math.cos(av))) with b.part(T_NONE, 0.5, *groups): lathe_on(b.bm, [(0.0042, 0.0), (0.0045, 0.006), (0.0036, 0.024), (0.0036, 0.028)], 10, RUBBER_IDX, base, vdir) with b.part(T_NONE, 0.5, *groups): lathe_on(b.bm, [(0.0050, 0.0265), (0.0054, 0.028), (0.0054, 0.036), (0.0040, 0.0375)], 10, ZINC_IDX, base, vdir) def build_rear_wheel(b, s, flags): bm = b.bm side = "L" if s > 0 else "R" gw = "wheelR" + side dx = -SKEW_WHEEL if (flags["skew_wheel"] and s > 0) else 0.0 dy = s * WIDE_TRACK if flags["wide_track"] else 0.0 C = Vector((AX_R + dx, s * RUT_Y + dy, Z_AR)) ax = Y * s rot = frame(ax, Z) bite = SINK_BITE if (flags["sink_tyre"] and s < 0) else TYRE_BITE nf = len(bm.faces) with b.part(T_TYRE, 0.40 if s > 0 else 0.60, "tr", gw, "tyre_R" + side): add_lathe(bm, rear_tyre_profile(bite), TYRE_SEGS_R, RUBBER_IDX, center=C, rot=rot) # mud packed between the bars: the crown's faces, not the sidewall's bm.faces.ensure_lookup_table() for i in range(nf, len(bm.faces)): q = bm.faces[i].calc_center_median() - C bm.faces[i][b.pack] = smoothstep(0.540, 0.566, (q - ax * q.dot(ax)).length) spin = -1.0 if (flags["reverse_lugs"] and s < 0) else 1.0 build_lugs(b, C, spin, ("tr", gw, "lugs_R" + side), lift=FLOAT_LUGS if (flags["float_lugs"] and s > 0) else 0.0, tall=TALL_LUGS if flags["tall_lugs"] else 0.0, bunch=flags["bunch_lugs"] and s < 0) with b.part(T_RIM, 0.5, "tr", gw): add_lathe(bm, rim_ring_profile(RIM_R), RIM_SEGS_R, WHEEL_IDX, center=C, rot=rot, phase=math.pi / RIM_SEGS_R) with b.part(T_NONE, 0.45, "tr", gw): add_lathe(bm, thick_profile(DISC_R, 0.006), 64, WHEEL_IDX, center=C, rot=rot, phase=0.0173) # clamp lugs welded in the rim's well, bolted through the disc for k in range(8): a = 2.0 * math.pi * (k + 0.5) / 8 e_r = rot @ Vector((math.cos(a), math.sin(a), 0.0)) e_t = rot @ Vector((-math.sin(a), math.cos(a), 0.0)) lrot = Matrix((e_t, ax, e_r)).transposed() # local (u, v, w) -> (tangent, axis, radial) with b.part(T_NONE, 0.5, "tr", gw): add_rbox(bm, 0.022, 0.015, 0.005, [(0.002, 0.0), (0.0, 0.002), (0.0, 0.0255)], C + e_r * 0.296 + ax * (-0.021), lrot, WHEEL_IDX, n_corner=2) with b.part(T_NONE, 0.5, "tr", gw): add_hex(bm, C + e_r * 0.306, ax, -0.0072 - 0.0001 * k, -0.0005 + 0.0002 * k, 0.0085, STEEL_IDX, phase=a) # wheel nuts on the disc for k in range(8): a = 2.0 * math.pi * k / 8 + 0.11 p = C + rot @ Vector((0.100 * math.cos(a), 0.100 * math.sin(a), 0.0)) with b.part(T_NONE, 0.5, "tr", gw): add_hex(bm, p, ax, -0.0557 - 0.00005 * k, -0.0440 + 0.0001 * k, 0.0120, STEEL_IDX, phase=a) # cast-iron weights: two half-moons on the disc, three bolts each g = 0.007 for half, (lo, hi) in enumerate(((-0.5 * math.pi, 0.5 * math.pi), (0.5 * math.pi, 1.5 * math.pi))): ro, ri = 0.225, 0.132 ao, ai = math.asin(g / ro), math.asin(g / ri) outline = [] for k in range(22): a = lo + ao + (hi - lo - 2 * ao) * k / 21 outline.append((ro * math.cos(a), ro * math.sin(a))) for k in range(12): a = hi - ai - (hi - lo - 2 * ai) * k / 11 outline.append((ri * math.cos(a), ri * math.sin(a))) if poly_area(outline) < 0.0: outline.reverse() e = 0.0003 * half with b.part(T_NONE, 0.35 + 0.3 * half, "tr", gw): add_prism(bm, outline, -0.0550 + e, -0.0100 + e, C, rot, IRON_IDX, ch=0.003) mid = 0.5 * (lo + hi) for k, da in enumerate((-0.75, 0.0, 0.75)): a = mid + da p = C + rot @ Vector((0.180 * math.cos(a), 0.180 * math.sin(a), 0.0)) with b.part(T_NONE, 0.5, "tr", gw): add_hex(bm, p, ax, -0.0105 + e + 0.0001 * k, -0.0010 + e + 0.0001 * k, 0.0130, STEEL_IDX, phase=a) # a cast grip on the weight's face a = mid gp = C + rot @ Vector((0.205 * math.cos(a), 0.205 * math.sin(a), -0.0110 + e)) e_t = rot @ Vector((-math.sin(a), math.cos(a), 0.0)) with b.part(T_NONE, 0.5, "tr", gw): add_bar(bm, [gp - e_t * 0.045, gp - e_t * 0.035 + ax * 0.0173, gp + e_t * 0.035 + ax * 0.0173, gp + e_t * 0.045], rot @ Vector((math.cos(a), math.sin(a), 0.0)), 0.008, 0.006, 0.003, IRON_IDX, fillet=0.010) add_valve(b, C, rot, 0.030, ("tr", gw), RIM_R["RD"]) def build_front_wheel(b, s, flags): bm = b.bm side = "L" if s > 0 else "R" gw = "wheelF" + side C = Vector((AX_F, s * 0.5 * TRACK_F, Z_AF)) ax = Y * s rot = frame(ax, Z) with b.part(T_TYRE, 0.45 if s > 0 else 0.55, "tr", gw, "tyre_F" + side): add_lathe(bm, front_tyre_profile(TYRE_BITE), TYRE_SEGS_F, RUBBER_IDX, center=C, rot=rot) with b.part(T_RIM, 0.5, "tr", gw): add_lathe(bm, rim_ring_profile(RIM_F), RIM_SEGS_F, WHEEL_IDX, center=C, rot=rot, phase=math.pi / RIM_SEGS_F) with b.part(T_NONE, 0.45, "tr", gw): add_lathe(bm, thick_profile(DISC_F, 0.005), 48, WHEEL_IDX, center=C, rot=rot, phase=0.0211) for k in range(5): a = 2.0 * math.pi * k / 5 + 0.2 p = C + rot @ Vector((0.065 * math.cos(a), 0.065 * math.sin(a), 0.0)) with b.part(T_NONE, 0.5, "tr", gw): add_hex(bm, p, ax, -0.0030 - 0.0001 * k, 0.0070 + 0.0001 * k, 0.0090, STEEL_IDX, phase=a) add_valve(b, C, rot, 0.006, ("tr", gw), RIM_F["RD"]) if flags["float_tyre"] and s > 0: for v in b.groups["tyre_F" + side]: v.co.z += FLOAT_TYRE # -------------------------------------------------------------------------- # Bodywork # -------------------------------------------------------------------------- def hood_path(n_corner): pts = [Vector((HW, HB)), Vector((HW, HB + 0.5 * (HT - HR - HB)))] for p in body_section(HW, HB, HT, HR, 0.01, CROWN, 3 * n_corner, top_n=10): if p[1] >= HT - HR - 1e-9: pts.append(Vector(p)) # body_section starts on the right corner and runs over the top leftward pts += [Vector((-HW, HB + 0.5 * (HT - HR - HB))), Vector((-HW, HB))] out = [] for p in pts: if not out or (p - out[-1]).length > 1e-6: out.append(p) return resample(out, 34 + 6 * n_corner) def build_bonnet(b, n_corner): bm = b.bm path = hood_path(n_corner) nu = len(path) - 1 def surf(u, v): i = min(int(round(u * nu)), nu) p2 = path[i] a = path[max(i - 1, 0)] c = path[min(i + 1, nu)] t = (c - a).normalized() n2 = Vector((t.y, -t.x)) x = HX0 + (HX1 - HX0) * v n = Vector((0.0, n2.x, n2.y)) # outward: away from the section's centre if n.dot(Vector((0.0, p2.x, p2.y - 0.95))) < 0.0: n = -n return Vector((x, p2.x, p2.y)), n with b.part(T_NONE, 0.5, "tr"): add_sheet(bm, surf, nu, 14, 0.003, PAINT_IDX) # a zinc trim strip down the bonnet's crown, its ends tucked under zt = top_z(HW, HT, HR, CROWN, 0.0) with b.part(T_NONE, 0.5, "tr"): add_bar(bm, [Vector((HX0 + 0.030, 0.0, zt - 0.004)), Vector((HX0 + 0.050, 0.0, zt + 0.0012)), Vector((HX1 - 0.040, 0.0, zt + 0.0012)), Vector((HX1 - 0.020, 0.0, zt - 0.004))], Y, 0.010, 0.0020, 0.0012, ZINC_IDX, fillet=0.015) # the bonnet's side louvres: pressed lips leaning out at the top, three a # side, each a step longer (level lips of one length share their planes) for s in (-1.0, 1.0): for k in range(3): z = 0.935 + 0.034 * k x0, x1 = 0.520 - 0.006 * k, 0.760 + 0.004 * k with b.part(T_NONE, 0.5, "tr"): add_bar(bm, [Vector((x0, s * (HW - 0.0015), z)), Vector((x0 + 0.022, s * (HW + 0.0045), z)), Vector((x1 - 0.022, s * (HW + 0.0045), z)), Vector((x1, s * (HW - 0.0015), z))], Vector((0.0, s * 0.30, 0.954)), 0.009, 0.0022, 0.0015, PAINT_IDX, fillet=0.012) def tank_section(d, n_corner): return body_section(HW + 0.004 - d, 0.836 + d, HT + 0.004 - d, HR + 0.004 - d, 0.020 - d, CROWN, n_corner * 2, top_n=10) def build_tank_dash(b, n_corner): bm = b.bm prof = [(0.006, 0.022), (0.0, 0.028), (0.0, 0.040), (0.0, 0.247), (0.0, 0.259), (0.006, 0.265)] loops = [[Vector((x, y, z)) for y, z in tank_section(d, n_corner)] for d, x in prof] with b.part(T_NONE, 0.5, "tr"): add_loft(bm, loops, PAINT_IDX) ztop = top_z(HW + 0.004, HT + 0.004, HR + 0.004, CROWN, 0.0) with b.part(T_NONE, 0.5, "tr"): lathe_on(bm, [(0.030, ztop - 0.012), (0.030, ztop + 0.022), (0.034, ztop + 0.026)], 20, STEEL_IDX, Vector((0.160, 0.0, 0.0)), Z) with b.part(T_NONE, 0.5, "tr"): lathe_on(bm, [(0.041, ztop + 0.023), (0.043, ztop + 0.027), (0.043, ztop + 0.038), (0.036, ztop + 0.045), (0.012, ztop + 0.048)], 24, ZINC_IDX, Vector((0.160, 0.0, 0.0)), Z) for k in range(4): a = 2.0 * math.pi * k / 4 + 0.4 p = Vector((0.160 + 0.043 * math.cos(a), 0.043 * math.sin(a), ztop + 0.030)) with b.part(T_NONE, 0.5, "tr"): add_rod(bm, p, Vector((math.cos(a), math.sin(a), 0.0)), -0.004, 0.006, 0.0040, ZINC_IDX, segs=8) # dash panel behind the tank, the column through it, gauges and switches dash = [(y, z) for y, z in body_section(0.215, 0.620, 1.085, 0.080, 0.020, 0.0, n_corner * 2, top_n=6)] loops = [[Vector((x, y + 0.0, z)) for y, z in (dash if d == 0.0 else body_section(0.215 - d, 0.620 + d, 1.085 - d, 0.080 - d, 0.020 - d, 0.0, n_corner * 2, top_n=6))] for d, x in ((0.003, 0.004), (0.0, 0.007), (0.0, 0.027), (0.003, 0.030))] with b.part(T_NONE, 0.5, "tr"): add_loft(bm, loops, PAINT_IDX) for k, yy in enumerate((0.090, -0.090)): c = Vector((0.0045, yy, 0.975)) with b.part(T_NONE, 0.5, "tr"): lathe_on(bm, [(0.034, -0.003), (0.038, 0.001), (0.038, 0.010), (0.034, 0.014)], 24, BLACK_IDX, c, -X) with b.part(T_NONE, 0.5, "tr"): lathe_on(bm, [(0.030, 0.0125), (0.0395, 0.0130), (0.0395, 0.0180), (0.0320, 0.0205)], 24, ZINC_IDX, c, -X, solid=False) with b.part(T_NONE, 0.2, "tr"): lathe_on(bm, [(0.0310, 0.0120), (0.0310, 0.0165)], 24, GLASS_IDX, c, -X) for k, (yy, zz, r) in enumerate(((0.000, 1.030, 0.014), (0.150, 0.800, 0.011), (-0.150, 0.800, 0.011))): c = Vector((0.0045, yy, zz)) with b.part(T_NONE, 0.5, "tr"): lathe_on(bm, [(r, -0.002), (r, 0.012), (r * 0.8, 0.016)], 16, ZINC_IDX, c, -X) with b.part(T_NONE, 0.5, "tr"): lathe_on(bm, [(r * 0.45, 0.014), (r * 0.45, 0.030), (r * 0.9, 0.032), (r * 0.9, 0.044), (r * 0.6, 0.048)], 12, BLACK_IDX, c, -X) def grille_section(d, n_corner): return body_section(0.240 - d, 0.560 + d, 1.140 - d, 0.100 - d, 0.030 - d, CROWN, n_corner * 2, top_n=10) def build_grille(b, n_corner): bm = b.bm prof = [(0.006, 0.940), (0.0, 0.940), (0.0, 0.946), (0.0, 1.170), (0.0, 1.176), (0.0015, 1.1845), (0.0040, 1.1885), (0.0060, 1.1850), (0.0060, 1.176), (0.0060, 0.946)] loops = [[Vector((x, y, z)) for y, z in grille_section(d, n_corner)] for d, x in prof] with b.part(T_NONE, 0.5, "tr"): add_ring_loft(bm, loops, PAINT_IDX) # vertical bars in the opening, biting the shell's top and bottom walls # each bar a further step in x and z from the centre out, so neighbours # do not share planes and mirrored bars still match for k in range(11): y = -0.200 + 0.040 * k j = min(k, 10 - k) zt = top_z(0.234, 1.134, 0.094, CROWN, y) + 0.003 z0 = 0.5625 - 0.00023 * j with b.part(T_NONE, 0.5, "tr"): add_rbox(bm, 0.012, 0.004, 0.0025, chamfered(zt - z0, 0.001), (1.164 - 0.00041 * j, y, z0), I3, PAINT_IDX, n_corner=2) # radiator core, top tank, filler neck and cap with b.part(T_NONE, 0.5, "tr"): box(bm, (0.955, -0.236, 0.585), (1.012, 0.236, 1.058), CORE_IDX, rc=0.004, ch=0.002) with b.part(T_NONE, 0.5, "tr"): box(bm, (0.948, -0.200, 1.052), (1.019, 0.200, 1.100), BLACK_IDX, rc=0.020, ch=0.006) zt = top_z(0.240, 1.140, 0.100, CROWN, 0.0) with b.part(T_NONE, 0.5, "tr"): lathe_on(bm, [(0.020, 1.095), (0.020, zt + 0.010), (0.024, zt + 0.013)], 16, STEEL_IDX, Vector((0.985, 0.0, 0.0)), Z) with b.part(T_NONE, 0.5, "tr"): lathe_on(bm, [(0.030, zt + 0.011), (0.032, zt + 0.014), (0.032, zt + 0.022), (0.026, zt + 0.028), (0.008, zt + 0.030)], 20, ZINC_IDX, Vector((0.985, 0.0, 0.0)), Z) # headlamps on brackets off the shell's sides for s in (-1.0, 1.0): c = Vector((1.030, s * 0.320, 0.960)) with b.part(T_NONE, 0.5, "tr"): lathe_on(bm, [(0.014, -0.064), (0.042, -0.058), (0.062, -0.042), (0.072, -0.016), (0.074, 0.008), (0.071, 0.013)], 28, PAINT_IDX, c, X, ref=Z) with b.part(T_NONE, 0.5, "tr"): lathe_on(bm, [(0.060, 0.006), (0.0765, 0.009), (0.0775, 0.019), (0.070, 0.025), (0.061, 0.023)], 28, ZINC_IDX, c, X, solid=False, ref=Z) with b.part(T_NONE, 0.1, "tr"): lathe_on(bm, [(0.0625, 0.011), (0.0630, 0.018), (0.052, 0.025), (0.030, 0.029), (0.006, 0.0305)], 28, GLASS_IDX, c, X, ref=Z) with b.part(T_NONE, 0.5, "tr"): add_bar(bm, [c + Vector((-0.040, -s * 0.050, -0.012)), Vector((0.985, s * 0.2385, 0.935))], Z, 0.015, 0.0040, 0.002, STEEL_IDX) def fender_path(): pts = [(0.470, -0.020), (0.472, -0.008), (0.480, 0.000), (0.500, 0.0045), (0.580, 0.0085), (0.660, 0.0100), (0.740, 0.0085), (0.810, 0.0040), (0.832, -0.0040), (0.845, -0.0200), (0.850, -0.0400), (0.850, -0.0750)] return resample([Vector(p) for p in pts], 26) _FPATH = None def fender_point(s, u, v, odd=0.0): """Mudguard outer surface and outward normal: ``u`` across the section (inner edge to the skirt's foot), ``v`` along the arc (rear to front).""" global _FPATH if _FPATH is None: _FPATH = fender_path() path = _FPATH nu = len(path) - 1 f = u * nu i = min(int(f), nu - 1) t = f - i p2 = path[i] * (1 - t) + path[i + 1] * t tg = (path[i + 1] - path[i]).normalized() th = math.radians(FENDER_TH[0] + (FENDER_TH[1] - FENDER_TH[0]) * v) yr = p2.x if odd and s > 0: thd = math.degrees(th) if -20.0 < thd < 40.0: yr += odd * math.cos(math.pi * (thd - 10.0) / 60.0) ** 2 r = R_F + p2.y er = Vector((math.sin(th), 0.0, math.cos(th))) p = Vector((AX_R, 0.0, Z_AR)) + er * r + Vector((0.0, s * yr, 0.0)) # normal: the section's own normal in (y, r), turned into the radial plane # the path's left normal: out of the crown, out of the skirt, in off the lip n2 = Vector((-tg.y, tg.x)) n = Vector((0.0, s * n2.x, 0.0)) + er * n2.y return p, n.normalized() def build_fenders(b, flags): bm = b.bm for s in (-1.0, 1.0): odd = ODD_FENDER if flags["odd_fender"] else 0.0 def surf(u, v, s=s, odd=odd): return fender_point(s, u, v, odd) with b.part(T_NONE, 0.5 if s > 0 else 0.55, "tr"): add_sheet(bm, surf, 25, 40, 0.003, PAINT_IDX) def build_footplates(b): bm = b.bm for s in (-1.0, 1.0): outline = [(x, s * y) for x, y in rrect(0.185, 0.1675, 0.020, 3)] outline = [(x - 0.065, y + s * 0.3375) for x, y in outline] if poly_area(outline) < 0.0: outline.reverse() with b.part(T_NONE, 0.5, "tr"): add_plate(bm, outline, 0.462, 0.470, 0.010, PAINT_IDX) # bare-steel anti-slip strips, worn bright by boots for k in range(5): x = -0.215 + 0.070 * k with b.part(T_NONE, 0.5, "tr"): add_bar(bm, [Vector((x, s * 0.195, 0.4695)), Vector((x + 0.030, s * 0.470, 0.4695))], X, 0.009, 0.0030, 0.0012, STEEL_IDX) # a turned-up lip along the outer edge with b.part(T_NONE, 0.5, "tr"): add_bar(bm, [Vector((-0.235, s * 0.498, 0.4665)), Vector((0.105, s * 0.498, 0.4665))], Z, 0.012, 0.0030, 0.0015, PAINT_IDX) for k, x in enumerate((-0.180, 0.060)): with b.part(T_NONE, 0.5, "tr"): add_bar(bm, [Vector((x, s * 0.150, 0.520 + 0.0003 * k)), Vector((x, s * 0.200, 0.470)), Vector((x, s * 0.380, 0.4605))], X, 0.015, 0.004, 0.002, STEEL_IDX, fillet=0.03) # -------------------------------------------------------------------------- # Engine accessories: exhaust, pre-cleaner, dynamo and belt, starter, filter # -------------------------------------------------------------------------- def chain_tangent(ca, ra, cb, rb): d = cb - ca dist = d.length dh = d / dist dp = Vector((-dh.y, dh.x)) k = (rb - ra) / dist s = math.sqrt(max(0.0, 1.0 - k * k)) n = dh * k + dp * s return ca - n * ra, cb - n * rb def belt_path(circles, step=0.008): """An open-belt loop round convex circles [(centre2d, r)] in order, sampled every ``step`` of arc length (after go-kart's chain path).""" n = len(circles) deps, arrs = [None] * n, [None] * n for i in range(n): ca, ra = circles[i] cb, rb = circles[(i + 1) % n] pa, pb = chain_tangent(ca, ra, cb, rb) deps[i] = pa arrs[(i + 1) % n] = pb out = [] for i in range(n): c, r = circles[i] a0 = math.atan2(arrs[i].y - c.y, arrs[i].x - c.x) a1 = math.atan2(deps[i].y - c.y, deps[i].x - c.x) sweep = (a1 - a0) % (2.0 * math.pi) m = max(2, int(sweep * r / step)) for k in range(m): a = a0 + sweep * k / m out.append(c + Vector((math.cos(a), math.sin(a))) * r) p0, p1 = deps[i], arrs[(i + 1) % n] m = max(1, int((p1 - p0).length / (4 * step))) for k in range(m): out.append(p0 + (p1 - p0) * (k / m)) return out def add_pulley(b, c, r, groups=("tr",)): prof = [(r - 0.010, 0.0), (r, 0.002), (r, 0.004), (r - 0.009, 0.011), (r, 0.018), (r, 0.020), (r - 0.010, 0.022)] with b.part(T_NONE, 0.5, *groups): lathe_on(b.bm, prof, 28, STEEL_IDX, c, X, ref=Z) def build_accessories(b): bm = b.bm xp = 0.664 # crank nose and pulley; water pump, its pulley, shaft and fan; dynamo with b.part(T_NONE, 0.5, "tr"): add_rod(bm, Vector((0.0, 0.0, 0.500)), X, 0.648, 0.672, 0.030, STEEL_IDX, segs=16) add_pulley(b, Vector((xp, 0.0, 0.500)), 0.060) with b.part(T_NONE, 0.5, "tr"): lathe_on(bm, [(0.050, 0.618), (0.050, 0.646), (0.034, 0.656), (0.030, 0.662)], 20, IRON_IDX, Vector((0.0, 0.0, 0.780)), X, ref=Z) add_pulley(b, Vector((xp, 0.0, 0.780)), 0.055) with b.part(T_NONE, 0.5, "tr"): add_rod(bm, Vector((0.0, 0.0, 0.780)), X, 0.660, 0.905, 0.012, STEEL_IDX, segs=12) with b.part(T_NONE, 0.5, "tr"): lathe_on(bm, [(0.042, 0.880), (0.042, 0.912), (0.030, 0.918)], 20, STEEL_IDX, Vector((0.0, 0.0, 0.780)), X, ref=Z) for k in range(4): a = 2.0 * math.pi * k / 4 + 0.35 e = Vector((0.0, math.cos(a), math.sin(a))) t = Vector((0.0, -math.sin(a), math.cos(a))) wax = (t * math.cos(0.45) + X * math.sin(0.45)).normalized() c = Vector((0.896, 0.0, 0.780)) with b.part(T_NONE, 0.5, "tr"): add_bar(bm, [c + e * 0.034, c + e * 0.120, c + e * 0.190], wax, 0.030, 0.0022, 0.004, STEEL_IDX, fillet=0.01) dyn = Vector((0.0, -0.200, 0.650)) with b.part(T_NONE, 0.35, "tr"): lathe_on(bm, [(0.040, 0.420), (0.050, 0.425), (0.055, 0.440), (0.055, 0.620), (0.050, 0.634), (0.030, 0.642)], 28, BLACK_IDX, dyn, X, ref=Z) with b.part(T_NONE, 0.5, "tr"): add_rod(bm, dyn, X, 0.636, 0.680, 0.008, STEEL_IDX, segs=10) add_pulley(b, Vector((xp, -0.200, 0.650)), 0.038) for k, x in enumerate((0.470, 0.590)): with b.part(T_NONE, 0.5, "tr"): add_bar(bm, [Vector((x, -0.200, 0.650)), Vector((x, -0.170, 0.640)), Vector((x, -0.140, 0.600 + 0.0005 * k))], X, 0.014, 0.004, 0.002, STEEL_IDX, fillet=0.02) # the fan belt round the three pulleys, in the grooves xb = xp + 0.011 circ = [(Vector((0.0, 0.500)), 0.060 - 0.006), (Vector((0.0, 0.780)), 0.055 - 0.006), (Vector((-0.200, 0.650)), 0.038 - 0.006)] path = belt_path(circ) loop = [] n = len(path) for i, p in enumerate(path): t = (path[(i + 1) % n] - path[i - 1]).normalized() nr = Vector((t.y, -t.x)) for u, v in rrect(0.0055, 0.0040, 0.0015, 1): q = p + nr * v loop.append(Vector((xb + u, q.x, q.y))) k = len(rrect(0.0055, 0.0040, 0.0015, 1)) rings = [loop[i * k:(i + 1) * k] for i in range(n)] with b.part(T_NONE, 0.5, "tr"): add_ring_loft(bm, rings, RUBBER_IDX) # starter on the bell housing flange; oil filter on a boss off the block with b.part(T_NONE, 0.40, "tr"): lathe_on(bm, [(0.030, 0.118), (0.046, 0.122), (0.046, 0.320), (0.040, 0.332), (0.020, 0.338)], 24, BLACK_IDX, Vector((0.0, -0.200, 0.515)), X, ref=Z) with b.part(T_NONE, 0.5, "tr"): lathe_on(bm, [(0.018, 0.150), (0.022, 0.154), (0.022, 0.250), (0.018, 0.254)], 16, BLACK_IDX, Vector((0.0, -0.200, 0.575)), X, ref=Z) with b.part(T_NONE, 0.5, "tr"): box(bm, (0.410, -0.172, 0.525), (0.470, -0.145, 0.585), IRON_IDX, rc=0.010, ch=0.003) with b.part(T_NONE, 0.30, "tr"): lathe_on(bm, [(0.034, 0.470), (0.040, 0.474), (0.040, 0.574), (0.036, 0.580), (0.030, 0.584)], 24, BLACK_IDX, Vector((0.440, -0.205, 0.0)), Z) # exhaust manifold on the right, ports into the block; the downpipe to the stack with b.part(T_NONE, 0.5, "tr"): add_tube(bm, [Vector((0.180, -0.188, 0.745)), Vector((0.560, -0.188, 0.745))], 0.026, 16, EXHAUST_IDX) for k in range(4): x = 0.220 + 0.100 * k with b.part(T_NONE, 0.5, "tr"): add_tube(bm, [Vector((x, -0.138, 0.745)), Vector((x, -0.188, 0.745))], 0.018, 12, EXHAUST_IDX) with b.part(T_NONE, 0.5, "tr"): add_rbox(bm, 0.030, 0.026, 0.006, chamfered(0.012, 0.002), (x, -0.146 - 0.00043 * k, 0.745), frame(-Y, X), EXHAUST_IDX, n_corner=2) sx, sy = 0.760, -0.130 down = [Vector((0.540, -0.188, 0.745)), Vector((0.650, -0.188, 0.752)), Vector((0.730, -0.150, 0.790)), Vector((sx, sy, 0.860)), Vector((sx, sy, 0.960))] with b.part(T_NONE, 0.5, "tr"): add_tube(bm, fillet_path(down, 0.05, 6), 0.029, 14, EXHAUST_IDX) # the stack through the bonnet: collar, a silencer barrel, a lip zb = top_z(HW, HT, HR, CROWN, sy) with b.part(T_NONE, 0.5, "tr"): lathe_on(bm, [(0.036, 0.940), (0.036, 1.180), (0.052, 1.200), (0.052, 1.420), (0.036, 1.440), (0.036, 1.628), (0.039, 1.630), (0.039, 1.638), (0.032, 1.640)], 24, EXHAUST_IDX, Vector((sx, sy, 0.0)), Z) with b.part(T_NONE, 0.5, "tr"): lathe_on(bm, [(0.050, zb - 0.010), (0.056, zb - 0.004), (0.056, zb + 0.006), (0.040, zb + 0.012)], 24, ZINC_IDX, Vector((sx, sy, 0.0)), Z) # rain cap: a flap hinged at the stack's back, lifted open, with its counterweight hinge = Vector((sx - 0.039, sy, 1.638)) open_a = math.radians(22.0) fl = Matrix.Rotation(-open_a, 3, "Y") with b.part(T_NONE, 0.5, "tr"): lathe_on(bm, [(0.0045, -0.018), (0.0045, 0.018)], 10, STEEL_IDX, hinge, Y, ref=X) fc = hinge + fl @ Vector((0.043, 0.0, 0.002)) with b.part(T_NONE, 0.5, "tr"): lathe_on(bm, [(0.046, -0.002), (0.047, 0.001), (0.046, 0.003), (0.030, 0.006), (0.004, 0.0072)], 24, EXHAUST_IDX, fc, fl @ Z, ref=X) with b.part(T_NONE, 0.5, "tr"): add_bar(bm, [hinge + fl @ Vector((0.010, 0.0, 0.004)), hinge + fl @ Vector((-0.010, 0.0, 0.0)), hinge + fl @ Vector((-0.030, 0.0, -0.010))], Y, 0.006, 0.0025, 0.001, STEEL_IDX, fillet=0.008) with b.part(T_NONE, 0.5, "tr"): add_rod(bm, hinge + fl @ Vector((-0.034, 0.0, -0.014)), fl @ -X, -0.010, 0.010, 0.010, EXHAUST_IDX, segs=12, ch=0.002) # air cleaner can in front of the block, its pipe up through the bonnet to # the pre-cleaner: a zinc base, a glass bowl, a zinc cap and wing nut ac = Vector((0.770, 0.100, 0.0)) with b.part(T_NONE, 0.35, "tr"): lathe_on(bm, [(0.050, 0.600), (0.058, 0.608), (0.058, 0.930), (0.050, 0.940), (0.026, 0.944)], 24, BLACK_IDX, ac, Z) with b.part(T_NONE, 0.5, "tr"): add_bar(bm, [Vector((0.715, 0.100, 0.700)), Vector((0.690, 0.100, 0.690)), Vector((0.650, 0.100, 0.690))], Z, 0.018, 0.004, 0.002, STEEL_IDX, fillet=0.01) zb = top_z(HW, HT, HR, CROWN, ac.y) with b.part(T_NONE, 0.5, "tr"): lathe_on(bm, [(0.028, 0.935), (0.028, 1.232), (0.032, 1.236)], 20, STEEL_IDX, ac, Z) with b.part(T_NONE, 0.5, "tr"): lathe_on(bm, [(0.042, zb - 0.010), (0.047, zb - 0.004), (0.047, zb + 0.006), (0.032, zb + 0.012)], 20, ZINC_IDX, ac, Z) with b.part(T_NONE, 0.5, "tr"): lathe_on(bm, [(0.030, 1.226), (0.052, 1.230), (0.058, 1.240), (0.058, 1.256), (0.052, 1.262)], 28, ZINC_IDX, ac, Z) with b.part(T_NONE, 0.25, "tr"): lathe_on(bm, [(0.050, 1.258), (0.058, 1.268), (0.062, 1.290), (0.061, 1.312), (0.055, 1.330), (0.050, 1.334)], 28, GLASS_IDX, ac, Z) with b.part(T_NONE, 0.5, "tr"): lathe_on(bm, [(0.050, 1.331), (0.056, 1.334), (0.056, 1.346), (0.044, 1.356), (0.010, 1.360)], 28, ZINC_IDX, ac, Z) with b.part(T_NONE, 0.5, "tr"): add_rod(bm, ac, Z, 1.352, 1.380, 0.0045, STEEL_IDX, segs=10) with b.part(T_NONE, 0.5, "tr"): add_bar(bm, [ac + Vector((-0.020, 0.0, 1.372)), ac + Vector((0.0, 0.0, 1.366)), ac + Vector((0.020, 0.0, 1.372))], Y, 0.004, 0.0025, 0.001, ZINC_IDX, fillet=0.006) with b.part(T_NONE, 0.5, "tr"): add_tube(bm, fillet_path([Vector((0.745, 0.150, 0.760)), Vector((0.650, 0.175, 0.745)), Vector((0.560, 0.182, 0.730))], 0.04, 4), 0.017, 12, RUBBER_IDX) with b.part(T_NONE, 0.5, "tr"): add_tube(bm, [Vector((0.200, 0.165, 0.725)), Vector((0.575, 0.165, 0.725))], 0.024, 14, IRON_IDX) # -------------------------------------------------------------------------- # Controls: column and wheel, seat on its spring, levers, pedals # -------------------------------------------------------------------------- def build_controls(b): bm = b.bm d = COL_DIR with b.part(T_NONE, 0.5, "tr"): lathe_on(bm, [(0.020, -0.030), (0.024, -0.026), (0.024, COL_L - 0.080), (0.021, COL_L - 0.074)], 20, PAINT_IDX, COL_B, d, ref=Y) with b.part(T_NONE, 0.5, "tr"): add_rod(bm, COL_B, d, COL_L - 0.090, COL_L + 0.0095, 0.011, STEEL_IDX, segs=12) c = COL_B + d * COL_L up = (Z - d * Z.dot(d)).normalized() rt = up.cross(d) with b.part(T_NONE, 0.5, "tr"): lathe_on(bm, [(0.012, -0.034), (0.030, -0.032), (0.036, -0.020), (0.036, 0.004), (0.030, 0.010), (0.012, 0.012)], 24, BLACK_IDX, c, d, ref=up) with b.part(T_NONE, 0.5, "tr"): add_hex(bm, c, d, 0.0100, 0.0180, 0.0120, ZINC_IDX) grip = [(WHEEL_RS + 0.0120 * math.cos(2.0 * math.pi * k / 12), 0.0125 * math.sin(2.0 * math.pi * k / 12)) for k in range(12)] with b.part(T_NONE, 0.5, "tr"): add_lathe(bm, grip, 56, BLACK_IDX, center=c + d * 0.030, rot=frame(d, up)) for ang in (90.0, 210.0, 330.0): a = math.radians(ang) rad = up * math.sin(a) + rt * math.cos(a) tan = d.cross(rad) with b.part(T_NONE, 0.5, "tr"): add_bar(bm, [c + rad * 0.028 - d * 0.004, c + rad * 0.100 + d * 0.018, c + rad * (WHEEL_RS - 0.002) + d * 0.030], tan, 0.009, 0.0035, 0.002, STEEL_IDX, fillet=0.03) # the pan seat: a pressed bowl on a leaf spring sc = Vector((-1.150, 0.0, 1.012)) cl = [(0.022, 0.000), (0.110, 0.004), (0.160, 0.018), (0.190, 0.042), (0.205, 0.070), (0.212, 0.092), (0.216, 0.100)] with b.part(T_NONE, 0.5, "tr", "seat"): vs = add_lathe(bm, thick_profile(cl, 0.0045), 44, BLACK_IDX, center=(0.0, 0.0, 0.0)) for v in vs: lx, ly, lz = v.co.x, v.co.y, v.co.z r = math.hypot(lx, ly) lz += 0.085 * smoothstep(-0.02, 0.17, -lx) * smoothstep(0.10, 0.20, r) v.co = sc + Vector((lx * 0.88, ly, lz)) with b.part(T_NONE, 0.5, "tr"): lathe_on(bm, [(0.040, -0.004), (0.040, 0.003), (0.034, 0.006)], 20, STEEL_IDX, sc, Z) with b.part(T_NONE, 0.5, "tr"): box(bm, (-1.165, -0.036, 0.992), (-1.075, 0.036, 1.0125), STEEL_IDX, rc=0.008, ch=0.002) leaf = [Vector((-0.735, 0.0, 0.955)), Vector((-0.860, 0.0, 0.990)), Vector((-1.000, 0.0, 1.000)), Vector((-1.130, 0.0, 1.002))] with b.part(T_NONE, 0.5, "tr"): add_bar(bm, leaf, Y, 0.028, 0.006, 0.002, STEEL_IDX, fillet=0.08) leaf2 = [p - Vector((0.0, 0.0, 0.0105)) for p in leaf[:3]] leaf2[-1] = leaf2[-1] + Vector((0.050, 0.0, 0.004)) with b.part(T_NONE, 0.5, "tr"): add_bar(bm, leaf2, Y, 0.026, 0.005, 0.002, STEEL_IDX, fillet=0.08) with b.part(T_NONE, 0.5, "tr"): box(bm, (-0.790, -0.036, 0.895), (-0.720, 0.036, 0.952), IRON_IDX, rc=0.010, ch=0.003) for k, x in enumerate((-0.745, -0.770)): with b.part(T_NONE, 0.5, "tr"): add_hex(bm, Vector((x, 0.0, 0.0)), Z, 0.9585 - 0.0003 * k, 0.9665 + 0.0002 * k, 0.0085, STEEL_IDX) # gear lever with its boot and knob lever = [Vector((-0.330, 0.0, 0.740)), Vector((-0.335, 0.0, 0.840)), Vector((-0.380, 0.030, 0.950)), Vector((-0.440, 0.060, 1.010))] with b.part(T_NONE, 0.5, "tr"): add_tube(bm, fillet_path(lever, 0.06, 5), 0.0085, 10, STEEL_IDX) with b.part(T_NONE, 0.5, "tr"): lathe_on(bm, [(0.030, 0.765), (0.030, 0.772), (0.020, 0.788), (0.024, 0.796), (0.014, 0.812), (0.017, 0.818), (0.010, 0.832)], 16, RUBBER_IDX, Vector((-0.331, 0.0, 0.0)), Z) kd = (lever[-1] - lever[-2]).normalized() with b.part(T_NONE, 0.5, "tr"): lathe_on(bm, [(0.006, -0.012), (0.018, -0.008), (0.024, 0.006), (0.020, 0.022), (0.008, 0.030)], 16, BLACK_IDX, lever[-1], kd) # pedals: pivots on the bell housing, clutch on the left, brake on the right for s in (-1.0, 1.0): piv = Vector((-0.030, s * 0.182, 0.530)) with b.part(T_NONE, 0.5, "tr"): add_rod(bm, piv, Y * s, -0.012, 0.028, 0.018, IRON_IDX, segs=16, ch=0.002) pad = Vector((0.085, s * 0.215, 0.604)) with b.part(T_NONE, 0.5, "tr"): add_bar(bm, [piv + Y * s * 0.020, Vector((0.020, s * 0.205, 0.560)), pad], Y, 0.009, 0.013, 0.003, STEEL_IDX, fillet=0.03) back = Vector((-0.45, 0.0, 0.89)).normalized() with b.part(T_NONE, 0.5, "tr"): add_rbox(bm, 0.040, 0.030, 0.008, chamfered(0.008, 0.0015), pad - back * 0.004, frame(back, Y), STEEL_IDX, n_corner=3) with b.part(T_NONE, 0.35, "tr"): add_rbox(bm, 0.034, 0.024, 0.007, chamfered(0.004, 0.001), pad + back * 0.0032, frame(back, Y), RUBBER_IDX, n_corner=3) # -------------------------------------------------------------------------- # Three-point linkage, drawbar, PTO # -------------------------------------------------------------------------- def add_eye(b, c, axis, r, hl, groups=("tr",)): with b.part(T_EYE, 0.5, *groups): lathe_on(b.bm, [(r - 0.003, -hl), (r, -hl + 0.003), (r, hl - 0.003), (r - 0.003, hl)], 20, STEEL_IDX, c, axis, ref=Z if abs(Vector(axis).z) < 0.9 else X) def add_pin(b, c, axis, a, bb, r, head=True, groups=("tr",)): with b.part(T_PIN, 0.5, *groups): lathe_on(b.bm, [(r - 0.0012, a), (r, a + 0.0012), (r, bb - 0.0012), (r - 0.0012, bb)], 12, STEEL_IDX, c, axis, ref=Z if abs(Vector(axis).z) < 0.9 else X) if head: with b.part(T_NONE, 0.5, *groups): add_hex(b.bm, c, axis, bb - 0.010, bb - 0.002, r * 1.6, STEEL_IDX) def build_hitch(b, flags): bm = b.bm ll_f = {} for s in (-1.0, 1.0): # lower-link brackets hung from the trumpet: two cheeks round the eye f = Vector((LL_F[0], s * LL_F[1], LL_F[2])) for k, dy in enumerate((-0.028, 0.028)): y = s * LL_F[1] + dy outline = circles_hull([(f.x, f.z, 0.032), (f.x - 0.050, Z_AR - 0.080, 0.012), (f.x + 0.050, Z_AR - 0.080, 0.012)], 24) with b.part(T_NONE, 0.5, "tr"): add_prism(bm, outline, y - 0.006 + 0.0002 * k, y + 0.006 + 0.0002 * k, (0.0, 0.0, 0.0), ROT_Y, IRON_IDX, ch=0.0015) add_pin(b, f, Y * s, -0.045, 0.050, 0.012) add_eye(b, f, Y, 0.030, 0.016) r = Vector((LL_R[0], s * LL_R[1], LL_R[2])) add_eye(b, r, Y, 0.034, 0.022) with b.part(T_NONE, 0.5, "tr"): add_bar(bm, [f, r], Z, 0.025, 0.011, 0.004, STEEL_IDX) ll_f[s] = (f, r) # lift arm, lift rod and their pins hub = Vector((LIFT_X, s * ARM_END[1], LIFT_Z)) with b.part(T_NONE, 0.5, "tr"): add_rod(bm, hub, Y * s, -0.020, 0.020, 0.044, STEEL_IDX, segs=20, ch=0.003) ae = Vector((ARM_END[0], s * ARM_END[1], ARM_END[2])) with b.part(T_NONE, 0.5, "tr"): add_bar(bm, [hub, ae], Y, 0.013, 0.024, 0.006, STEEL_IDX) add_eye(b, ae, Y, 0.030, 0.013) rt = ae + Y * s * 0.030 add_eye(b, rt, Y, 0.026, 0.011) add_pin(b, ae + Y * s * 0.015, Y * s, -0.040, 0.040, 0.011) # the rod's foot on the lower link, beside it t = (-1.300 - f.x) / (r.x - f.x) on = f + (r - f) * t rb = on + Y * s * 0.030 add_eye(b, rb, Y, 0.024, 0.011) add_pin(b, on + Y * s * 0.012, Y * s, -0.040, 0.040, 0.011) with b.part(T_NONE, 0.5, "tr"): dd = (rb - rt).normalized() add_tube(bm, [rt + dd * 0.020, rb - dd * 0.020], 0.013, 12, STEEL_IDX) if s < 0: # levelling box and crank on the right-hand rod mid = rt + (rb - rt) * 0.40 dd = (rb - rt).normalized() with b.part(T_NONE, 0.5, "tr"): add_rbox(bm, 0.024, 0.020, 0.006, chamfered(0.080, 0.004), mid - dd * 0.040, frame(dd, X), STEEL_IDX, n_corner=2) with b.part(T_NONE, 0.5, "tr"): add_bar(bm, [mid - dd * 0.030, mid - dd * 0.030 - Y * 0.050, mid - dd * 0.030 - Y * 0.055 + X * 0.070], Z, 0.006, 0.004, 0.002, STEEL_IDX, fillet=0.01) # the lift cross-shaft through the cover with b.part(T_NONE, 0.5, "tr"): add_rod(bm, Vector((LIFT_X, 0.0, LIFT_Z)), Y, -0.255, 0.255, 0.026, STEEL_IDX, segs=20, ch=0.003) # drawbar through the lower links' rear eyes: it is their pin with b.part(T_PIN, 0.5, "tr"): lathe_on(bm, [(0.0175, -0.470), (0.0190, -0.468), (0.0190, 0.468), (0.0175, 0.470)], 16, STEEL_IDX, Vector((LL_R[0], 0.0, LL_R[2])), Y, ref=Z) for s in (-1.0, 1.0): with b.part(T_NONE, 0.5, "tr"): add_rod(bm, Vector((LL_R[0], s * 0.448, LL_R[2])), Z, -0.030, 0.030, 0.0045, STEEL_IDX, segs=8) # centre hitch plate on the drawbar, the top link's stowage cheeks and pin with b.part(T_NONE, 0.5, "tr"): add_rbox(bm, 0.060, 0.040, 0.010, chamfered(0.024, 0.002), Vector((LL_R[0] - 0.050, 0.0, LL_R[2] - 0.012)), I3, STEEL_IDX, n_corner=2) for k, dy in enumerate((-0.030, 0.030)): outline = circles_hull([(LL_R[0], LL_R[2], 0.024), (TL_R.x, TL_R.z, 0.020)], 20) with b.part(T_NONE, 0.5, "tr"): add_prism(bm, outline, dy - 0.005 + 0.0002 * k, dy + 0.005 + 0.0002 * k, (0.0, 0.0, 0.0), ROT_Y, STEEL_IDX, ch=0.0012) add_pin(b, TL_R, Y, -0.050, 0.050, 0.010) # top link: clevis on the housing, pinned eye, turnbuckle, stowed eye for k, dy in enumerate((-0.032, 0.032)): outline = circles_hull([(TL_F.x, TL_F.z, 0.030), (AX_R - 0.180, 0.850, 0.015), (AX_R - 0.180, 0.740, 0.015)], 20) with b.part(T_NONE, 0.5, "tr"): add_prism(bm, outline, dy - 0.006 + 0.0002 * k, dy + 0.006 + 0.0002 * k, (0.0, 0.0, 0.0), ROT_Y, IRON_IDX, ch=0.0015) add_pin(b, TL_F, Y, -0.052, 0.052, 0.012) dl = (TL_R - TL_F).normalized() rear = TL_R - dl * (UNPIN if flags["unpin_link"] else 0.0) add_eye(b, TL_F, Y, 0.028, 0.018) add_eye(b, rear, Y, 0.026, 0.018) ln = (rear - TL_F).length with b.part(T_NONE, 0.5, "tr"): add_rod(bm, TL_F, dl, 0.020, ln - 0.020, 0.0125, STEEL_IDX, segs=12) with b.part(T_NONE, 0.40, "tr"): lathe_on(bm, [(0.018, 0.30 * ln), (0.024, 0.30 * ln + 0.008), (0.024, 0.70 * ln - 0.008), (0.018, 0.70 * ln)], 16, STEEL_IDX, TL_F, dl) for k, fr in enumerate((0.30, 0.70)): with b.part(T_NONE, 0.5, "tr"): add_hex(bm, TL_F + dl * (fr * ln + (-0.012 if k == 0 else 0.012)), dl, -0.006, 0.006, 0.020, STEEL_IDX) with b.part(T_NONE, 0.5, "tr"): add_rod(bm, TL_F + dl * (0.5 * ln), Y, -0.060, 0.060, 0.0060, STEEL_IDX, segs=10, ch=0.001) # PTO: splined stub out of the rear cover, its shield over it spl = [] for k in range(6): a0 = 2.0 * math.pi * k / 6 for da, r in ((-0.30, 0.0152), (-0.20, 0.0178), (0.20, 0.0178), (0.30, 0.0152)): spl.append((r * math.cos(a0 + da), r * math.sin(a0 + da))) with b.part(T_NONE, 0.5, "tr"): add_prism(bm, spl, 0.180, 0.345, Vector((AX_R, 0.0, 0.550)), frame(-X, Z), STEEL_IDX, ch=0.0012) with b.part(T_NONE, 0.5, "tr"): lathe_on(bm, [(0.040, 0.205), (0.044, 0.209), (0.044, 0.226), (0.030, 0.232)], 24, IRON_IDX, Vector((AX_R, 0.0, 0.550)), -X, ref=Z) def shield(u, v): a = math.radians(200.0 - 220.0 * u) r = 0.085 x = AX_R - 0.201 - 0.120 * v p = Vector((x, r * math.cos(a), 0.550 + r * math.sin(a) * 0.8)) n = Vector((0.0, math.cos(a), math.sin(a) * 1.25)).normalized() return p, n with b.part(T_NONE, 0.5, "tr"): add_sheet(bm, shield, 22, 6, 0.003, PAINT_IDX) def build_lamps(b): bm = b.bm # tail lamp on the right mudguard's skirt, facing back p, n = fender_point(-1.0, 0.96, 0.12) base = p - n * 0.0015 # the lamp stands clear of the skirt: only its bracket touches the mudguard lc = base + n * 0.046 + Vector((0.0, 0.0, 0.004)) with b.part(T_NONE, 0.5, "lamp"): add_bar(bm, [base, base + n * 0.026, lc + Vector((0.012, 0.0, 0.0))], X, 0.012, 0.003, 0.0015, STEEL_IDX, fillet=0.008) with b.part(T_NONE, 0.5, "lamp"): lathe_on(bm, [(0.010, -0.034), (0.030, -0.030), (0.036, -0.018), (0.036, 0.004), (0.034, 0.007)], 20, ZINC_IDX, lc, -X, ref=Z) with b.part(T_NONE, 0.5, "lamp"): lathe_on(bm, [(0.029, 0.004), (0.0385, 0.006), (0.0385, 0.012), (0.031, 0.015)], 20, BLACK_IDX, lc, -X, solid=False, ref=Z) with b.part(T_NONE, 1.0, "lamp"): lathe_on(bm, [(0.0305, 0.006), (0.0305, 0.012), (0.022, 0.017), (0.005, 0.019)], 20, GLASS_IDX, lc, -X, ref=Z) # work lamp on the left mudguard, on a stalk, aimed back and down p, n = fender_point(1.0, 0.62, 0.22) top = p + n * 0.070 with b.part(T_NONE, 0.5, "tr"): add_rod(bm, p, n, -0.0035, 0.075, 0.0085, STEEL_IDX, segs=10) with b.part(T_NONE, 0.5, "tr"): lathe_on(bm, [(0.020, -0.002), (0.020, 0.004), (0.014, 0.008)], 14, STEEL_IDX, p, n) aim = (Vector((-0.80, 0.0, -0.40))).normalized() lc = top + aim * 0.012 with b.part(T_NONE, 0.5, "tr"): lathe_on(bm, [(0.012, -0.034), (0.034, -0.031), (0.050, -0.020), (0.058, 0.002), (0.056, 0.006)], 24, ZINC_IDX, lc, aim, ref=Z) with b.part(T_NONE, 0.5, "tr"): lathe_on(bm, [(0.047, 0.004), (0.060, 0.006), (0.060, 0.013), (0.050, 0.017)], 24, BLACK_IDX, lc, aim, solid=False, ref=Z) with b.part(T_NONE, 0.1, "tr"): lathe_on(bm, [(0.0485, 0.0045), (0.0485, 0.013), (0.035, 0.019), (0.006, 0.021)], 24, GLASS_IDX, lc, aim, ref=Z) # -------------------------------------------------------------------------- # Assembly # -------------------------------------------------------------------------- def edge_wear(bm): """Per-vertex edge wear: how sharp the convex edges through a vertex are (0 on a smooth surface or a 45-degree chamfer, 1 on a square edge such as a sheet's rolled rim). The paint shaders chip through to primer and bare metal where it is high; every part that carries it has a vertex ring a few millimetres in from its square edges, so the wear stays on the edge rather than being interpolated across a whole face.""" lay = bm.verts.layers.float.new(WEAR) bm.verts.index_update() acc = {} for e in bm.edges: if len(e.link_faces) != 2: continue ang = e.calc_face_angle_signed(0.0) if ang <= 0.0: continue w = smoothstep(math.radians(50.0), math.radians(85.0), ang) for v in e.verts: if w > acc.get(v.index, 0.0): acc[v.index] = w bm.verts.index_update() for v in bm.verts: v[lay] = acc.get(v.index, 0.0) def build_mesh(name, n_corner, flags): bm = bmesh.new() _HEX[0] = 0 _AXL[0] = 0 try: b = Build(bm) build_soil(b) build_water(b) build_rear_axle(b, flags) build_gearbox_engine(b) build_front_support(b, flags) for s in (-1.0, 1.0): build_rear_wheel(b, s, flags) build_front_wheel(b, s, flags) build_bonnet(b, n_corner) build_tank_dash(b, n_corner) build_grille(b, n_corner) build_fenders(b, flags) build_footplates(b) build_accessories(b) build_controls(b) build_hitch(b, flags) build_lamps(b) if flags["loose_lamp"]: for v in b.groups["lamp"]: v.co.y -= LOOSE_LAMP # stand the tractor in the ruts for v in set(b.groups["tr"]) | set(b.groups["lamp"]): v.co.z += Z0 bmesh.ops.remove_doubles(bm, verts=list(bm.verts), dist=DOUBLES_EPS) bmesh.ops.dissolve_degenerate(bm, dist=1e-6) triangulate_ngons(bm) pack_uvs(bm) bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces)) bm.edges.index_update() for face in bm.faces: face.smooth = True for edge in bm.edges: mats = {f.material_index for f in edge.link_faces} if len(mats) > 1 or not edge.is_manifold or len(edge.link_faces) != 2: edge.smooth = False else: edge.smooth = edge.calc_face_angle() < math.radians(35.0) edge_wear(bm) me = bpy.data.meshes.new(name) bm.to_mesh(me) me.update() finally: bm.free() obj = bpy.data.objects.new(name, me) bpy.context.collection.objects.link(obj) return obj # -------------------------------------------------------------------------- # Materials # -------------------------------------------------------------------------- def _node(nt, kind, **inputs): n = nt.nodes.new(kind) for k, v in inputs.items(): n.inputs[k].default_value = v return n def _ramp(nt, a, ca, b, cb): r = nt.nodes.new("ShaderNodeValToRGB") r.color_ramp.elements[0].position = a r.color_ramp.elements[0].color = ca r.color_ramp.elements[1].position = b r.color_ramp.elements[1].color = cb return r def _mix(nt, fac_socket, c1, c2): mx = nt.nodes.new("ShaderNodeMixRGB") mx.blend_type = "MIX" if isinstance(fac_socket, float): mx.inputs[0].default_value = fac_socket else: nt.links.new(fac_socket, mx.inputs[0]) for i, c in ((1, c1), (2, c2)): if isinstance(c, tuple): mx.inputs[i].default_value = c else: nt.links.new(c, mx.inputs[i]) return mx.outputs[0] def _math(nt, op, a, b, clamp=False): m = nt.nodes.new("ShaderNodeMath") m.operation = op m.use_clamp = clamp for i, v in ((0, a), (1, b)): if isinstance(v, (int, float)): m.inputs[i].default_value = float(v) else: nt.links.new(v, m.inputs[i]) return m.outputs[0] def _gray(v): return (v, v, v, 1.0) def _stretched(nt, coord_socket, sx, sy, sz): sep = nt.nodes.new("ShaderNodeSeparateXYZ") nt.links.new(coord_socket, sep.inputs[0]) comb = nt.nodes.new("ShaderNodeCombineXYZ") for i, (k, sc) in enumerate((("X", sx), ("Y", sy), ("Z", sz))): nt.links.new(_math(nt, "MULTIPLY", sep.outputs[k], sc), comb.inputs[i]) return comb.outputs[0] def worn(name, col_a, col_b, rough, metallic=0.0, primer=None, bare=None, chip=0.0, rust=0.0, rust_col=(0.20, 0.085, 0.035, 1.0), mud_top=0.30, mud_amt=0.8, mud_col=(0.115, 0.082, 0.055, 1.0), spray=0.0, bump=0.10, bump_scale=300.0, coat=0.0, oil=0.0, fade=0.0, grime=0.0, scuff=0.0, pack=0.0, pack_col=(0.150, 0.108, 0.070, 1.0)): """A designed, weathered surface: a per-part tone between two colours, paint chipped through to primer and to bare metal where the edge-wear attribute is high, sparse scuffs through to primer on the faces, paint faded where it faces the sky, grime darkening toward the ground, rust streaks running down, mud rising from the ground with a splashed edge and flecks above it, oily darkening, mud packed in clumps where the TreadMud attribute marks a tyre's crown between its bars, roughness breakup and a fine bump.""" mat = bpy.data.materials.new(name) mat.use_nodes = True nt = mat.node_tree bsdf = nt.nodes["Principled BSDF"] if coat > 0.0 and "Coat Weight" in bsdf.inputs: bsdf.inputs["Coat Weight"].default_value = coat coord = nt.nodes.new("ShaderNodeTexCoord") obj = coord.outputs["Object"] sep = nt.nodes.new("ShaderNodeSeparateXYZ") nt.links.new(obj, sep.inputs[0]) attr = nt.nodes.new("ShaderNodeAttribute") attr.attribute_name = TONE col = _mix(nt, attr.outputs["Fac"], col_a, col_b) # broad mottle, so no panel is one flat colour mot = _node(nt, "ShaderNodeTexNoise", Scale=3.5, Detail=4.0) nt.links.new(obj, mot.inputs["Vector"]) mr = _ramp(nt, 0.3, _gray(0.82), 0.7, _gray(1.08)) nt.links.new(mot.outputs["Fac"], mr.inputs["Fac"]) mm = nt.nodes.new("ShaderNodeMixRGB") mm.blend_type = "MULTIPLY" mm.inputs[0].default_value = 1.0 nt.links.new(col, mm.inputs[1]) nt.links.new(mr.outputs["Color"], mm.inputs[2]) col = mm.outputs[0] if fade > 0.0: geo = nt.nodes.new("ShaderNodeNewGeometry") gsep = nt.nodes.new("ShaderNodeSeparateXYZ") nt.links.new(geo.outputs["Normal"], gsep.inputs[0]) up = _ramp(nt, 0.35, _gray(0.0), 0.95, _gray(fade)) nt.links.new(gsep.outputs["Z"], up.inputs["Fac"]) fn = _node(nt, "ShaderNodeTexNoise", Scale=5.0, Detail=5.0) nt.links.new(obj, fn.inputs["Vector"]) fr = _ramp(nt, 0.30, _gray(0.45), 0.70, _gray(1.0)) nt.links.new(fn.outputs["Fac"], fr.inputs["Fac"]) faded = nt.nodes.new("ShaderNodeMixRGB") faded.blend_type = "MIX" faded.inputs[0].default_value = 0.55 nt.links.new(col, faded.inputs[1]) faded.inputs[2].default_value = (0.30, 0.36, 0.26, 1.0) col = _mix(nt, _math(nt, "MULTIPLY", up.outputs["Color"], fr.outputs["Color"]), col, faded.outputs[0]) if grime > 0.0: gm = _node(nt, "ShaderNodeMapRange") gm.inputs["From Min"].default_value = SOIL_T gm.inputs["From Max"].default_value = SOIL_T + 1.30 gm.inputs["To Min"].default_value = grime gm.inputs["To Max"].default_value = 0.0 nt.links.new(sep.outputs["Z"], gm.inputs["Value"]) gn2 = _node(nt, "ShaderNodeTexNoise", Scale=3.0, Detail=6.0) nt.links.new(obj, gn2.inputs["Vector"]) gmix = _math(nt, "MULTIPLY", gm.outputs["Result"], _math(nt, "ADD", _math(nt, "MULTIPLY", gn2.outputs["Fac"], 1.2), 0.2)) col = _mix(nt, _math(nt, "MINIMUM", gmix, 1.0), col, (0.040, 0.032, 0.022, 1.0)) rough_s = None metal_s = None if chip > 0.0: wear = nt.nodes.new("ShaderNodeAttribute") wear.attribute_name = WEAR # chips come in patches along an edge, not as an unbroken line cn = _node(nt, "ShaderNodeTexNoise", Scale=22.0, Detail=10.0, Roughness=0.65) nt.links.new(obj, cn.inputs["Vector"]) gate = _math(nt, "MULTIPLY", _math(nt, "SUBTRACT", cn.outputs["Fac"], 0.50), 6.0, clamp=True) cm = _math(nt, "MULTIPLY", wear.outputs["Fac"], gate) cm = _math(nt, "MULTIPLY", cm, chip) c1 = _ramp(nt, 0.30, _gray(0.0), 0.36, _gray(1.0)) nt.links.new(cm, c1.inputs["Fac"]) c2 = _ramp(nt, 0.58, _gray(0.0), 0.64, _gray(1.0)) nt.links.new(cm, c2.inputs["Fac"]) if scuff > 0.0: sn2 = _node(nt, "ShaderNodeTexNoise", Scale=26.0, Detail=12.0, Roughness=0.7) nt.links.new(obj, sn2.inputs["Vector"]) s2 = _ramp(nt, 0.678, _gray(0.0), 0.70, _gray(scuff)) nt.links.new(sn2.outputs["Fac"], s2.inputs["Fac"]) col = _mix(nt, s2.outputs["Color"], col, (0.12, 0.080, 0.055, 1.0)) col = _mix(nt, c1.outputs["Color"], col, primer) col = _mix(nt, c2.outputs["Color"], col, bare) metal_s = _math(nt, "MULTIPLY", c2.outputs["Color"], 0.85) rough_s = _math(nt, "MULTIPLY", c2.outputs["Color"], -0.15) if rust > 0.0: sv = _stretched(nt, obj, 1.0, 1.0, 0.06) sn = _node(nt, "ShaderNodeTexNoise", Scale=48.0, Detail=3.0) nt.links.new(sv, sn.inputs["Vector"]) sr = _ramp(nt, 0.57, _gray(0.0), 0.68, _gray(rust)) nt.links.new(sn.outputs["Fac"], sr.inputs["Fac"]) gn = _node(nt, "ShaderNodeTexNoise", Scale=2.5, Detail=2.0) nt.links.new(obj, gn.inputs["Vector"]) gr = _ramp(nt, 0.40, _gray(0.0), 0.58, _gray(1.0)) nt.links.new(gn.outputs["Fac"], gr.inputs["Fac"]) rm = _math(nt, "MULTIPLY", sr.outputs["Color"], gr.outputs["Color"]) col = _mix(nt, rm, col, rust_col) if oil > 0.0: on = _node(nt, "ShaderNodeTexNoise", Scale=6.0, Detail=6.0) nt.links.new(obj, on.inputs["Vector"]) orr = _ramp(nt, 0.45, _gray(0.0), 0.70, _gray(oil)) nt.links.new(on.outputs["Fac"], orr.inputs["Fac"]) col = _mix(nt, orr.outputs["Color"], col, (0.02, 0.018, 0.015, 1.0)) pk_m = None if pack > 0.0: pk = nt.nodes.new("ShaderNodeAttribute") pk.attribute_name = PACK pn = _node(nt, "ShaderNodeTexNoise", Scale=16.0, Detail=6.0, Roughness=0.6) nt.links.new(obj, pn.inputs["Vector"]) pg = _ramp(nt, 0.36, _gray(0.0), 0.46, _gray(pack)) nt.links.new(pn.outputs["Fac"], pg.inputs["Fac"]) pk_m = _math(nt, "MULTIPLY", pk.outputs["Fac"], pg.outputs["Color"]) pc = _ramp(nt, 0.35, (0.62, 0.62, 0.62, 1.0), 0.75, (1.12, 1.12, 1.12, 1.0)) nt.links.new(pn.outputs["Fac"], pc.inputs["Fac"]) tinted = nt.nodes.new("ShaderNodeMixRGB") tinted.blend_type = "MULTIPLY" tinted.inputs[0].default_value = 1.0 tinted.inputs[1].default_value = pack_col nt.links.new(pc.outputs["Color"], tinted.inputs[2]) col = _mix(nt, pk_m, col, tinted.outputs[0]) # mud from the ground up, a splashed edge, and flecks above it hmap = _node(nt, "ShaderNodeMapRange") hmap.inputs["From Min"].default_value = SOIL_T - 0.02 hmap.inputs["From Max"].default_value = SOIL_T + mud_top hmap.inputs["To Min"].default_value = 1.0 hmap.inputs["To Max"].default_value = 0.0 nt.links.new(sep.outputs["Z"], hmap.inputs["Value"]) dn = _node(nt, "ShaderNodeTexNoise", Scale=7.0, Detail=8.0, Roughness=0.6) nt.links.new(obj, dn.inputs["Vector"]) ms = _math(nt, "ADD", hmap.outputs["Result"], _math(nt, "MULTIPLY", dn.outputs["Fac"], 0.55)) mr2 = _ramp(nt, 0.82, _gray(0.0), 0.90, _gray(mud_amt)) nt.links.new(ms, mr2.inputs["Fac"]) mud = mr2.outputs["Color"] if spray > 0.0: vor = nt.nodes.new("ShaderNodeTexVoronoi") vor.inputs["Scale"].default_value = 18.0 nt.links.new(obj, vor.inputs["Vector"]) spr = _ramp(nt, 0.10, _gray(spray), 0.17, _gray(0.0)) nt.links.new(vor.outputs["Distance"], spr.inputs["Fac"]) hm2 = _node(nt, "ShaderNodeMapRange") hm2.inputs["From Min"].default_value = SOIL_T + mud_top * 0.6 hm2.inputs["From Max"].default_value = SOIL_T + mud_top * 2.4 hm2.inputs["To Min"].default_value = 1.0 hm2.inputs["To Max"].default_value = 0.0 nt.links.new(sep.outputs["Z"], hm2.inputs["Value"]) sp = _math(nt, "MULTIPLY", spr.outputs["Color"], hm2.outputs["Result"]) mud = _math(nt, "MAXIMUM", mud, sp) col = _mix(nt, mud, col, mud_col) nt.links.new(col, bsdf.inputs["Base Color"]) rr = _ramp(nt, 0.30, _gray(max(0.03, rough - 0.08)), 0.70, _gray(min(0.95, rough + 0.08))) nt.links.new(dn.outputs["Fac"], rr.inputs["Fac"]) rs = _math(nt, "ADD", rr.outputs["Color"], _math(nt, "MULTIPLY", mud, 0.6), clamp=True) if rough_s is not None: rs = _math(nt, "ADD", rs, rough_s, clamp=True) nt.links.new(rs, bsdf.inputs["Roughness"]) if metal_s is not None: nt.links.new(_math(nt, "ADD", metal_s, metallic, clamp=True), bsdf.inputs["Metallic"]) else: bsdf.inputs["Metallic"].default_value = metallic if bump > 0.0: bn = _node(nt, "ShaderNodeTexNoise", Scale=bump_scale, Detail=6.0) nt.links.new(obj, bn.inputs["Vector"]) bh = _math(nt, "ADD", bn.outputs["Fac"], _math(nt, "MULTIPLY", mud, 0.8)) if pk_m is not None: bh = _math(nt, "ADD", bh, _math(nt, "MULTIPLY", pk_m, 1.5)) bp = _node(nt, "ShaderNodeBump", Strength=bump) bp.inputs["Distance"].default_value = 0.0008 nt.links.new(bh, bp.inputs["Height"]) nt.links.new(bp.outputs["Normal"], bsdf.inputs["Normal"]) return mat def add_studio(mat, color, env, stops): """A studio carried in the material (after espresso-machine): the world-space reflection vector looks up a soft band of softboxes round the horizon, brighter on the key's side, added as emission. A metal on a dark stage mirrors the dark stage and reads as grey plastic without it.""" nt = mat.node_tree bsdf = nt.nodes["Principled BSDF"] out = nt.nodes["Material Output"] coord = nt.nodes.new("ShaderNodeTexCoord") sep = nt.nodes.new("ShaderNodeSeparateXYZ") nt.links.new(coord.outputs["Reflection"], sep.inputs[0]) mz = nt.nodes.new("ShaderNodeMapRange") mz.inputs["From Min"].default_value = -1.0 mz.inputs["From Max"].default_value = 1.0 nt.links.new(sep.outputs["Z"], mz.inputs["Value"]) ramp = nt.nodes.new("ShaderNodeValToRGB") cr = ramp.color_ramp cr.elements[0].position, cr.elements[0].color = stops[0][0], (stops[0][1],) * 3 + (1.0,) cr.elements[1].position, cr.elements[1].color = stops[-1][0], (stops[-1][1],) * 3 + (1.0,) for pos, val in stops[1:-1]: e = cr.elements.new(pos) e.color = (val, val, val, 1.0) nt.links.new(mz.outputs["Result"], ramp.inputs["Fac"]) mx = nt.nodes.new("ShaderNodeMapRange") mx.inputs["From Min"].default_value = -1.0 mx.inputs["From Max"].default_value = 1.0 mx.inputs["To Min"].default_value = 1.0 mx.inputs["To Max"].default_value = 0.40 nt.links.new(sep.outputs["X"], mx.inputs["Value"]) side = _math(nt, "MULTIPLY", mx.outputs["Result"], env) tint = nt.nodes.new("ShaderNodeMixRGB") tint.blend_type = "MULTIPLY" tint.inputs[0].default_value = 1.0 tint.inputs[2].default_value = color nt.links.new(ramp.outputs["Color"], tint.inputs[1]) em = nt.nodes.new("ShaderNodeEmission") nt.links.new(tint.outputs[0], em.inputs["Color"]) nt.links.new(side, em.inputs["Strength"]) add = nt.nodes.new("ShaderNodeAddShader") nt.links.new(bsdf.outputs["BSDF"], add.inputs[0]) nt.links.new(em.outputs["Emission"], add.inputs[1]) nt.links.new(add.outputs["Shader"], out.inputs["Surface"]) return mat STUDIO = [(0.0, 0.02), (0.30, 0.05), (0.42, 0.45), (0.50, 1.0), (0.62, 0.30), (1.0, 0.12)] def soil_material(): """Farmyard mud: a brown field with clods and straw flecks, darker and glossier where it is wet down in the ruts, and the rear tyres' chevron bars printed in the rut floors (apex to the rear: the bar's point meets the ground first).""" mat = bpy.data.materials.new("FarmyardMud") mat.use_nodes = True nt = mat.node_tree bsdf = nt.nodes["Principled BSDF"] coord = nt.nodes.new("ShaderNodeTexCoord") obj = coord.outputs["Object"] sep = nt.nodes.new("ShaderNodeSeparateXYZ") nt.links.new(obj, sep.inputs[0]) n1 = _node(nt, "ShaderNodeTexNoise", Scale=4.0, Detail=8.0, Roughness=0.6) nt.links.new(obj, n1.inputs["Vector"]) base = _ramp(nt, 0.30, (0.105, 0.072, 0.045, 1.0), 0.70, (0.175, 0.122, 0.075, 1.0)) nt.links.new(n1.outputs["Fac"], base.inputs["Fac"]) col = base.outputs["Color"] # wet: the rut floors and the dip, darker and glossy wet = _node(nt, "ShaderNodeMapRange") wet.inputs["From Min"].default_value = Z0 + 0.010 wet.inputs["From Max"].default_value = Z0 + 0.001 wet.inputs["To Min"].default_value = 0.0 wet.inputs["To Max"].default_value = 1.0 nt.links.new(sep.outputs["Z"], wet.inputs["Value"]) wn = _node(nt, "ShaderNodeTexNoise", Scale=9.0, Detail=5.0) nt.links.new(obj, wn.inputs["Vector"]) # only up-facing mud in the ruts is wet, not the patch's skirt geo = nt.nodes.new("ShaderNodeNewGeometry") gsep = nt.nodes.new("ShaderNodeSeparateXYZ") nt.links.new(geo.outputs["Normal"], gsep.inputs[0]) up = _ramp(nt, 0.70, _gray(0.0), 0.92, _gray(1.0)) nt.links.new(gsep.outputs["Z"], up.inputs["Fac"]) wet_m = _math(nt, "MULTIPLY", wet.outputs["Result"], up.outputs["Color"]) wf = _math(nt, "MULTIPLY", wet_m, _math(nt, "ADD", wn.outputs["Fac"], 0.35)) wf = _math(nt, "MINIMUM", wf, 1.0) col = _mix(nt, wf, col, (0.055, 0.040, 0.028, 1.0)) # straw flecks on the berms and the field sv = _stretched(nt, obj, 1.0, 9.0, 1.0) st = _node(nt, "ShaderNodeTexNoise", Scale=28.0, Detail=2.0) nt.links.new(sv, st.inputs["Vector"]) sr = _ramp(nt, 0.68, _gray(0.0), 0.72, _gray(0.7)) nt.links.new(st.outputs["Fac"], sr.inputs["Fac"]) sm = _math(nt, "MULTIPLY", sr.outputs["Color"], _math(nt, "SUBTRACT", 1.0, wet_m)) col = _mix(nt, sm, col, (0.42, 0.33, 0.17, 1.0)) nt.links.new(col, bsdf.inputs["Base Color"]) nt.links.new(_math(nt, "SUBTRACT", 0.92, _math(nt, "MULTIPLY", wf, 0.72)), bsdf.inputs["Roughness"]) # chevron prints in the rut floors: u = (x - K|d|) / P, staggered half a pitch ay = _math(nt, "ABSOLUTE", sep.outputs["Y"], 0.0) dd = _math(nt, "SUBTRACT", ay, RUT_Y) ad = _math(nt, "ABSOLUTE", dd, 0.0) k_run = LUG_SWEEP * R_RT / (LUG_END + LUG_X) pitch = 2.0 * math.pi * R_RT / LUGS_PER_HALF u = _math(nt, "DIVIDE", _math(nt, "SUBTRACT", sep.outputs["X"], _math(nt, "MULTIPLY", ad, k_run)), pitch) u = _math(nt, "ADD", u, _math(nt, "MULTIPLY", _math(nt, "GREATER_THAN", dd, 0.0), 0.5)) fr = _math(nt, "FRACT", u, 0.0) band = _ramp(nt, 0.0, _gray(1.0), 0.28, _gray(0.0)) nt.links.new(fr, band.inputs["Fac"]) inside = _ramp(nt, 0.118, _gray(1.0), 0.138, _gray(0.0)) nt.links.new(ad, inside.inputs["Fac"]) lug = _math(nt, "MULTIPLY", _math(nt, "MULTIPLY", band.outputs["Color"], inside.outputs["Color"]), wet_m) clod = nt.nodes.new("ShaderNodeTexVoronoi") clod.inputs["Scale"].default_value = 38.0 nt.links.new(obj, clod.inputs["Vector"]) h = _math(nt, "SUBTRACT", _math(nt, "MULTIPLY", clod.outputs["Distance"], 0.6), _math(nt, "MULTIPLY", lug, 1.2)) h = _math(nt, "ADD", h, _math(nt, "MULTIPLY", n1.outputs["Fac"], 0.5)) bp = _node(nt, "ShaderNodeBump", Strength=0.55) bp.inputs["Distance"].default_value = 0.006 nt.links.new(h, bp.inputs["Height"]) nt.links.new(bp.outputs["Normal"], bsdf.inputs["Normal"]) return mat def water_material(): mat = bpy.data.materials.new("PuddleWater") mat.use_nodes = True nt = mat.node_tree bsdf = nt.nodes["Principled BSDF"] bsdf.inputs["Base Color"].default_value = (0.016, 0.012, 0.008, 1.0) bsdf.inputs["Roughness"].default_value = 0.06 coord = nt.nodes.new("ShaderNodeTexCoord") n = _node(nt, "ShaderNodeTexNoise", Scale=14.0, Detail=3.0) nt.links.new(coord.outputs["Object"], n.inputs["Vector"]) bp = _node(nt, "ShaderNodeBump", Strength=0.08) bp.inputs["Distance"].default_value = 0.002 nt.links.new(n.outputs["Fac"], bp.inputs["Height"]) nt.links.new(bp.outputs["Normal"], bsdf.inputs["Normal"]) add_studio(mat, (0.30, 0.26, 0.21, 1.0), 0.10, STUDIO) return mat def glass_material(): mat = bpy.data.materials.new("LampGlass") mat.use_nodes = True nt = mat.node_tree bsdf = nt.nodes["Principled BSDF"] attr = nt.nodes.new("ShaderNodeAttribute") attr.attribute_name = TONE col = _mix(nt, attr.outputs["Fac"], (0.60, 0.58, 0.46, 1.0), (0.55, 0.030, 0.020, 1.0)) nt.links.new(col, bsdf.inputs["Base Color"]) bsdf.inputs["Roughness"].default_value = 0.08 if "Specular IOR Level" in bsdf.inputs: bsdf.inputs["Specular IOR Level"].default_value = 0.8 add_studio(mat, (0.70, 0.70, 0.66, 1.0), 0.55, STUDIO) return mat def core_material(): """Radiator core: fine horizontal fins, black-painted copper.""" mat = bpy.data.materials.new("RadiatorCore") mat.use_nodes = True nt = mat.node_tree bsdf = nt.nodes["Principled BSDF"] coord = nt.nodes.new("ShaderNodeTexCoord") wv = nt.nodes.new("ShaderNodeTexWave") wv.wave_type = "BANDS" wv.bands_direction = "Z" wv.inputs["Scale"].default_value = 160.0 nt.links.new(coord.outputs["Object"], wv.inputs["Vector"]) r = _ramp(nt, 0.35, (0.012, 0.012, 0.012, 1.0), 0.65, (0.075, 0.068, 0.058, 1.0)) nt.links.new(wv.outputs["Fac"], r.inputs["Fac"]) nt.links.new(r.outputs["Color"], bsdf.inputs["Base Color"]) bsdf.inputs["Roughness"].default_value = 0.65 bsdf.inputs["Metallic"].default_value = 0.3 bp = _node(nt, "ShaderNodeBump", Strength=0.6) bp.inputs["Distance"].default_value = 0.0015 nt.links.new(wv.outputs["Fac"], bp.inputs["Height"]) nt.links.new(bp.outputs["Normal"], bsdf.inputs["Normal"]) return mat def set_materials(): """Slot order: body paint, wheel paint, rubber, cast iron, steel, zinc, black enamel, glass, exhaust, radiator core, soil, water. Shared by the check and the render.""" primer = (0.22, 0.080, 0.048, 1.0) bare = (0.32, 0.31, 0.30, 1.0) paint = worn("BodyPaint", (0.062, 0.240, 0.092, 1.0), (0.052, 0.212, 0.080, 1.0), 0.44, primer=primer, bare=bare, chip=1.0, rust=0.95, mud_top=0.62, mud_amt=0.9, spray=1.0, coat=0.12, fade=0.8, grime=0.55, scuff=0.9) add_studio(paint, (0.30, 0.42, 0.34, 1.0), 0.06, STUDIO) wheel = worn("WheelPaint", (0.58, 0.52, 0.37, 1.0), (0.52, 0.46, 0.32, 1.0), 0.48, primer=primer, bare=bare, chip=1.0, rust=0.55, grime=0.35, scuff=0.5, mud_top=0.30, mud_amt=0.95, spray=0.9) rubber = worn("TyreRubber", (0.030, 0.029, 0.028, 1.0), (0.040, 0.038, 0.036, 1.0), 0.80, mud_top=0.22, mud_amt=0.95, mud_col=(0.10, 0.072, 0.048, 1.0), spray=0.7, bump=0.15, bump_scale=500.0, pack=1.0) iron = worn("CastIron", (0.070, 0.078, 0.072, 1.0), (0.058, 0.064, 0.060, 1.0), 0.62, metallic=0.35, primer=(0.20, 0.08, 0.05, 1.0), bare=(0.24, 0.23, 0.22, 1.0), chip=0.8, rust=0.6, mud_top=0.30, mud_amt=0.85, spray=0.8, bump=0.25, bump_scale=420.0, oil=0.6) add_studio(iron, (0.40, 0.40, 0.40, 1.0), 0.06, STUDIO) steel = worn("ForgedSteel", (0.16, 0.155, 0.15, 1.0), (0.12, 0.115, 0.11, 1.0), 0.48, metallic=0.9, rust=0.8, rust_col=(0.22, 0.10, 0.045, 1.0), mud_top=0.30, mud_amt=0.8, spray=0.6, oil=0.4) add_studio(steel, (0.45, 0.45, 0.46, 1.0), 0.22, STUDIO) zinc = worn("ZincPlate", (0.62, 0.62, 0.60, 1.0), (0.54, 0.54, 0.52, 1.0), 0.30, metallic=1.0, rust=0.3, mud_top=0.20, mud_amt=0.6, bump=0.05) add_studio(zinc, (0.66, 0.67, 0.70, 1.0), 0.55, STUDIO) black = worn("BlackEnamel", (0.022, 0.022, 0.024, 1.0), (0.032, 0.032, 0.034, 1.0), 0.40, primer=(0.20, 0.08, 0.05, 1.0), bare=(0.30, 0.29, 0.28, 1.0), chip=0.8, rust=0.4, mud_top=0.25, mud_amt=0.7) add_studio(black, (0.35, 0.35, 0.36, 1.0), 0.10, STUDIO) glass = glass_material() exhaust = worn("ExhaustIron", (0.20, 0.090, 0.040, 1.0), (0.12, 0.070, 0.045, 1.0), 0.78, metallic=0.3, rust=1.0, rust_col=(0.30, 0.13, 0.05, 1.0), mud_top=0.2, mud_amt=0.4, bump=0.35, bump_scale=260.0, oil=0.5) core = core_material() soil = soil_material() water = water_material() return (paint, wheel, rubber, iron, steel, zinc, black, glass, exhaust, core, soil, water) def assign_slots(obj, wanted): # Do not materials.clear() - that resets polygon material_index to 0. mats = obj.data.materials for i, mat in enumerate(wanted): if i < len(mats): mats[i] = mat else: mats.append(mat) # -------------------------------------------------------------------------- # Audits # -------------------------------------------------------------------------- def vert_bbox(me): # read the vertices: bound_box is cached and an in-place edit does not refresh it co = np.empty(len(me.vertices) * 3) me.vertices.foreach_get("co", co) co = co.reshape(-1, 3) lo, hi = co.min(axis=0), co.max(axis=0) return (lo[0], lo[1], lo[2], hi[0], hi[1], hi[2]) def uv_stats(mesh): uv = mesh.uv_layers.active if uv is None: return 0.0, 0.0, 1.0, 1.0, 0, 1.0 data = uv.data us = [loop.uv[0] for loop in data] vs = [loop.uv[1] for loop in data] aabbs = [] for poly in mesh.polygons: pu = [data[i].uv[0] for i in poly.loop_indices] pv = [data[i].uv[1] for i in poly.loop_indices] aabbs.append((min(pu), min(pv), max(pu), max(pv))) aabbs.sort() overlap = 0.0 for i, a in enumerate(aabbs): for j in range(i + 1, len(aabbs)): b = aabbs[j] if b[0] >= a[2]: break x0 = max(a[0], b[0]) y0 = max(a[1], b[1]) x1 = min(a[2], b[2]) y1 = min(a[3], b[3]) overlap += max(0.0, x1 - x0) * max(0.0, y1 - y0) return min(us), min(vs), max(us), max(vs), overlap, len(aabbs) def face_area(me, poly): vs = [me.vertices[i].co for i in poly.vertices] if len(vs) < 3: return 0.0 v0 = vs[0] area = 0.0 for i in range(1, len(vs) - 1): area += (vs[i] - v0).cross(vs[i + 1] - v0).length * 0.5 return area def hygiene_audit(me): # Combinatorics match examples/mesh-hygiene-audit.audit (copied, not imported). ngons = sum(1 for p in me.polygons if len(p.vertices) > 4) zero_area = sum(1 for p in me.polygons if face_area(me, p) <= AREA_EPS) bm = bmesh.new() try: bm.from_mesh(me) loose_v = sum(1 for v in bm.verts if len(v.link_edges) == 0) loose_e = sum(1 for e in bm.edges if len(e.link_faces) == 0) nonman = sum(1 for e in bm.edges if not e.is_manifold) ret = bmesh.ops.find_doubles(bm, verts=list(bm.verts), dist=DOUBLES_EPS) doubles = len(ret.get("targetmap") or {}) finally: bm.free() return {"ngons": ngons, "loose_v": loose_v, "loose_e": loose_e, "nonman": nonman, "zero_area": zero_area, "doubles": doubles} def shells(me): neighbors = [[] for _ in range(len(me.vertices))] for edge in me.edges: a, b = edge.vertices neighbors[a].append(b) neighbors[b].append(a) seen = [False] * len(me.vertices) groups = [] for start in range(len(me.vertices)): if seen[start]: continue seen[start] = True stack = [start] group = [] while stack: cur = stack.pop() group.append(cur) for nxt in neighbors[cur]: if not seen[nxt]: seen[nxt] = True stack.append(nxt) groups.append(group) return groups def zfight_pairs(me, groups, report=None): """Coplanar face pairs from *different shells* (copied from showcase/grindstone).""" owner = {} for si, g in enumerate(groups): for vi in g: owner[vi] = si faces = [(p.normal.copy(), p.center.copy(), owner.get(p.vertices[0], -1)) for p in me.polygons] kd = KDTree(len(faces)) for i, (_n, c, _s) in enumerate(faces): kd.insert(c, i) kd.balance() hits = 0 for i, (ni, ci, si) in enumerate(faces): for _co, j, _d in kd.find_range(ci, COPLANAR_CENTRE_MAX): if j <= i: continue nj, cj, sj = faces[j] if si == sj: continue if abs(abs(ni.dot(nj)) - 1.0) > COPLANAR_NORMAL_EPS: continue if abs(ni.dot(cj - ci)) > COPLANAR_PLANE_EPS: continue hits += 1 if report is not None: report.append((tuple(round(v, 4) for v in ci), tuple(round(v, 4) for v in cj), tuple(round(v, 3) for v in ni), me.polygons[i].material_index, me.polygons[j].material_index)) return hits def shell_polys(me, groups): owner = [0] * len(me.vertices) for si, g in enumerate(groups): for vi in g: owner[vi] = si polys = [[] for _ in groups] for p in me.polygons: polys[owner[p.vertices[0]]].append(p) return polys class Shell: def __init__(self, me, idx, verts, polys, tags): self.idx = idx self.verts = verts pts = [me.vertices[i].co.copy() for i in verts] self.pts = pts self.lo = Vector((min(p.x for p in pts), min(p.y for p in pts), min(p.z for p in pts))) self.hi = Vector((max(p.x for p in pts), max(p.y for p in pts), max(p.z for p in pts))) self.size = self.hi - self.lo self.centre = (self.lo + self.hi) * 0.5 self.mean = sum(pts, Vector()) / len(pts) mats, tg = {}, {} for p in polys: mats[p.material_index] = mats.get(p.material_index, 0) + 1 t = tags[p.index] tg[t] = tg.get(t, 0) + 1 self.mat = max(mats, key=mats.get) if mats else None self.mats = set(mats) self.tag = max(tg, key=tg.get) if tg else T_NONE remap = {vi: n for n, vi in enumerate(verts)} self.tri_idx = [[remap[v] for v in p.vertices] for p in polys] self.tree = BVHTree.FromPolygons([tuple(p) for p in pts], self.tri_idx) self.polys = polys def pca(pts): """(mean, eigenvalues ascending, eigenvectors as columns).""" p = np.array([tuple(v) for v in pts], dtype=np.float64) c = p.mean(axis=0) q = p - c w, vecs = np.linalg.eigh(q.T @ q / len(p)) return Vector(c), w, vecs def lathe_axis(pts): """A body of revolution's axis: the eigenvector whose eigenvalue stands apart from the other two (the two radial ones are equal).""" c, w, vecs = pca(pts) k = 2 if (w[1] - w[0]) < (w[2] - w[1]) else 0 a = Vector(vecs[:, k]) return c, a.normalized() def pca_line(pts): c, _w, vecs = pca(pts) return c, Vector(vecs[:, 2]).normalized() def classify(me): groups = shells(me) polys = shell_polys(me, groups) attr = me.attributes.get("part") tags = [0] * len(me.polygons) if attr is not None: tags = [d.value for d in attr.data] parts = [Shell(me, i, g, polys[i], tags) for i, g in enumerate(groups)] out = {"all": parts, "groups": groups} for key, t in (("soil", T_SOIL), ("water", T_WATER), ("tyres", T_TYRE), ("lugs", T_LUG), ("rims", T_RIM), ("hubs_f", T_HUB_F), ("hubs_r", T_HUB_R), ("stubs", T_STUB), ("axles", T_AXLE), ("pivot", T_PIVOT), ("bush", T_BUSH), ("eyes", T_EYE), ("pins", T_PIN)): out[key] = [s for s in parts if s.tag == t] out["tractor"] = [s for s in parts if s.tag not in (T_SOIL, T_WATER)] ax = out["axles"] out["y_mid"] = 0.5 * (ax[0].mean.y + ax[1].mean.y) if len(ax) == 2 else 0.0 ty = sorted(out["tyres"], key=lambda s: s.centre.x) out["rear_tyres"] = ty[:2] if len(ty) == TYRE_COUNT else [] out["front_tyres"] = ty[2:] if len(ty) == TYRE_COUNT else [] # every lug belongs to the rear tyre whose centre it is nearest out["lug_of"] = {} for lg in out["lugs"]: if out["rear_tyres"]: t = min(out["rear_tyres"], key=lambda s: (s.centre - lg.mean).length) out["lug_of"].setdefault(t.idx, []).append(lg) return out def soil_z(soil, x, y): hit = soil.tree.ray_cast(Vector((x, y, 5.0)), Vector((0.0, 0.0, -1.0)), 10.0) return hit[0].z if hit[0] is not None else None def support_audit(cls): """Per tyre (carcass and its lugs): the deepest vertex under the mud's surface, read by a ray down onto the soil shell.""" soil = cls["soil"][0] if len(cls["soil"]) == 1 else None res = [] if soil is None: return res for t in cls["tyres"]: pts = list(t.pts) + [p for lg in cls["lug_of"].get(t.idx, []) for p in lg.pts] best = -9.0 for p in pts: if p.z > soil.hi.z: continue gz = soil_z(soil, p.x, p.y) if gz is not None: best = max(best, gz - p.z) res.append(best) return res def coax(host_pts, part_pts): hc, ha = pca_line(host_pts) pc, pa = lathe_axis(part_pts) if pa.dot(ha) < 0.0: pa = -pa rel = pc - hc off = (rel - ha * rel.dot(ha)).length ang = math.degrees(math.acos(min(1.0, abs(pa.dot(ha))))) return off, ang def nearest(shells_, p): return min(shells_, key=lambda s: (s.mean - p).length) if shells_ else None def joint_audit(cls): """Hubs coaxial with their spindles and half-shafts; the pivot pin coaxial with both bushings; every link eye on a pin: the eye's centre on the pin's axis, its axis along the pin's, the pin past both faces.""" res = {"hubs": [], "bush": [], "eyes": []} for h in cls["hubs_f"]: st = nearest(cls["stubs"], h.mean) if st is not None: res["hubs"].append(("front",) + coax(st.pts, h.pts)) for h in cls["hubs_r"]: ax = nearest(cls["axles"], h.mean) if ax is not None: res["hubs"].append(("rear",) + coax(ax.pts, h.pts)) if len(cls["pivot"]) == 1: for bsh in cls["bush"]: res["bush"].append(coax(cls["pivot"][0].pts, bsh.pts)) pins = [(p,) + pca_line(p.pts) for p in cls["pins"]] for e in cls["eyes"]: ec, ea = lathe_axis(e.pts) best = None for p, pc, pa in pins: rel = ec - pc off = (rel - pa * rel.dot(pa)).length # coaxial pins either side: prefer the one whose span holds the eye pp = [(q - pc).dot(pa) for q in p.pts] along = rel.dot(pa) outside = max(0.0, min(pp) - along, along - max(pp)) score = off + 10.0 * outside if best is None or score < best[4]: best = (off, p, pc, pa, score) if best is None: res["eyes"].append((9.0, 90.0, -9.0)) continue off, p, pc, pa, _sc = best ang = math.degrees(math.acos(min(1.0, abs(ea.dot(pa))))) ep = [(q - pc).dot(pa) for q in e.pts] pp = [(q - pc).dot(pa) for q in p.pts] past = min(min(ep) - min(pp), max(pp) - max(ep)) res["eyes"].append((off, ang, past)) return res def rim_host_radius(rim, c, a, p): """The rim's outer surface at the vertex's own station and angle: a ray from outside toward the axis, along the vertex's radial.""" rel = p - c s = rel.dot(a) q = rel - a * s rho = q.length if rho < 1e-6: return None, rho u = q / rho origin = c + a * s + u * (rho + 0.03) hit, _n, _i, _d = rim.tree.ray_cast(origin, -u, 0.06) if hit is None: return None, rho return (hit - (c + a * s)).length, rho def tyre_seat_audit(cls): """Per tyre, per angular segment, the innermost vertices (the bead) against the rim's bead seat read by rays: the seat depth.""" seats, orphans = [], 0 for t in cls["tyres"]: rim = nearest(cls["rims"], t.mean) if rim is None: orphans += 1 continue c, a = lathe_axis(rim.pts) e1 = (any_perp(a) - a * any_perp(a).dot(a)).normalized() e2 = a.cross(e1) segs = TYRE_SEGS_R if t.size.z > 1.0 else TYRE_SEGS_F bins = {} for p in t.pts: rel = p - c q = rel - a * rel.dot(a) ang = math.atan2(q.dot(e2), q.dot(e1)) key = round(ang / (2.0 * math.pi / segs)) % segs bins.setdefault(key, []).append((q.length, p)) for key, lst in bins.items(): rmin = min(r for r, _p in lst) for r, p in lst: if r > rmin + 0.0002: continue hr, rho = rim_host_radius(rim, c, a, p) if hr is None: orphans += 1 continue seats.append(hr - rho) return seats, orphans def carcass_radius(tyre, c, a, p): """The carcass's outer surface at the vertex's own station and angle: a ray from outside toward the axis along the vertex's radial.""" rel = p - c s = rel.dot(a) q = rel - a * s rho = q.length u = q / rho origin = c + a * s + u * (rho + 0.08) hit = tyre.tree.ray_cast(origin, -u, 0.20)[0] if hit is None: return None, rho return (hit - (c + a * s)).length, rho def lug_audit(cls): """Every bar's root inside the carcass by a band, its crown proud of it; per rear tyre and half, the bars' pitch round the spin axis; each bar's handing (its apex ahead of its shoulder end in forward rolling, which turns both rear wheels the same way, about +Y).""" res = {"count": 0, "bite": [9.0, -9.0], "proud": 9.0, "orphans": 0, "pitch": 0.0, "hand": 180.0, "hand_max": -180.0, "per_tyre": []} for t in cls["rear_tyres"]: lugs = cls["lug_of"].get(t.idx, []) res["count"] += len(lugs) res["per_tyre"].append(len(lugs)) rim = nearest(cls["rims"], t.mean) if rim is None or not lugs: res["orphans"] += 1 continue c, a = lathe_axis(rim.pts) halves = {1: [], -1: []} for lg in lugs: deep, proud = -9.0, -9.0 for p in lg.pts: # inside or out by the radial ray; how far by the nearest # carcass face, since down the shoulder the carcass normal # runs nearly along the axis, not the radius hr, rho = carcass_radius(t, c, a, p) if hr is None: continue d = (t.tree.find_nearest(p)[0] - p).length sd = d if rho < hr else -d deep = max(deep, sd) proud = max(proud, -sd) if deep < -8.0: res["orphans"] += 1 res["bite"][0] = min(res["bite"][0], deep) res["bite"][1] = max(res["bite"][1], deep) res["proud"] = min(res["proud"], proud) dy = [p.y - c.y for p in lg.pts] h = 1 if sum(dy) > 0.0 else -1 # the spin angle about +Y: from +Z toward +X th = [math.atan2(p.x - c.x, p.z - c.z) for p in lg.pts] order = sorted(range(len(lg.pts)), key=lambda i: h * dy[i]) ce = order[:6] sh = order[-6:] def mean_ang(idx): return math.atan2(sum(math.sin(th[i]) for i in idx), sum(math.cos(th[i]) for i in idx)) d = math.degrees((mean_ang(ce) - mean_ang(sh) + math.pi) % (2.0 * math.pi) - math.pi) res["hand"] = min(res["hand"], d) res["hand_max"] = max(res["hand_max"], d) m = lg.mean - c halves[h].append(math.atan2(m.x, m.z)) for h, angs in halves.items(): if len(angs) < 2: res["pitch"] = 99.0 continue ts = sorted(angs) want = 360.0 / len(ts) for x0, x1 in zip(ts, ts[1:] + [ts[0] + 2.0 * math.pi]): res["pitch"] = max(res["pitch"], abs(math.degrees(x1 - x0) - want)) return res def mirror_audit(cls): """Every body-paint vertex against its mirror partner across the tractor's centre plane (half-way between the rear half-shafts).""" y0 = cls["y_mid"] pts = [p for s in cls["tractor"] if s.mat == PAINT_IDX for p in s.pts] if not pts: return 9.0, 0 kd = KDTree(len(pts)) for i, p in enumerate(pts): kd.insert(p, i) kd.balance() worst = 0.0 for p in pts: _co, _i, d = kd.find(Vector((p.x, 2.0 * y0 - p.y, p.z))) worst = max(worst, d) return worst, len(pts) def wheel_audit(cls): """Tyres paired front and rear: mirror of each pair, wheelbase and both tracks from the carcasses' own centres; each rear tyre's diameter over its lugs about its rim's axis.""" y0 = cls["y_mid"] res = {"mirror": 9.0, "wheelbase": 0.0, "track_f": 0.0, "track_r": 0.0, "diam": []} if len(cls["tyres"]) != TYRE_COUNT: return res rear, front = cls["rear_tyres"], cls["front_tyres"] worst = 0.0 for pair in (front, rear): L, R = sorted(pair, key=lambda s: -s.centre.y) worst = max(worst, abs((L.centre.y - y0) - (y0 - R.centre.y)), abs(L.centre.x - R.centre.x), abs(L.lo.z - R.lo.z), abs(L.size.x - R.size.x), abs(L.size.y - R.size.y), abs(L.size.z - R.size.z)) res["mirror"] = worst res["wheelbase"] = (0.5 * (front[0].centre.x + front[1].centre.x) - 0.5 * (rear[0].centre.x + rear[1].centre.x)) res["track_f"] = abs(front[0].centre.y - front[1].centre.y) res["track_r"] = abs(rear[0].centre.y - rear[1].centre.y) for t in rear: rim = nearest(cls["rims"], t.mean) if rim is None: res["diam"].append(0.0) continue c, a = lathe_axis(rim.pts) pts = list(t.pts) + [p for lg in cls["lug_of"].get(t.idx, []) for p in lg.pts] rmax = max(((p - c) - a * (p - c).dot(a)).length for p in pts) res["diam"].append(2.0 * rmax) return res def shell_mass(s): vol = 0.0 mom = Vector() for tri in s.tri_idx: a = s.pts[tri[0]] for k in range(1, len(tri) - 1): b, c = s.pts[tri[k]], s.pts[tri[k + 1]] v = a.dot(b.cross(c)) / 6.0 vol += v mom += v * (a + b + c) / 4.0 return vol, (mom / vol if abs(vol) > 1e-15 else s.mean) def stance_audit(cls): """Mass centre against the tractor's real support: a triangle, because the front axle pivots on its pin. Its corners are the two rear tyres' contact patches (the centroid of each tyre's vertices under the mud) and the pivot pin's centre, all in plan.""" total = 0.0 mom = Vector() for s in cls["tractor"]: if s.mat is None: continue vol, cen = shell_mass(s) m = abs(vol) * DENSITY[s.mat] total += m mom += m * cen com = mom / total if total > 0.0 else Vector() soil = cls["soil"][0] if len(cls["soil"]) == 1 else None tri = [] for t in cls["rear_tyres"]: pts = list(t.pts) + [p for lg in cls["lug_of"].get(t.idx, []) for p in lg.pts] sub = [] for p in pts: if soil is not None and p.z < soil.hi.z: gz = soil_z(soil, p.x, p.y) if gz is not None and p.z < gz: sub.append(p) if sub: tri.append((sum(p.x for p in sub) / len(sub), sum(p.y for p in sub) / len(sub))) if len(cls["pivot"]) == 1: pv = cls["pivot"][0].mean tri.append((pv.x, pv.y)) margin = -1.0 if len(tri) == 3: hull = hull2d(tri) margin = 9.0 for k in range(len(hull)): a, b = hull[k], hull[(k + 1) % len(hull)] ex, ey = b[0] - a[0], b[1] - a[1] ln = math.hypot(ex, ey) margin = min(margin, (ex * (com.y - a[1]) - ey * (com.x - a[0])) / ln) return {"mass": total, "com": com, "margin": margin, "tri": tri} def connected_components(cls): parts = cls["all"] n = len(parts) parent = list(range(n)) def find(i): while parent[i] != i: parent[i] = parent[parent[i]] i = parent[i] return i for i in range(n): a = parts[i] for j in range(i + 1, n): b = parts[j] if (a.lo.x > b.hi.x or b.lo.x > a.hi.x or a.lo.y > b.hi.y or b.lo.y > a.hi.y or a.lo.z > b.hi.z or b.lo.z > a.hi.z): continue if find(i) == find(j): continue if a.tree.overlap(b.tree): parent[find(i)] = find(j) roots = {find(i) for i in range(n)} sizes = {} for i in range(n): sizes[find(i)] = sizes.get(find(i), 0) + 1 if os.environ.get("BDT_TRACTOR_DIAG"): big = max(sizes, key=sizes.get) for i in range(n): if find(i) != big: p = parts[i] print(f"diag: stray shell {i} mat={p.mat} tag={p.tag} centre=" f"({p.centre.x:.3f},{p.centre.y:.3f},{p.centre.z:.3f})") return len(roots), sorted(sizes.values()) def add_stray_vert(me): bm = bmesh.new() try: bm.from_mesh(me) # inside the envelope, so only the hygiene budget can see it bm.verts.new((0.0, 0.0, 0.5)) bm.to_mesh(me) me.update() finally: bm.free() def make_lod(obj, name, ratio, skip_decimate): mesh = obj.data.copy() lod = bpy.data.objects.new(name, mesh) lod.matrix_world = obj.matrix_world.copy() bpy.context.scene.collection.objects.link(lod) if not skip_decimate and 0.0 < ratio < 1.0: mod = lod.modifiers.new("DecimateBudget", "DECIMATE") mod.decimate_type = "COLLAPSE" mod.ratio = ratio return lod def convex_hull_collider(obj, name): # Duplicated from snippets/convex_hull_collider.py (not a package). The # hull is fed the vertices alone: handed faces too, it keeps some. mesh = bpy.data.meshes.new(name) bm = bmesh.new() try: for v in obj.data.vertices: bm.verts.new(v.co) result = bmesh.ops.convex_hull(bm, input=list(bm.verts)) interior = [g for g in (result.get("geom_interior") or []) if g.is_valid] if interior: bmesh.ops.delete(bm, geom=interior, context="VERTS") unused = [g for g in (result.get("geom_unused") or []) if g.is_valid] if unused: bmesh.ops.delete(bm, geom=unused, context="VERTS") bm.to_mesh(mesh) mesh.update() finally: bm.free() collider = bpy.data.objects.new(name, mesh) bpy.context.collection.objects.link(collider) collider.matrix_world = obj.matrix_world.copy() return collider def setup_bake_image(obj, target_mat, size=BAKE_RES): # Adapted from snippets/setup_bake_target_image.py - do not replace slots. if not obj.data.uv_layers: return None, None img = bpy.data.images.new("TractorNrm", size, size, alpha=True, float_buffer=False) img.colorspace_settings.name = "Non-Color" nodes = target_mat.node_tree.nodes tex = nodes.new("ShaderNodeTexImage") tex.image = img nodes.active = tex tex.select = True obj.active_material_index = PAINT_IDX return img, tex def bake_normal(high, low): # Duplicated from snippets/bake_normal_high_to_low.py (not a package). scene = bpy.context.scene scene.render.engine = "CYCLES" scene.cycles.device = "CPU" scene.cycles.samples = 1 scene.cycles.use_denoising = False for ob in bpy.context.view_layer.objects: ob.select_set(False) high.select_set(True) low.select_set(True) bpy.context.view_layer.objects.active = low return bpy.ops.object.bake( type="NORMAL", use_selected_to_active=True, cage_extrusion=CAGE_EXTRUSION, use_cage=False, normal_space="TANGENT", margin=4, margin_type="ADJACENT_FACES", use_clear=True, target="IMAGE_TEXTURES", ) def export_unity(path, objects): # Duplicated from snippets/export_preset_unity.py (not a package). for ob in bpy.context.view_layer.objects: ob.select_set(False) for ob in objects: ob.select_set(True) bpy.context.view_layer.objects.active = objects[0] bpy.ops.export_scene.gltf( filepath=path, use_selection=True, export_yup=True, export_apply=True, export_draco_mesh_compression_enable=False, export_animations=False, ) def _mm(v): return round(v * 1000.0, 3) def check(skip_decimate, lift_z=False, stray_vert=False, **flags): bpy.ops.wm.read_factory_settings(use_empty=True) low = build_mesh("TractorLow", n_corner=2, flags=flags) high = build_mesh("TractorHigh", n_corner=4, flags=flags) mats = set_materials() assign_slots(low, mats) assign_slots(high, mats) # The bake targets the body paint: the bonnet, tank and grille shell's # rounded corners are where the high mesh differs from the low. target = mats[PAINT_IDX] if stray_vert: add_stray_vert(low.data) if lift_z: for v in low.data.vertices: v.co.z += LIFT_Z low.data.update() none3 = (None, None, None) if low.data is None or len(low.data.polygons) < 6: return (fail("mesh did not build", 3),) + none3 base_tris = triangle_count(low.data) slots = [s for s in low.data.materials if s is not None] nmat = len(slots) distinct_mats = len({id(s) for s in slots}) idx_counts = {} for poly in low.data.polygons: idx_counts[poly.material_index] = idx_counts.get(poly.material_index, 0) + 1 print(f"measured mat_index_counts={dict(sorted(idx_counts.items()))}") u0, v0, u1, v1, overlap, nfaces = uv_stats(low.data) bb = vert_bbox(low.data) size_x, size_y, size_z = bb[3] - bb[0], bb[4] - bb[1], bb[5] - bb[2] hyg = hygiene_audit(low.data) cls = classify(low.data) zrep = [] if os.environ.get("BDT_TRACTOR_DIAG") else None zf = zfight_pairs(low.data, cls["groups"], zrep) if zrep: seen = {} for r in zrep: key = (round(r[0][0], 2), round(r[0][1], 2), round(r[0][2], 2), r[3], r[4]) seen.setdefault(key, [0, r])[0] += 1 for key, (cnt, r) in sorted(seen.items(), key=lambda kv: -kv[1][0])[:60]: print(f"diag: coplanar x{cnt} {r}") sinks = support_audit(cls) joints = joint_audit(cls) seats, orphans = tyre_seat_audit(cls) lugs = lug_audit(cls) mirror, nmirror = mirror_audit(cls) wheels = wheel_audit(cls) stance = stance_audit(cls) ncomp, comp_sizes = connected_components(cls) img, tex = setup_bake_image(low, target) if img is None: return (fail("mesh has no UV layer", 3),) + none3 bake_result = bake_normal(high, low) lod1 = make_lod(low, "TractorLOD1", LOD1_TARGET, skip_decimate) lod2 = make_lod(low, "TractorLOD2", LOD2_TARGET, skip_decimate) bpy.context.view_layer.update() lod1_tris = evaluated_triangle_count(lod1) lod2_tris = evaluated_triangle_count(lod2) r1 = lod1_tris / base_tris if base_tris else 0.0 r2 = lod2_tris / base_tris if base_tris else 0.0 collider = convex_hull_collider(low, "TractorCollider") col_tris = triangle_count(collider.data) export_path = os.path.join(tempfile.gettempdir(), f"bdt_farm_tractor_{os.getpid()}.glb") if os.path.exists(export_path): os.remove(export_path) export_unity(export_path, [low, collider]) export_size = os.path.getsize(export_path) if os.path.isfile(export_path) else 0 if os.path.isfile(export_path): try: os.remove(export_path) except OSError: pass hubs, bush, eyes = joints["hubs"], joints["bush"], joints["eyes"] print(f"blender={tuple(bpy.app.version)} skip_decimate={skip_decimate}") print(f"measured base_tris={base_tris} lod1_tris={lod1_tris} " f"lod2_tris={lod2_tris} r1={r1:.4f} r2={r2:.4f}") print(f"measured nmat={nmat} uv=({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f}) " f"overlap={overlap:.6f} nfaces={nfaces}") print(f"measured bbox=({size_x:.4f},{size_y:.4f},{size_z:.4f}) " f"outer={OUTER_SIZE} zmin={bb[2]:.5f} min=({bb[0]:.4f},{bb[1]:.4f})") print(f"measured collider_tris={col_tris} bake={bake_result} " f"bake_has_data={img.has_data} export_bytes={export_size}") print(f"measured hygiene loose_v={hyg['loose_v']} loose_e={hyg['loose_e']} " f"nonman={hyg['nonman']} zero_area={hyg['zero_area']} " f"doubles={hyg['doubles']} ngons={hyg['ngons']} zfight={zf}") print(f"measured shells={len(cls['all'])} tyres={len(cls['tyres'])} " f"sink_mm={[_mm(s) for s in sinks]}") print(f"measured hubs={[(h[0], _mm(h[1]), round(h[2], 4)) for h in hubs]} " f"bushings={[(_mm(o), round(a, 4)) for o, a in bush]}") print(f"measured eyes={[(_mm(o), round(a, 3), _mm(p)) for o, a, p in eyes]}") if seats: print(f"measured tyre_seat_mm=({min(seats) * 1000:.3f},{max(seats) * 1000:.3f}) " f"n={len(seats)} orphans={orphans} rims={len(cls['rims'])}") print(f"measured lugs={lugs['count']} per_tyre={lugs['per_tyre']} bite_mm=({_mm(lugs['bite'][0])}," f"{_mm(lugs['bite'][1])}) proud_mm={_mm(lugs['proud'])} orphans={lugs['orphans']} " f"pitch_dev_deg={lugs['pitch']:.4f} hand_deg=({lugs['hand']:.3f},{lugs['hand_max']:.3f})") print(f"measured mirror_mm={mirror * 1000:.4f} (n={nmirror}) wheel_mirror_mm=" f"{wheels['mirror'] * 1000:.4f} wheelbase={wheels['wheelbase']:.5f} " f"track_f={wheels['track_f']:.5f} track_r={wheels['track_r']:.5f} " f"rear_diam={[round(d, 5) for d in wheels['diam']]}") print(f"measured mass={stance['mass']:.1f}kg com=({stance['com'].x:.4f},{stance['com'].y:.4f}," f"{stance['com'].z:.4f}) support={[(round(x, 4), round(y, 4)) for x, y in stance['tri']]} " f"margin={stance['margin']:.4f}") print(f"measured components={ncomp} sizes={comp_sizes[-5:]} shells={len(cls['all'])}") if not (BASE_TRIS_MIN <= base_tris <= BASE_TRIS_MAX): return (fail(f"base tris {base_tris} not in [{BASE_TRIS_MIN}, {BASE_TRIS_MAX}]", 4),) + none3 if nmat != MATERIAL_COUNT or distinct_mats != MATERIAL_COUNT: return (fail(f"material slots {nmat} distinct {distinct_mats} != {MATERIAL_COUNT}", 5),) + none3 for idx, floor in FACE_FLOORS.items(): if idx_counts.get(idx, 0) < floor: return (fail(f"{MAT_LABELS[idx]} faces {idx_counts.get(idx, 0)} < {floor}", 5),) + none3 if u0 < -UV_EPS or v0 < -UV_EPS or u1 > 1.0 + UV_EPS or v1 > 1.0 + UV_EPS: return (fail(f"UVs outside 0..1: ({u0:.4f},{v0:.4f})-({u1:.4f},{v1:.4f})", 6),) + none3 if overlap > UV_OVERLAP_MAX: return (fail(f"UV AABB overlap {overlap:.6f} > {UV_OVERLAP_MAX}", 7),) + none3 if (abs(size_x - OUTER_SIZE[0]) > BBOX_TOL or abs(size_y - OUTER_SIZE[1]) > BBOX_TOL or abs(size_z - OUTER_SIZE[2]) > BBOX_TOL): return (fail(f"bbox ({size_x:.4f},{size_y:.4f},{size_z:.4f}) off outer {OUTER_SIZE}", 8),) + none3 if not (LOD1_RATIO_MIN <= r1 <= LOD1_RATIO_MAX): return (fail(f"LOD1 ratio {r1:.4f} not in [{LOD1_RATIO_MIN}, {LOD1_RATIO_MAX}] " "(--skip-decimate is the designed fail)", 9),) + none3 if not (LOD2_RATIO_MIN <= r2 <= LOD2_RATIO_MAX): return (fail(f"LOD2 ratio {r2:.4f} not in [{LOD2_RATIO_MIN}, {LOD2_RATIO_MAX}]", 9),) + none3 if col_tris > COLLIDER_TRIS_MAX: return (fail(f"collider tris {col_tris} > {COLLIDER_TRIS_MAX}", 11),) + none3 if bake_result != {"FINISHED"} or not img.has_data: return (fail(f"bake failed result={bake_result} has_data={img.has_data}", 12),) + none3 if export_size <= 0: return (fail("export file missing or empty", 13),) + none3 if (hyg["loose_v"] or hyg["loose_e"] or hyg["nonman"] or hyg["zero_area"] or hyg["doubles"] or hyg["ngons"] or zf): return (fail(f"hygiene {hyg} zfight={zf}", 15),) + none3 if bb[2] > ZMIN_EPS or bb[2] < -ZMIN_EPS: return (fail(f"grounded zmin={bb[2]:.5f}", 16),) + none3 if len(sinks) != TYRE_COUNT or any(not (SINK_MIN <= s <= SINK_MAX) for s in sinks): return (fail(f"tyres: {len(sinks)} (want {TYRE_COUNT}), pressed into the mud " f"{[_mm(s) for s in sinks]} mm (band [{SINK_MIN * 1000}, {SINK_MAX * 1000}])", 16),) + none3 if (len(hubs) != HUBS or len(bush) != BUSHINGS or len(eyes) != EYES or any(o > COAX_MAX or a > COAX_DEG_MAX for _k, o, a in hubs) or any(o > COAX_MAX or a > COAX_DEG_MAX for o, a in bush) or any(o > EYE_OFF_MAX or a > EYE_DEG_MAX or p < EYE_PIN_PAST for o, a, p in eyes)): return (fail(f"joint fit: hubs {[(k, _mm(o), round(a, 3)) for k, o, a in hubs]}, pivot " f"bushings {[(_mm(o), round(a, 3)) for o, a in bush]} (off the axis <= " f"{COAX_MAX * 1000} mm, {COAX_DEG_MAX} deg); {len(eyes)} eyes (want {EYES}) " f"{[(_mm(o), round(a, 2), _mm(p)) for o, a, p in eyes]} (off the pin <= " f"{EYE_OFF_MAX * 1000} mm, {EYE_DEG_MAX} deg, pin past both faces >= " f"{EYE_PIN_PAST * 1000} mm)", 17),) + none3 if (len(cls["tyres"]) != TYRE_COUNT or orphans or not seats or min(seats) < TYRE_SEAT_MIN or max(seats) > TYRE_SEAT_MAX or lugs["count"] != LUGS or lugs["orphans"] or lugs["bite"][0] < LUG_BITE_MIN or lugs["bite"][1] > LUG_BITE_MAX or lugs["proud"] < LUG_PROUD_MIN): return (fail(f"seat: tyre beads {min(seats) * 1000 if seats else 0:.3f}.." f"{max(seats) * 1000 if seats else 0:.3f} mm (band [{TYRE_SEAT_MIN * 1000}, " f"{TYRE_SEAT_MAX * 1000}]), orphans {orphans}; {lugs['count']} lugs (want {LUGS}), " f"roots {_mm(lugs['bite'][0])}..{_mm(lugs['bite'][1])} mm into the carcass " f"(band [{LUG_BITE_MIN * 1000}, {LUG_BITE_MAX * 1000}]), proud " f"{_mm(lugs['proud'])} mm (>= {LUG_PROUD_MIN * 1000})", 18),) + none3 if (mirror > MIRROR_EPS or wheels["mirror"] > WHEEL_MIRROR_EPS or abs(wheels["wheelbase"] - WHEELBASE) > SIZE_TOL or abs(wheels["track_f"] - TRACK_F) > SIZE_TOL or abs(wheels["track_r"] - TRACK_R) > SIZE_TOL or len(wheels["diam"]) != 2 or any(abs(d - REAR_DIAM) > DIAM_TOL for d in wheels["diam"])): return (fail(f"mirror/size: body mirror {mirror * 1000:.3f} mm, wheels " f"{wheels['mirror'] * 1000:.3f} mm (eps {MIRROR_EPS * 1000}), wheelbase " f"{wheels['wheelbase']:.4f}, tracks {wheels['track_f']:.4f} / " f"{wheels['track_r']:.4f} (+- {SIZE_TOL}), rear tyres " f"{[round(d, 4) for d in wheels['diam']]} m (want {REAR_DIAM} +- {DIAM_TOL})", 19),) + none3 if lugs["pitch"] > PITCH_TOL_DEG: return (fail(f"lug pitch off by {lugs['pitch']:.3f} deg (<= {PITCH_TOL_DEG})", 20),) + none3 if lugs["hand"] < HAND_MIN_DEG: return (fail(f"chevron handing: a bar's apex {lugs['hand']:.3f} deg ahead of its shoulder " f"(>= {HAND_MIN_DEG}: the point meets the ground first)", 21),) + none3 if stance["margin"] < STANCE_MARGIN: return (fail(f"stance: mass centre {stance['margin']:.4f} m inside the support triangle " f"< {STANCE_MARGIN:.4f}", 22),) + none3 if ncomp != 1: return (fail(f"assembly splits into {ncomp} components {comp_sizes}", 23),) + none3 return 0, low, target, tex def wire_normal(mat, tex): nt = mat.node_tree bsdf = nt.nodes["Principled BSDF"] nrm = nt.nodes.new("ShaderNodeNormalMap") nrm.inputs["Strength"].default_value = 1.0 nt.links.new(tex.outputs["Color"], nrm.inputs["Color"]) # chain the baked normal under the paint's own bump for link in list(bsdf.inputs["Normal"].links): src = link.from_node if src.type == "BUMP": nt.links.new(nrm.outputs["Normal"], src.inputs["Normal"]) return nt.links.new(nrm.outputs["Normal"], bsdf.inputs["Normal"]) def render_still(low, target, tex, path, engine): scene = bpy.context.scene wire_normal(target, tex) for ob in list(scene.objects): if ob.type == "MESH" and ob != low: ob.hide_render = True ob.hide_viewport = True low.rotation_euler.z = math.radians(HERO_YAW_DEG) bpy.context.view_layer.update() corners = [low.matrix_world @ Vector(c) for c in low.bound_box] lo = Vector((min(c.x for c in corners), min(c.y for c in corners), min(c.z for c in corners))) hi = Vector((max(c.x for c in corners), max(c.y for c in corners), max(c.z for c in corners))) centre = (lo + hi) * 0.5 floor_me = bpy.data.meshes.new("Floor") bm = bmesh.new() try: bmesh.ops.create_grid(bm, x_segments=1, y_segments=1, size=60.0) bm.to_mesh(floor_me) finally: bm.free() fmat = bpy.data.materials.new("Floor") fmat.use_nodes = True fb = fmat.node_tree.nodes["Principled BSDF"] fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0) fb.inputs["Roughness"].default_value = 0.7 floor_me.materials.append(fmat) floor = bpy.data.objects.new("Floor", floor_me) floor.location.z = -0.001 scene.collection.objects.link(floor) wall = bpy.data.objects.new("Wall", floor_me.copy()) wall.location = (0.0, WALL_Y, 0.0) wall.rotation_euler = (math.radians(90), 0.0, 0.0) scene.collection.objects.link(wall) world = bpy.data.worlds.new("World") world.use_nodes = True world.node_tree.nodes["Background"].inputs["Color"].default_value = (0.02, 0.021, 0.025, 1.0) scene.world = world def light(name, offset, energy, size, col, target=None, spread=None): ld = bpy.data.lights.new(name, "AREA") ld.energy = energy ld.size = size ld.color = col if spread is not None: ld.spread = math.radians(spread) ob = bpy.data.objects.new(name, ld) ob.location = centre + Vector(offset) aim_at = centre if target is None else Vector(target) ob.rotation_euler = (aim_at - ob.location).normalized().to_track_quat("-Z", "Y").to_euler() scene.collection.objects.link(ob) # Key from the camera's left and high, a cool fill low right, a cool rim # behind to lift the tractor off the wall, the warm wedge pooled on the # back wall. light("Key", (-2.6, -6.0, 6.2), 420.0, 4.0, (1.0, 0.95, 0.90), spread=36.0) light("Fill", (7.5, -3.5, 1.6), 60.0, 9.0, (0.72, 0.82, 1.0)) light("Rim", (-3.4, 4.2, 4.2), 380.0, 3.4, (0.62, 0.78, 1.0)) light("Wedge", (-2.4, 4.3, 2.8), 760.0, 5.5, (1.0, 0.72, 0.44), target=(centre.x - 1.4, WALL_Y, 1.0)) cam_data = bpy.data.cameras.new("Cam") cam_data.lens = CAM_LENS cam = bpy.data.objects.new("Cam", cam_data) view = Vector((CAM_VIEW[0], CAM_VIEW[1], 0.0)).normalized() cam.location = centre + view * CAM_DIST + Vector((0.0, 0.0, CAM_LIFT)) scene.collection.objects.link(cam) aim = bpy.data.objects.new("Aim", None) aim.location = centre + Vector(AIM_OFFSET) scene.collection.objects.link(aim) con = cam.constraints.new("TRACK_TO") con.target = aim con.track_axis = "TRACK_NEGATIVE_Z" con.up_axis = "UP_Y" scene.camera = cam scene.render.engine = "CYCLES" if engine == "cycles" else eevee_engine_id() if engine == "cycles": scene.cycles.samples = 32 scene.cycles.device = "CPU" else: try: scene.eevee.taa_render_samples = 64 except AttributeError: pass scene.render.resolution_x = 1280 scene.render.resolution_y = 720 scene.render.image_settings.file_format = "WEBP" if path.lower().endswith(".webp") else "PNG" if path.lower().endswith(".webp"): scene.render.image_settings.quality = 90 scene.render.filepath = path # Standard, not AgX: AgX washes the green paint and the grey wheels flat scene.view_settings.view_transform = "Standard" fcode = gallery_framing.check_framing(scene, cam, hero=[low], elements=[low], stage=[floor, wall]) if fcode: return fcode # asset-quality floors return 11, which this piece spends on the # collider ceiling; remap at the call site if gallery_asset_quality.check_asset_quality(scene, cam, [low], stage=[floor, wall]): return 24 bpy.ops.render.render(write_still=True) if not (os.path.exists(path) and os.path.getsize(path) > 0): return fail("render produced no file", 14) return 0 FLAGS = ("float_tyre", "cock_hub", "cant_pin", "unpin_link", "sink_tyre", "float_lugs", "skew_wheel", "wide_track", "tall_lugs", "odd_fender", "bunch_lugs", "reverse_lugs", "offset_pivot", "loose_lamp") def main(): argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] p = argparse.ArgumentParser() p.add_argument("--output", default=None) p.add_argument("--engine", default="eevee", choices=("eevee", "cycles")) p.add_argument("--skip-decimate", action="store_true") p.add_argument("--stray-vert", action="store_true") p.add_argument("--lift-z", action="store_true") p.add_argument("--float-tyre", action="store_true") p.add_argument("--cock-hub", action="store_true") p.add_argument("--cant-pin", action="store_true") p.add_argument("--unpin-link", action="store_true") p.add_argument("--sink-tyre", action="store_true") p.add_argument("--float-lugs", action="store_true") p.add_argument("--skew-wheel", action="store_true") p.add_argument("--wide-track", action="store_true") p.add_argument("--tall-lugs", action="store_true") p.add_argument("--odd-fender", action="store_true") p.add_argument("--bunch-lugs", action="store_true") p.add_argument("--reverse-lugs", action="store_true") p.add_argument("--offset-pivot", action="store_true") p.add_argument("--loose-lamp", action="store_true") args = p.parse_args(argv) code, low, target, tex = check( args.skip_decimate, lift_z=args.lift_z, stray_vert=args.stray_vert, **{k: getattr(args, k) for k in FLAGS}, ) if code: return code if args.output: rcode = render_still(low, target, tex, os.path.abspath(args.output), args.engine) if rcode: return rcode print(f"rendered still {args.output}") print("farm-tractor OK") return 0 if __name__ == "__main__": try: sys.exit(main()) except Exception as e: traceback.print_exc() print(f"FATAL: {e}", file=sys.stderr) sys.exit(1)