diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d5c7b71..b0d94f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,10 +42,39 @@ jobs: # PowerShell runs on Linux, so the Windows installer is checkable here # rather than trusted on inspection. - name: Install PowerShell + env: + # Authenticated so the release lookup is not subject to the shared + # runners' unauthenticated GitHub API rate limit. Hitting that limit + # returned an empty URL, `curl -fsSL ""` failed, and the whole job + # went red for a reason that had nothing to do with the code. + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Known-good fallback if the API is unavailable entirely. + PWSH_FALLBACK: "7.4.6" run: | - URL=$(curl -fsSL https://api.github.com/repos/PowerShell/PowerShell/releases/latest \ - | grep -o '"browser_download_url": *"[^"]*linux-x64.tar.gz"' | cut -d'"' -f4 | head -1) - mkdir -p "$HOME/.pwsh" && curl -fsSL "$URL" | tar -xz -C "$HOME/.pwsh" + set -euo pipefail + + resolve_url() { + curl -fsSL -H "Authorization: Bearer $GH_TOKEN" \ + https://api.github.com/repos/PowerShell/PowerShell/releases/latest \ + | grep -o '"browser_download_url": *"[^"]*linux-x64.tar.gz"' \ + | cut -d'"' -f4 | head -1 + } + + URL="" + for attempt in 1 2 3; do + URL=$(resolve_url || true) + [ -n "$URL" ] && break + echo "release lookup attempt $attempt failed, retrying" >&2 + sleep $((attempt * 5)) + done + + if [ -z "$URL" ]; then + echo "falling back to pinned PowerShell $PWSH_FALLBACK" >&2 + URL="https://github.com/PowerShell/PowerShell/releases/download/v${PWSH_FALLBACK}/powershell-${PWSH_FALLBACK}-linux-x64.tar.gz" + fi + + mkdir -p "$HOME/.pwsh" + curl -fsSL --retry 3 --retry-delay 5 "$URL" | tar -xz -C "$HOME/.pwsh" chmod +x "$HOME/.pwsh/pwsh" echo "$HOME/.pwsh" >> "$GITHUB_PATH" diff --git a/.gitignore b/.gitignore index 7ab1ee0..23ba3ee 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,7 @@ assets-source/**/raw/* !assets-source/**/raw/.gitkeep .DS_Store + +# Python bytecode from the Blender asset generators. +__pycache__/ +*.pyc diff --git a/apps/game/public/assets/manifest.json b/apps/game/public/assets/manifest.json index 5414f86..7232e30 100644 --- a/apps/game/public/assets/manifest.json +++ b/apps/game/public/assets/manifest.json @@ -1,7 +1,7 @@ { "generator": "tools/art/build-assets.mjs", "blender": "Blender 4.5.12 LTS", - "commit": "fae10c13fb39b93d0d4d66f67a506d9d9078487d", + "commit": "f24a07a4ca2a223aed5561a9ab08ce87dea1f262", "license": "Original work, © NIGHTCELL 7. No third-party assets.", "source": "Generated procedurally from the scripts in tools/art. No asset is downloaded, photographed, traced, or derived from another game.", "textureSize": 1024, @@ -67,9 +67,9 @@ "ui_hover.mp3" ], "bytes": { - "models": 1135736, + "models": 1235920, "textures": 1971598, - "audio": 323166, - "total": 3430500 + "audio": 326680, + "total": 3534198 } } diff --git a/apps/game/public/assets/models/character.glb b/apps/game/public/assets/models/character.glb index 3e53179..8e7f2e5 100644 Binary files a/apps/game/public/assets/models/character.glb and b/apps/game/public/assets/models/character.glb differ diff --git a/tools/art/blender/__pycache__/_lib.cpython-311.pyc b/tools/art/blender/__pycache__/_lib.cpython-311.pyc deleted file mode 100644 index 611e75f..0000000 Binary files a/tools/art/blender/__pycache__/_lib.cpython-311.pyc and /dev/null differ diff --git a/tools/art/blender/_lib.py b/tools/art/blender/_lib.py index 343c308..47bd519 100644 --- a/tools/art/blender/_lib.py +++ b/tools/art/blender/_lib.py @@ -401,3 +401,102 @@ def output_path(filename: str) -> str: else: base = os.path.join(os.path.dirname(__file__), "..", "..", "..", "build", "models") return os.path.abspath(os.path.join(base, filename)) + + +# ------------------------------------------------------------------ lofting + +def ring( + centre: tuple[float, float, float], + rx: float, + ry: float, + segments: int = 12, + squash: float = 0.0, + rotation: float = 0.0, +) -> list[Vector]: + """ + One cross-section: an ellipse, optionally squared off toward a rectangle. + + `squash` blends from a true ellipse (0) toward a rounded rectangle (1). + Bodies are not circular in section — a chest is broad and flat, a forearm + is nearly round — and that difference is most of what separates a lofted + figure from a stack of cylinders. + """ + points: list[Vector] = [] + for i in range(segments): + a = rotation + (i / segments) * math.tau + c, s = math.cos(a), math.sin(a) + # Superellipse-ish: push the point outward toward the bounding box. + if squash > 0.0: + m = max(abs(c), abs(s)) + c = c * (1 - squash) + (c / m) * squash + s = s * (1 - squash) + (s / m) * squash + points.append(Vector((centre[0] + c * rx, centre[1] + s * ry, centre[2]))) + return points + + +def loft(bm: bmesh.types.BMesh, rings: list[list[Vector]], cap: bool = True) -> None: + """ + Bridge a sequence of equal-length rings into a tube. + + This is the core of the character work. Stacking primitives gives a figure + made of visible blocks no amount of texturing hides; lofting a profile that + changes along its length gives limbs that taper and a torso that actually + has a waist. + """ + if len(rings) < 2: + return + + verts = [[bm.verts.new(p) for p in r] for r in rings] + + for a, b in zip(verts, verts[1:]): + n = len(a) + for i in range(n): + j = (i + 1) % n + try: + bm.faces.new((a[i], a[j], b[j], b[i])) + except ValueError: + # Duplicate face where two rings coincide; harmless. + pass + + if cap: + for end in (verts[0], verts[-1]): + try: + bm.faces.new(end) + except ValueError: + pass + + bm.verts.index_update() + bmesh.ops.recalc_face_normals(bm, faces=bm.faces[:]) + + +def limb( + bm: bmesh.types.BMesh, + joints: list[tuple[tuple[float, float, float], float, float]], + segments: int = 10, + squash: float = 0.0, +) -> None: + """ + Loft a limb through `(centre, rx, ry)` joints. + + Rings are oriented in the XY plane and stacked in Z, which suits a figure + modelled standing in a neutral pose — every limb here runs broadly + vertically, and the rig bends them later. + """ + loft(bm, [ring(c, rx, ry, segments, squash) for c, rx, ry in joints]) + + +def subdivide_smooth(bm: bmesh.types.BMesh, cuts: int = 1) -> None: + """ + Catmull-Clark-ish smoothing pass. + + Applied to the body only. Gear stays faceted: hard surfaces should read as + hard, and smoothing a plate carrier makes it look inflated. + """ + for _ in range(cuts): + bmesh.ops.subdivide_edges( + bm, + edges=bm.edges[:], + cuts=1, + use_grid_fill=True, + smooth=1.0, + ) diff --git a/tools/art/blender/character.py b/tools/art/blender/character.py index a9c155a..73e9a2a 100644 --- a/tools/art/blender/character.py +++ b/tools/art/blender/character.py @@ -1,30 +1,31 @@ """ -Player character. - -This asset exists because remote players are currently *invisible*: the client -interpolates their positions (`RemotePlayerInterpolator`) and -`NetClient.remotePosition()` returns them, but nothing in the game ever calls -it or creates a mesh. A six-a-side match therefore renders an empty yard. - -Design constraints, in priority order: - - 1. **Readable silhouette.** A competitive shooter lives or dies on whether you - can identify a player shape in a fraction of a second against cluttered - industrial geometry. Chunky pauldrons, a helmet with a distinct brow, and a - backpack give a recognisable outline from any angle. - 2. **Team legibility without pay-to-win.** Team identity is carried by - material (`paint_red` / `paint_cyan`), not by geometry, so the two sides - are exactly the same size and present exactly the same hitbox. PRD § - "Multiplayer Alpha is free and cannot be pay-to-win" — a smaller-silhouette - skin would be precisely that. - 3. **Matches the capsule the server simulates.** 1.8 m standing, roughly - 0.6 m across the shoulders, centred on the origin in plan so the mesh sits - on the simulated position without an offset fudge. - -Unrigged and unanimated on purpose: V1 has no animation system, and a static -mesh that reads correctly beats a rigged one that cannot be driven. It is built -in a neutral standing pose so a skeleton can be added later without redoing the -silhouette. +Player character — armoured operative. + +The first version of this file stacked axis-aligned cubes. It read as a +Minecraft figure, and no amount of texturing fixes a silhouette made of blocks. +This one lofts the body from cross-sections that change along their length, so +limbs taper and the torso has a waist, and layers hard-surface gear on top. + +The art direction does real work here. A helmeted, visored operative in heavy +gear is almost entirely *hard surface* — plates, pouches, straps, boots — which +is what a script is good at. The things procedural generation genuinely cannot +do well are faces, skin and bare hands, and this design has none of them +visible. That is a deliberate choice, not a dodge: it is also why real military +shooters can reuse one body under many loadouts. + +Rules that still hold from the first version: + + 1. **Readable silhouette.** Competitive play depends on identifying a shape in + a fraction of a second against cluttered industrial geometry. Pauldrons, a + distinct helmet brow, and a backpack give an outline recognisable from any + angle. + 2. **Team identity by material, never geometry.** Both sides are the exact + same mesh with a different paint material, so neither presents a smaller + target. PRD: Multiplayer Alpha cannot be pay-to-win, and a slimmer skin + would be precisely that. + 3. **1.8 m standing**, matching the capsule the server simulates, centred on + the origin in plan so the mesh sits on the simulated position with no + offset fudge. """ from __future__ import annotations @@ -39,84 +40,236 @@ HEIGHT = 1.8 +# Roughly 7.5 heads, the standard heroic-but-not-cartoon proportion. +HEAD_TOP = 1.80 +NECK_Y = 1.46 +CHEST_Y = 1.28 +WAIST_Y = 1.02 +HIP_Y = 0.92 -def build(seed: int = 31) -> None: - L.reset_scene() - armour = L.new_bmesh() # team-coloured plate - gear = L.new_bmesh() # webbing, boots, weapon — always dark +def build_torso(body: L.Part) -> None: + """ + Torso lofted hips -> waist -> chest -> shoulders. + + The chest section is much wider than deep (squash pushes it toward a + rounded rectangle); a circular section reads as a barrel, not a ribcage. + """ + L.limb( + body, + [ + ((0.0, 0.0, HIP_Y - 0.10), 0.155, 0.115), + ((0.0, 0.0, HIP_Y), 0.160, 0.120), + ((0.0, 0.0, WAIST_Y), 0.145, 0.105), + ((0.0, 0.0, WAIST_Y + 0.12), 0.170, 0.120), + ((0.0, 0.0, CHEST_Y), 0.195, 0.135), + ((0.0, 0.0, CHEST_Y + 0.10), 0.200, 0.138), + ((0.0, 0.0, NECK_Y - 0.04), 0.165, 0.120), + ], + segments=14, + squash=0.35, + ) + + +def build_legs(body: L.Part) -> None: + for side in (-1, 1): + x = side * 0.098 + # Thigh -> knee -> calf -> ankle. The calf bulge is small but it is + # what stops a leg reading as a cone. + L.limb( + body, + [ + ((x, 0.0, HIP_Y), 0.098, 0.098), + ((x, 0.005, 0.72), 0.092, 0.094), + ((x, 0.004, 0.50), 0.075, 0.078), + ((x, 0.0, 0.44), 0.070, 0.072), # knee + ((x, -0.004, 0.34), 0.076, 0.080), # calf + ((x, 0.0, 0.16), 0.055, 0.058), + ((x, 0.0, 0.09), 0.050, 0.054), # ankle + ], + segments=10, + ) + + +def build_arms(body: L.Part) -> None: + for side in (-1, 1): + x = side * 0.225 + # Shoulder -> bicep -> elbow -> forearm -> wrist, drifting forward as + # it descends so the arms hang naturally rather than pin-straight. + L.limb( + body, + [ + ((x, 0.0, CHEST_Y + 0.08), 0.072, 0.072), + ((x, -0.005, 1.24), 0.068, 0.070), + ((x, -0.015, 1.10), 0.058, 0.060), + ((x - side * 0.005, -0.030, 1.00), 0.052, 0.054), # elbow + ((x - side * 0.012, -0.075, 0.95), 0.050, 0.052), + ((x - side * 0.020, -0.150, 0.94), 0.044, 0.046), # wrist + ], + segments=10, + ) + + +def build_head(body: L.Part) -> None: + """Neck and the skull under the helmet — mostly hidden, but it fills the gap.""" + L.limb( + body, + [ + ((0.0, 0.0, NECK_Y - 0.05), 0.062, 0.058), + ((0.0, 0.0, NECK_Y + 0.03), 0.058, 0.055), + ((0.0, -0.005, 1.56), 0.078, 0.082), + ((0.0, -0.008, 1.63), 0.086, 0.092), + ((0.0, -0.005, 1.70), 0.080, 0.086), + ((0.0, 0.0, 1.745), 0.055, 0.060), + ], + segments=12, + ) + + +def build_gear(armour: L.Part, gear: L.Part, rng) -> None: + """ + Everything worn over the body. - # ---- legs ------------------------------------------------------------ + Hard surface, faceted, bevelled — the contrast against the smooth body + underneath is what makes the gear read as equipment rather than anatomy. + """ + # ---- helmet: a cut-down sphere with a brow and a rail ------------------ + L.limb( + armour, + [ + ((0.0, -0.004, 1.585), 0.098, 0.104), + ((0.0, -0.006, 1.635), 0.108, 0.115), + ((0.0, -0.006, 1.685), 0.104, 0.110), + ((0.0, -0.004, 1.730), 0.086, 0.092), + ((0.0, 0.0, 1.762), 0.052, 0.056), + ], + segments=14, + ) + # Brow / visor lip — the strongest single read on the head. + L.add_box(armour, centre=(0.0, -0.098, 1.605), size=(0.185, 0.045, 0.055)) + # Visor itself, recessed and dark. + L.add_box(gear, centre=(0.0, -0.082, 1.565), size=(0.155, 0.040, 0.062)) + # Side rails and an NVG mount: small asymmetric shapes that sell "kit". + for sx in (-1, 1): + L.add_box(gear, centre=(sx * 0.098, -0.010, 1.600), size=(0.020, 0.130, 0.030)) + L.add_box(gear, centre=(0.0, -0.085, 1.700), size=(0.055, 0.045, 0.030)) + # Ear cups. for sx in (-1, 1): - x = sx * 0.115 - # Thigh and shin, slightly tapered by stacking two boxes. - L.add_box(gear, centre=(x, 0.0, 0.24), size=(0.17, 0.20, 0.48)) - L.add_box(gear, centre=(x, 0.005, 0.66), size=(0.19, 0.23, 0.40)) - # Boot: wider than the shin, which anchors the figure to the ground. - L.add_box(gear, centre=(x, -0.03, 0.05), size=(0.20, 0.30, 0.10)) - # Knee pad, in team colour so the legs are not a dark mass. - L.add_box(armour, centre=(x, -0.10, 0.47), size=(0.16, 0.06, 0.14)) - - # ---- torso ----------------------------------------------------------- - # Built as a stack rather than one box so the chest tapers into the - # shoulders. Heights are chosen to leave NO vertical gap anywhere up the - # figure: the first version ended the torso at 1.32 and started the neck at - # 1.41, which rendered as a head floating above the body. - L.add_box(gear, centre=(0.0, 0.0, 0.90), size=(0.33, 0.22, 0.22)) # waist / belt - L.add_box(armour, centre=(0.0, 0.0, 1.10), size=(0.40, 0.25, 0.24)) # lower chest - L.add_box(armour, centre=(0.0, 0.0, 1.29), size=(0.44, 0.27, 0.20)) # upper chest, widest - L.add_box(armour, centre=(0.0, -0.15, 1.16), size=(0.34, 0.06, 0.34)) # plate carrier front - # Collar bridging chest to neck, so the head sits ON the body. - L.add_box(gear, centre=(0.0, 0.0, 1.41), size=(0.26, 0.20, 0.08)) - - # Magazine pouches across the front — small shapes that catch the light. + L.add_cylinder( + gear, centre=(sx * 0.100, -0.005, 1.545), radius=0.042, height=0.030, + segments=10, axis="X", + ) + # Comms boom. + L.add_cylinder(gear, centre=(0.055, -0.075, 1.520), radius=0.010, height=0.10, segments=6, axis="Y") + + # ---- plate carrier ----------------------------------------------------- + # Front and back plates, slightly proud of the chest, with a shoulder yoke. + L.add_box(armour, centre=(0.0, -0.115, 1.20), size=(0.270, 0.052, 0.330)) + L.add_box(armour, centre=(0.0, 0.112, 1.20), size=(0.270, 0.048, 0.330)) + for sx in (-1, 1): + L.add_box(armour, centre=(sx * 0.135, 0.0, 1.345), size=(0.075, 0.240, 0.055)) + # Cummerbund. + L.add_box(gear, centre=(0.0, 0.0, 1.055), size=(0.330, 0.235, 0.085)) + + # ---- pouches ----------------------------------------------------------- for i in range(3): - L.add_box(gear, centre=(-0.11 + i * 0.11, -0.19, 1.02), size=(0.09, 0.06, 0.14)) + L.add_box( + gear, + centre=(-0.105 + i * 0.105, -0.150, 1.105), + size=(0.088, 0.058, 0.135), + ) + # Radio on the back, admin pouch on the chest. + L.add_box(gear, centre=(-0.115, 0.140, 1.240), size=(0.070, 0.050, 0.115)) + L.add_box(gear, centre=(0.085, -0.145, 1.290), size=(0.105, 0.035, 0.085)) - # Backpack: pushes the silhouette asymmetric front-to-back, which is what - # makes facing direction readable at distance. - L.add_box(gear, centre=(0.0, 0.185, 1.18), size=(0.30, 0.15, 0.38)) - L.add_cylinder(gear, centre=(0.0, 0.26, 1.33), radius=0.05, height=0.28, segments=8, axis="X") + # ---- pauldrons --------------------------------------------------------- + for sx in (-1, 1): + # Tapered top and bottom so the plate sits *over* the shoulder and + # blends into the arm. Straight-sided rings with capped ends read as a + # separate blob floating beside the body, which is how the first + # attempt rendered. + L.limb( + armour, + [ + ((sx * 0.200, 0.0, 1.385), 0.038, 0.052), + ((sx * 0.218, 0.0, 1.350), 0.062, 0.086), + ((sx * 0.228, 0.0, 1.305), 0.066, 0.092), + ((sx * 0.230, 0.0, 1.258), 0.060, 0.084), + ((sx * 0.228, 0.0, 1.220), 0.044, 0.062), + ], + segments=12, + ) + + # ---- belt, holster, thigh rig ----------------------------------------- + L.add_box(gear, centre=(0.0, 0.0, 0.955), size=(0.330, 0.240, 0.062)) + L.add_box(gear, centre=(0.155, -0.020, 0.845), size=(0.070, 0.110, 0.190)) # holster + L.add_box(gear, centre=(-0.140, -0.040, 0.800), size=(0.085, 0.075, 0.150)) # dump pouch - # ---- shoulders and arms --------------------------------------------- + # ---- knee pads, gloves, boots ----------------------------------------- for sx in (-1, 1): - # Pauldron — the strongest silhouette element on the upper body. Sits - # just outboard of the chest and overlaps it, so there is no seam. - L.add_box(armour, centre=(sx * 0.245, 0.0, 1.31), size=(0.15, 0.26, 0.19)) - # Upper arm hangs from under the pauldron. - L.add_box(gear, centre=(sx * 0.255, -0.005, 1.13), size=(0.125, 0.16, 0.24)) - # Elbow, then a forearm angled in and forward as if holding a weapon. - L.add_box(gear, centre=(sx * 0.245, -0.03, 1.00), size=(0.115, 0.14, 0.10)) - L.add_box(gear, centre=(sx * 0.205, -0.16, 0.97), size=(0.105, 0.26, 0.105)) - # Glove. - L.add_box(gear, centre=(sx * 0.185, -0.29, 0.96), size=(0.095, 0.10, 0.095)) - - # ---- head ------------------------------------------------------------ - L.add_box(gear, centre=(0.0, 0.0, 1.485), size=(0.125, 0.13, 0.09)) # neck - L.add_box(armour, centre=(0.0, 0.0, 1.60), size=(0.215, 0.245, 0.185)) # helmet shell - L.add_box(armour, centre=(0.0, 0.0, 1.695), size=(0.185, 0.215, 0.045)) # crown - L.add_box(armour, centre=(0.0, -0.135, 1.585), size=(0.195, 0.05, 0.085)) # brow / visor lip - L.add_box(gear, centre=(0.0, -0.115, 1.535), size=(0.165, 0.05, 0.07)) # face shadow - # Comms boom, a small asymmetric tell for facing. - L.add_cylinder(gear, centre=(0.07, -0.115, 1.515), radius=0.012, height=0.12, segments=5, axis="Y") - - L.bevel(armour, width=0.008) - L.bevel(gear, width=0.006) + x = sx * 0.098 + L.add_box(armour, centre=(x, -0.070, 0.455), size=(0.105, 0.055, 0.115)) # knee + # Glove: a blockier cap on the wrist, so hands read without fingers. + L.add_box(gear, centre=(sx * 0.205 - sx * 0.020, -0.205, 0.935), size=(0.078, 0.115, 0.082)) + # Boot: sole plus upper. Wider than the ankle, which anchors the figure. + L.add_box(gear, centre=(x, -0.030, 0.028), size=(0.105, 0.255, 0.055)) + L.add_box(gear, centre=(x, -0.010, 0.085), size=(0.098, 0.170, 0.070)) + + # ---- backpack ---------------------------------------------------------- + L.limb( + gear, + [ + ((0.0, 0.185, 1.045), 0.130, 0.070), + ((0.0, 0.195, 1.120), 0.148, 0.082), + ((0.0, 0.195, 1.250), 0.150, 0.084), + ((0.0, 0.185, 1.330), 0.128, 0.070), + ], + segments=10, + squash=0.55, + ) + L.add_cylinder(gear, centre=(0.0, 0.268, 1.290), radius=0.045, height=0.26, segments=8, axis="X") + _ = rng + + +def build(seed: int = 31) -> None: + L.reset_scene() + rng = L.rng(seed) + + body = L.new_bmesh() # skin/undersuit, smoothed + armour = L.new_bmesh() # team-coloured plate + gear = L.new_bmesh() # webbing, boots, weapon furniture — always dark + + build_torso(body) + build_legs(body) + build_arms(body) + build_head(body) + build_gear(armour, gear, rng) + + # Smooth only the body. Gear keeps its hard edges; a smoothed plate carrier + # looks inflated rather than rigid. + L.subdivide_smooth(body, cuts=1) + + L.bevel(armour, width=0.006) + L.bevel(gear, width=0.005) L.finish_object( "character", - [L.Part(armour, L.MAT_PAINT_RED), L.Part(gear, L.MAT_RUBBER)], + [ + L.Part(body, L.MAT_RUBBER), + L.Part(armour, L.MAT_PAINT_RED), + L.Part(gear, L.MAT_RUBBER), + ], + shade_smooth_angle=math.radians(38), ) # Collision proxy matching the simulated capsule. L.add_collider("character", (0.0, 0.0, HEIGHT / 2), (0.6, 0.4, HEIGHT)) - # Where a weapon world-model attaches, and where tracers should originate - # for other players' shots. - L.add_socket("WEAPON", (0.22, -0.30, 0.98), rotation=(0.0, 0.0, 0.0)) - L.add_socket("HEAD", (0.0, 0.0, 1.62)) + # Where a weapon world-model attaches, and where other players' tracers + # should originate. + L.add_socket("WEAPON", (0.20, -0.26, 0.95)) + L.add_socket("HEAD", (0.0, 0.0, 1.63)) L.export_glb(L.output_path("character.glb")) - _ = seed, math build() diff --git a/tools/art/blender/preview.py b/tools/art/blender/preview.py index a1b82f4..d1c3cf9 100644 --- a/tools/art/blender/preview.py +++ b/tools/art/blender/preview.py @@ -120,10 +120,14 @@ def main() -> None: cam_data.lens = 60 cam = bpy.data.objects.new("preview", cam_data) angle = math.radians(38) + # `--front` puts the camera on -Y. Props are authored facing +Y, but the + # character faces -Y, and rendering a character from behind hides every + # detail worth reviewing. + facing = -1.0 if "--front" in sys.argv else 1.0 cam.location = centre + Vector( ( math.cos(angle) * radius * 3.1, - math.sin(angle) * radius * 3.1 + radius * 1.4, + facing * (math.sin(angle) * radius * 3.1 + radius * 1.4), radius * 1.35, ) )