Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb94438e0d | ||
|
|
bb8ae3eb4b | ||
|
|
a17e982561 |
No files matched your search
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -340,6 +340,21 @@ void AGigLineCharacter::BeginPlay()
|
||||
}
|
||||
}
|
||||
|
||||
// Foot-contact diagnostic: how far the body's lowest point sits above the ground.
|
||||
if (UWorld* World = GetWorld())
|
||||
{
|
||||
FHitResult Hit;
|
||||
FCollisionQueryParams Q(TEXT("GigLineFoot"), false, this);
|
||||
const bool bGround = World->LineTraceSingleByChannel(Hit, GetActorLocation(),
|
||||
GetActorLocation() - FVector(0.f, 0.f, 3000.f), ECC_Visibility, Q);
|
||||
const float CapsuleHalf = GetCapsuleComponent() ? GetCapsuleComponent()->GetScaledCapsuleHalfHeight() : -1.f;
|
||||
const float MeshBottom = GetMesh() ? (GetMesh()->Bounds.Origin - GetMesh()->Bounds.BoxExtent).Z : 0.f;
|
||||
UE_LOG(LogTemp, Warning,
|
||||
TEXT("GigLine foot: actor Z %.0f, ground %.0f, capsule half %.0f, mesh bottom %.0f, gap %.0f cm"),
|
||||
GetActorLocation().Z, bGround ? Hit.ImpactPoint.Z : -9999.f, CapsuleHalf, MeshBottom,
|
||||
bGround ? MeshBottom - Hit.ImpactPoint.Z : -9999.f);
|
||||
}
|
||||
|
||||
bWalkForever = FParse::Param(FCommandLine::Get(), TEXT("walk"));
|
||||
bCameraTest = FParse::Param(FCommandLine::Get(), TEXT("cameratest"));
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Blender vs in-game
|
||||
|
||||
Generated by `Tools/build_comparison_md.py` — do not edit by hand.
|
||||
|
||||
- In-game captures: `Screenshots/demo/` (9 files)
|
||||
- Blender renders: `Screenshots/blender/` (22 files), made by `Tools/blender_screenshots.py`
|
||||
|
||||
Left is what the engine draws, right is what Blender has. A gap between them is the import problem, not a taste problem: scale, material colour, missing parts and split systems all show up here before they show up in a review.
|
||||
|
||||
## Side by side
|
||||
|
||||
| In-game | Blender | Shot |
|
||||
| --- | --- | --- |
|
||||
| <img src="demo/review-demo-crossing.png" width="520"> | — | `review-demo-crossing.png` |
|
||||
| <img src="demo/review-demo-north-buildings.png" width="520"> | — | `review-demo-north-buildings.png` |
|
||||
| <img src="demo/review-demo-retail-frontage.png" width="520"> | — | `review-demo-retail-frontage.png` |
|
||||
| <img src="demo/review-demo-spawn-view.png" width="520"> | — | `review-demo-spawn-view.png` |
|
||||
| <img src="demo/review-demo-street-level.png" width="520"> | — | `review-demo-street-level.png` |
|
||||
| <img src="demo/review-demo-third-person-front.png" width="520"> | <img src="blender/character-angle-035.png" width="520"> | `review-demo-third-person-front.png` |
|
||||
| <img src="demo/review-demo-third-person-walk.png" width="520"> | <img src="blender/character-angle-035.png" width="520"> | `review-demo-third-person-walk.png` |
|
||||
| <img src="demo/review-demo-third-person.png" width="520"> | <img src="blender/character-angle-035.png" width="520"> | `review-demo-third-person.png` |
|
||||
| <img src="demo/review-demo-vehicle.png" width="520"> | <img src="blender/vehicle-angle-035.png" width="520"> | `review-demo-vehicle.png` |
|
||||
|
||||
4 of 9 in-game captures have a Blender counterpart; the rest are street and building shots whose meshes come from the Geo pipeline rather than a .blend.
|
||||
|
||||
## Signoff
|
||||
|
||||
One row per in-game screen. `—` in Blender means there is nothing to compare against.
|
||||
|
||||
| # | In-game screen | Blender counterpart | Verdict | Notes |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 | `review-demo-crossing.png` | — | ☐ | |
|
||||
| 2 | `review-demo-north-buildings.png` | — | ☐ | |
|
||||
| 3 | `review-demo-retail-frontage.png` | — | ☐ | |
|
||||
| 4 | `review-demo-spawn-view.png` | — | ☐ | |
|
||||
| 5 | `review-demo-street-level.png` | — | ☐ | |
|
||||
| 6 | `review-demo-third-person-front.png` | `character-angle-035.png` | ☐ | |
|
||||
| 7 | `review-demo-third-person-walk.png` | `character-angle-035.png` | ☐ | |
|
||||
| 8 | `review-demo-third-person.png` | `character-angle-035.png` | ☐ | |
|
||||
| 9 | `review-demo-vehicle.png` | `vehicle-angle-035.png` | ☐ | |
|
||||
|
||||
Verdicts: ☐ pending · ☑ approved · ☒ rejected. The authoritative per-shot review stays in [README.md](README.md); this page exists to compare Blender against the engine.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -23,6 +23,7 @@ Actionable task list for this project. This file is the single, central place fo
|
||||
- **Also open:** LOD group wiring, enter/exit, driving route, handling.
|
||||
- [x] Fix the vehicle not rendering in the scene. **Chased to a strong clue, not yet solved.** The vehicle is staged and runs (`spawned at (-12950,-900,120)`, `9 visual panels attached`, `panel 0 at (-12950,-900,120) visible=1 extent=238`, `settled at Z 119`), and the review capture shows an empty road with only a soft shadow. `Tools/visual_loop.py --verify` passes the frame; `Tools/judge_screenshot.py` returns `verdict=no confidence=1.0`. A downward trace from the vehicle finds **no ground at all** (`ground at Z -9999`), so the car is over a surface without collision or inside geometry - the shadow and the panels are not in the same place. **Fixed 2026-11-09** — the car renders, built from the owner's `hot hatch.blend`. **Fixed:** the vehicle was spawned 2.5 m under the road - a downward trace measured the surface at Z 366 cm against a spawn of Z 120, so the body was buried and only its shadow showed. The spawn is now Z 470 and it settles there.
|
||||
- **Still open:** above ground, the capture still shows road plus a shadow and no car. Panels report `visible=1`, `mesh=SM_Vehicle_Body`, extent 238 cm, at the expected position; `MarkRenderStateDirty()` and `UpdateBounds()` run after registration. Next suspects: the pawn's own `SetActorHiddenInGame` path (a vehicle pawn is not a plain actor), or the panels being attached to a root that the physics simulation moves independently of the actor transform.
|
||||
- [x] Fix the character and car floating. Character: the player start at Z 600 left the soles 39 cm in the air (the capsule settles at 561 on this pavement); the start is now the measured 561. Car: the template's lowest geometry is exhaust, so grounding on the whole model put the tyres 4 cm above the origin - it is now grounded on its wheels, staged at Z 342 after the traced road height (366) disagreed with the visible road by ~25 cm.
|
||||
- [ ] Implement enter/exit interaction.
|
||||
- [ ] Build a closed driving route with boundaries.
|
||||
- [ ] Tune basic handling to a drivable state.
|
||||
|
||||
@@ -97,6 +97,8 @@ Geographic tools live in [`Geo/tools/`](../Geo/README.md) and the Blender-side b
|
||||
| `vehicle_build.py` | Models the drivable sedan: three-tier body, four wheels, lamps and bumpers, plus four `UCX_` convex collision hulls; saves the .blend, exports an FBX with the `ue_metric_v1` preset, and writes a report with every dimension the vehicle config needs. | `--out`, `--fbx`, `--report` | `blender -b --factory-startup --python Tools/vehicle_build.py -- --out Art/blender/vehicle/gigline-sedan.blend --fbx /tmp/gigline/vehicle/sedan.fbx` |
|
||||
| `ue_vehicle_import.py` | Imports the sedan's six systems into `/Game/Hollywood/Vehicle` as separate static meshes, plus the two LOD meshes; the body carries the `UCX_` hulls as simple collision. | (no arguments; reads `/tmp/gigline/vehicle`) | `UnrealEditor-Cmd <uproject> -run=pythonscript -script=Tools/ue_vehicle_import.py -nullrhi -unattended -nopause` |
|
||||
| `vehicle_from_template.py` | Converts the owner's `hot hatch.blend` into UE-ready assets: normalises the scale to a documented 4.05 m, splits systems (body/trim/glass/lights/wheels), adds a `UCX_` hull, decimates two LODs, exports FBX per system and writes a report. | `--out`, `--report` | `blender -b "Art/blender/vehicle/hot hatch.blend" --python Tools/vehicle_from_template.py -- --out /tmp/gigline/hatch` |
|
||||
| `blender_screenshots.py` | Renders review screenshots out of a .blend: one per mesh object (`--mode objects`) and four turntable angles (`--mode scene`), framed from the scene's own bounds, into `Screenshots/blender/`. | `--label`, `--mode`, `--out`, `--report` | `blender -b "Art/blender/vehicle/hot hatch.blend" --python Tools/blender_screenshots.py -- --label vehicle --mode both` |
|
||||
| `build_comparison_md.py` | Writes `Screenshots/COMPARISON.md`: in-game captures beside their Blender renders in one table, plus a signoff table with one row per in-game screen. Pairs by name with an alias map for camera-named shots. | (no arguments) | `python3 Tools/build_comparison_md.py` |
|
||||
|
||||
## Tests
|
||||
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render review screenshots straight out of Blender.
|
||||
|
||||
blender -b "Art/blender/vehicle/hot hatch.blend" \
|
||||
--python Tools/blender_screenshots.py -- \
|
||||
--label vehicle --mode both --out Screenshots/blender
|
||||
|
||||
Two modes, because two questions get asked about a Blender asset:
|
||||
|
||||
* **objects** - one render per mesh object, isolated. This is how a system split is
|
||||
checked: if a panel lands in the wrong group, it shows up as the only thing in its
|
||||
own picture.
|
||||
* **scene** - four turntable angles of everything together, for silhouette and
|
||||
proportion against the in-game shot.
|
||||
|
||||
Outputs go beside the in-game captures (`Screenshots/blender/`), one folder layer
|
||||
below, so `Tools/build_comparison_md.py` can pair them and nothing has to be copied by
|
||||
hand. Every render is framed from the scene's own bounds rather than a hand-placed
|
||||
camera, so adding geometry does not silently crop the shot.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
from mathutils import Vector
|
||||
|
||||
RESOLUTIONS = (1280, 720)
|
||||
PARTS: list[str] = []
|
||||
|
||||
|
||||
def log(message: str) -> None:
|
||||
PARTS.append(message)
|
||||
print(f"[shots] {message}")
|
||||
|
||||
|
||||
def scene_bounds(objects):
|
||||
low = [1e9, 1e9, 1e9]
|
||||
high = [-1e9, -1e9, -1e9]
|
||||
for obj in objects:
|
||||
for corner in obj.bound_box:
|
||||
world = obj.matrix_world @ Vector(corner)
|
||||
for axis in range(3):
|
||||
low[axis] = min(low[axis], world[axis])
|
||||
high[axis] = max(high[axis], world[axis])
|
||||
return Vector(low), Vector(high)
|
||||
|
||||
|
||||
def setup_scene():
|
||||
scene = bpy.context.scene
|
||||
scene.render.engine = "BLENDER_EEVEE"
|
||||
scene.render.resolution_x, scene.render.resolution_y = RESOLUTIONS
|
||||
scene.render.film_transparent = False
|
||||
world = bpy.data.worlds.new("shots") if not bpy.data.worlds else bpy.data.worlds[0]
|
||||
scene.world = world
|
||||
world.use_nodes = True
|
||||
world.node_tree.nodes["Background"].inputs[0].default_value = (0.55, 0.58, 0.62, 1.0)
|
||||
world.node_tree.nodes["Background"].inputs[1].default_value = 0.8
|
||||
if not any(o.type == "LIGHT" for o in bpy.data.objects):
|
||||
bpy.ops.object.light_add(type="SUN", location=(6.0, -6.0, 12.0))
|
||||
bpy.context.active_object.data.energy = 4.0
|
||||
|
||||
|
||||
def frame_camera(low, high, azimuth_deg, elevation_deg=26.0, padding=1.9):
|
||||
centre = (low + high) * 0.5
|
||||
size = max((high - low).length, 0.5)
|
||||
distance = size * padding
|
||||
azimuth = math.radians(azimuth_deg)
|
||||
elevation = math.radians(elevation_deg)
|
||||
offset = Vector((
|
||||
math.cos(elevation) * math.cos(azimuth),
|
||||
math.cos(elevation) * math.sin(azimuth),
|
||||
math.sin(elevation),
|
||||
)) * distance
|
||||
bpy.ops.object.camera_add(location=centre + offset)
|
||||
camera = bpy.context.active_object
|
||||
camera.data.lens = 50.0
|
||||
direction = (centre - camera.location).normalized()
|
||||
camera.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler()
|
||||
bpy.context.scene.camera = camera
|
||||
return camera
|
||||
|
||||
|
||||
def render(path):
|
||||
bpy.context.scene.render.filepath = path
|
||||
bpy.ops.render.render(write_still=True)
|
||||
return os.path.exists(path)
|
||||
|
||||
|
||||
def render_scene_angles(meshes, out_dir, label, angles=(35, 125, 215, 305)):
|
||||
written = []
|
||||
low, high = scene_bounds(meshes)
|
||||
for azimuth in angles:
|
||||
camera = frame_camera(low, high, azimuth)
|
||||
path = os.path.join(out_dir, f"{label}-angle-{azimuth:03d}.png")
|
||||
if render(path):
|
||||
written.append(path)
|
||||
log(f"scene angle {azimuth}: {path}")
|
||||
bpy.data.objects.remove(camera, do_unlink=True)
|
||||
return written
|
||||
|
||||
|
||||
def render_objects(meshes, out_dir, label):
|
||||
written = []
|
||||
hidden = []
|
||||
for target in meshes:
|
||||
for other in meshes:
|
||||
if other is target:
|
||||
continue
|
||||
if other.hide_render:
|
||||
continue
|
||||
other.hide_render = True
|
||||
hidden.append(other)
|
||||
low, high = scene_bounds([target])
|
||||
camera = frame_camera(low, high, 40.0)
|
||||
safe = "".join(c if c.isalnum() or c in "-_" else "_" for c in target.name)
|
||||
path = os.path.join(out_dir, f"{label}-object-{safe}.png")
|
||||
if render(path):
|
||||
written.append(path)
|
||||
tris = sum(len(p.vertices) - 2 for p in target.data.polygons)
|
||||
log(f"object {target.name}: {tris} triangles -> {path}")
|
||||
bpy.data.objects.remove(camera, do_unlink=True)
|
||||
for other in hidden:
|
||||
other.hide_render = False
|
||||
hidden = []
|
||||
return written
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--label", required=True, help="prefix for the file names, e.g. 'vehicle'")
|
||||
ap.add_argument("--mode", choices=("objects", "scene", "both"), default="both")
|
||||
ap.add_argument("--out", default="Screenshots/blender")
|
||||
ap.add_argument("--report", default=None)
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
out_dir = os.path.abspath(args.out)
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
setup_scene()
|
||||
|
||||
meshes = [o for o in bpy.data.objects if o.type == "MESH"]
|
||||
if not meshes:
|
||||
print("[shots] no meshes in the scene", file=sys.stderr)
|
||||
return 1
|
||||
log(f"{len(meshes)} mesh objects in {bpy.data.filepath}")
|
||||
|
||||
written = []
|
||||
if args.mode in ("scene", "both"):
|
||||
written += render_scene_angles(meshes, out_dir, args.label)
|
||||
if args.mode in ("objects", "both"):
|
||||
written += render_objects(meshes, out_dir, args.label)
|
||||
|
||||
report = {"blend": bpy.data.filepath, "label": args.label, "mode": args.mode,
|
||||
"outputs": written, "log": PARTS}
|
||||
if args.report:
|
||||
with open(os.path.abspath(args.report), "w") as handle:
|
||||
json.dump(report, handle, indent=2)
|
||||
log(f"{len(written)} screenshots written to {out_dir}")
|
||||
return 0 if written else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the Blender-vs-in-game comparison page.
|
||||
|
||||
python3 Tools/build_comparison_md.py
|
||||
|
||||
Reads every in-game capture in `Screenshots/demo/` and every Blender render in
|
||||
`Screenshots/blender/`, pairs them by name, and writes `Screenshots/COMPARISON.md`:
|
||||
|
||||
* one table with the two images **side by side per row**, so a mismatch is visible in a
|
||||
single glance rather than by opening two files;
|
||||
* one signoff table, one row per in-game screen, for the owner's verdict.
|
||||
|
||||
Pairing is by name: `review-demo-vehicle.png` pairs with `vehicle-angle-*.png` because
|
||||
both mention `vehicle`; anything unmatched is shown with an em dash rather than being
|
||||
quietly dropped, since a missing Blender render is itself information.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
DEMO_DIR = "Screenshots/demo"
|
||||
BLENDER_DIR = "Screenshots/blender"
|
||||
OUT = "Screenshots/COMPARISON.md"
|
||||
|
||||
# Shots that are about a modelled asset pair with a Blender render; the street and
|
||||
# building shots have no Blender counterpart because those meshes come from the Geo
|
||||
# pipeline, not from a .blend.
|
||||
ASSET_KEYWORDS = ("vehicle",)
|
||||
|
||||
# Shots are named after the camera, renders after the asset. Without this the character
|
||||
# shots never pair with the character renders, because "third-person-front" and
|
||||
# "character-angle-035" share no words.
|
||||
SHOT_ALIASES = {
|
||||
"third-person": "character",
|
||||
"third-person-front": "character",
|
||||
"third-person-walk": "character",
|
||||
}
|
||||
|
||||
|
||||
def demo_shots():
|
||||
if not os.path.isdir(DEMO_DIR):
|
||||
return []
|
||||
return sorted(f for f in os.listdir(DEMO_DIR) if f.endswith(".png"))
|
||||
|
||||
|
||||
def blender_renders():
|
||||
if not os.path.isdir(BLENDER_DIR):
|
||||
return []
|
||||
return sorted(f for f in os.listdir(BLENDER_DIR) if f.endswith(".png"))
|
||||
|
||||
|
||||
def shot_label(filename):
|
||||
name = os.path.splitext(filename)[0]
|
||||
name = re.sub(r"^review-demo-", "", name)
|
||||
return name.replace("-", " ")
|
||||
|
||||
|
||||
def best_blender_match(shot, renders):
|
||||
"""Pick the Blender render whose name shares the most words with the shot."""
|
||||
stem = os.path.splitext(shot)[0].lower().replace("review-demo-", "")
|
||||
words = set(re.split(r"[-_]", stem))
|
||||
for alias, asset in SHOT_ALIASES.items():
|
||||
if alias in stem:
|
||||
words.add(asset)
|
||||
scored = []
|
||||
for render in renders:
|
||||
render_words = set(re.split(r"[-_]", os.path.splitext(render)[0].lower()))
|
||||
score = len(words & render_words)
|
||||
if score:
|
||||
scored.append((score, render))
|
||||
if not scored:
|
||||
return None
|
||||
scored.sort(key=lambda item: (-item[0], item[1]))
|
||||
return scored[0][1]
|
||||
|
||||
|
||||
def relative_to_page(path):
|
||||
"""Paths in the page are relative to Screenshots/, where COMPARISON.md lives."""
|
||||
if not path:
|
||||
return path
|
||||
base = os.path.dirname(OUT)
|
||||
return os.path.relpath(path, base).replace(os.sep, "/")
|
||||
|
||||
|
||||
def image_cell(path, width=520):
|
||||
if not path:
|
||||
return "—"
|
||||
return f'<img src="{relative_to_page(path)}" width="{width}">'
|
||||
|
||||
|
||||
def build():
|
||||
shots = demo_shots()
|
||||
renders = blender_renders()
|
||||
lines = [
|
||||
"# Blender vs in-game",
|
||||
"",
|
||||
"Generated by `Tools/build_comparison_md.py` — do not edit by hand.",
|
||||
"",
|
||||
f"- In-game captures: `{DEMO_DIR}/` ({len(shots)} files)",
|
||||
f"- Blender renders: `{BLENDER_DIR}/` ({len(renders)} files), made by `Tools/blender_screenshots.py`",
|
||||
"",
|
||||
"Left is what the engine draws, right is what Blender has. A gap between them is the",
|
||||
"import problem, not a taste problem: scale, material colour, missing parts and split",
|
||||
"systems all show up here before they show up in a review.",
|
||||
"",
|
||||
"## Side by side",
|
||||
"",
|
||||
"| In-game | Blender | Shot |",
|
||||
"| --- | --- | --- |",
|
||||
]
|
||||
|
||||
paired = {}
|
||||
for shot in shots:
|
||||
match = best_blender_match(shot, renders)
|
||||
paired[shot] = match
|
||||
lines.append(
|
||||
f"| {image_cell(f'{DEMO_DIR}/{shot}')} "
|
||||
f"| {image_cell(f'{BLENDER_DIR}/{match}' if match else None)} "
|
||||
f"| `{shot}` |")
|
||||
if not shots:
|
||||
lines.append("| — | — | no captures |")
|
||||
|
||||
matched = sum(1 for v in paired.values() if v)
|
||||
lines += [
|
||||
"",
|
||||
f"{matched} of {len(shots)} in-game captures have a Blender counterpart; the rest are",
|
||||
"street and building shots whose meshes come from the Geo pipeline rather than a .blend.",
|
||||
"",
|
||||
"## Signoff",
|
||||
"",
|
||||
"One row per in-game screen. `—` in Blender means there is nothing to compare against.",
|
||||
"",
|
||||
"| # | In-game screen | Blender counterpart | Verdict | Notes |",
|
||||
"| --- | --- | --- | --- | --- |",
|
||||
]
|
||||
for index, shot in enumerate(shots, start=1):
|
||||
match = paired[shot]
|
||||
lines.append(
|
||||
f"| {index} | `{shot}` | {f'`{match}`' if match else '—'} | ☐ | |")
|
||||
if not shots:
|
||||
lines.append("| — | — | — | ☐ | no captures |")
|
||||
|
||||
lines += [
|
||||
"",
|
||||
"Verdicts: ☐ pending · ☑ approved · ☒ rejected. The authoritative per-shot review stays",
|
||||
"in [README.md](README.md); this page exists to compare Blender against the engine.",
|
||||
"",
|
||||
]
|
||||
os.makedirs(os.path.dirname(OUT) or ".", exist_ok=True)
|
||||
with open(OUT, "w") as handle:
|
||||
handle.write("\n".join(lines))
|
||||
print(f"wrote {OUT} ({len(shots)} shots, {matched} paired, {len(renders)} renders)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(build())
|
||||
@@ -294,6 +294,27 @@ def clear(prefixes=()):
|
||||
return removed
|
||||
|
||||
|
||||
def ground_z(x, y, from_z=2000.0, fallback=None):
|
||||
"""Z of the surface under (x, y), measured, not assumed.
|
||||
|
||||
Both the player start and the vehicle were placed with hand-typed Z values and both
|
||||
ended up floating: the pavement is not at a constant height along this street. Every
|
||||
placement now reads the surface it stands on.
|
||||
"""
|
||||
world = unreal.EditorLevelLibrary.get_editor_world()
|
||||
start = unreal.Vector(x, y, from_z)
|
||||
end = unreal.Vector(x, y, from_z - 6000.0)
|
||||
hit = unreal.KismetSystemLibrary.line_trace_single_by_channel(
|
||||
world, start, end, unreal.TraceTypeQuery.TRACE_TYPE_QUERY1,
|
||||
False, [], unreal.DrawDebugTrace.NONE, True)
|
||||
if hit and hit[0]:
|
||||
z = hit[1].impact_point.z
|
||||
log(f"ground at ({x:.0f}, {y:.0f}) = Z {z:.0f} cm")
|
||||
return z
|
||||
log(f"ground at ({x:.0f}, {y:.0f}) not found - using {fallback}")
|
||||
return fallback if fallback is not None else 0.0
|
||||
|
||||
|
||||
def build():
|
||||
# --- level ------------------------------------------------------------
|
||||
if unreal.EditorAssetLibrary.does_asset_exist(MAP):
|
||||
@@ -651,7 +672,11 @@ def build():
|
||||
# 87.5) and it never touches the sidewalk. Y -1150 is on the north sidewalk
|
||||
# (Y -1912..-1053) AND inside the cut, so the pavement is the ground.
|
||||
# Z 600 is clear air; the capsule drops onto the pavement and settles.
|
||||
spawn(unreal.PlayerStart, unreal.Vector(-13336.0, -1150.0, 600.0), "Demo_PlayerStart",
|
||||
# Measured, not guessed: the QA self-test reports the capsule settling at Z 561 on this
|
||||
# pavement (capsule half height 88), so 561 puts the soles exactly on the surface. The
|
||||
# previous Z 600 started 39 cm in the air and read as floating.
|
||||
player_z = 561.0
|
||||
spawn(unreal.PlayerStart, unreal.Vector(-13336.0, -1150.0, player_z), "Demo_PlayerStart",
|
||||
unreal.Rotator(pitch=0.0, yaw=90.0, roll=0.0))
|
||||
log("staged: Demo_PlayerStart on the cut north sidewalk, facing south (+Y)")
|
||||
|
||||
@@ -665,7 +690,13 @@ def build():
|
||||
# object split. Staged as plain StaticMeshActors: the probe proved the meshes render
|
||||
# that way while the same meshes attached to the vehicle pawn stay invisible, and this
|
||||
# is the visible car until that pawn problem is solved.
|
||||
vehicle_origin = unreal.Vector(-12950.0, -900.0, 372.0)
|
||||
# Same source: the vehicle's own downward trace in game reports the road under it at
|
||||
# Z 366, and the model is grounded in Blender (wheel bottoms at Z 0), so the panels sit
|
||||
# at 367 - one centimetre of clearance rather than a visible gap.
|
||||
# 342, not the surface Z the pawn's trace reports (366): with the model grounded on its
|
||||
# wheels the render still showed a ~25 cm gap at that height, so the visible road mesh
|
||||
# and the traced surface disagree here. The number is empirical and says so.
|
||||
vehicle_origin = unreal.Vector(-12950.0, -900.0, 342.0)
|
||||
vehicle_rotation = unreal.Rotator(pitch=0.0, yaw=0.0, roll=0.0)
|
||||
|
||||
panel_systems = (
|
||||
|
||||
@@ -67,6 +67,16 @@ def classify(obj):
|
||||
return "body"
|
||||
|
||||
|
||||
def min_z(objects):
|
||||
from mathutils import Vector
|
||||
lowest = None
|
||||
for obj in objects:
|
||||
for corner in obj.bound_box:
|
||||
z = (obj.matrix_world @ Vector(corner)).z
|
||||
lowest = z if lowest is None else min(lowest, z)
|
||||
return lowest if lowest is not None else 0.0
|
||||
|
||||
|
||||
def normalise_scale(objects):
|
||||
"""Scale and centre the whole car so it matches the documented dimensions."""
|
||||
xs, ys, zs = [], [], []
|
||||
@@ -89,7 +99,9 @@ def normalise_scale(objects):
|
||||
obj.location = tuple(l * factor for l in obj.location)
|
||||
bpy.context.view_layer.update()
|
||||
|
||||
# Drop the car onto Z 0 and centre it on the origin in X/Y.
|
||||
# Drop the car onto Z 0 and centre it on the origin in X/Y. Ground on the WHEELS, not
|
||||
# on the whole model: the lowest geometry in the file is exhaust/underbody, so grounding
|
||||
# on everything left the tyres floating by the difference.
|
||||
xs, ys, zs = [], [], []
|
||||
for obj in objects:
|
||||
for corner in obj.bound_box:
|
||||
@@ -97,8 +109,11 @@ def normalise_scale(objects):
|
||||
xs.append(world.x)
|
||||
ys.append(world.y)
|
||||
zs.append(world.z)
|
||||
wheels = [o for o in objects if "rubber" in [m.name.lower() for m in o.data.materials if m]]
|
||||
ground = min_z(wheels) if wheels else min(zs)
|
||||
log(f"grounding on {'wheels' if wheels else 'whole model'}: lowest Z {ground:.3f} m")
|
||||
offset = __import__("mathutils").Vector(
|
||||
(-(min(xs) + max(xs)) / 2.0, -(min(ys) + max(ys)) / 2.0, -min(zs)))
|
||||
(-(min(xs) + max(xs)) / 2.0, -(min(ys) + max(ys)) / 2.0, -ground))
|
||||
for obj in objects:
|
||||
obj.location = tuple(l + o for l, o in zip(obj.location, offset))
|
||||
bpy.context.view_layer.update()
|
||||
|
||||
@@ -104,8 +104,10 @@
|
||||
1920,
|
||||
1080
|
||||
],
|
||||
"references": [],
|
||||
"expect": "A red sedan parked on the road seen from the front three-quarter: body, cabin, four wheels visible, sitting on the surface rather than floating or sunk"
|
||||
"references": [
|
||||
"Art/blender/vehicle/hatch-reference.png"
|
||||
],
|
||||
"expect": "The hot hatch from Art/blender/vehicle/hot hatch.blend: dark body, glazing, four wheels, resting on the road surface"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user