Audit prop budgets and add a prop-gallery art check
- audit_glb decodes geometry: 400 tris / 600 verts budget, degenerate triangles, UV range, ground contact, one material; newspaper box parts moved to sit on y=0 - capture_prop_gallery.gd renders the six props on a studio backdrop; new prop_gallery catalogue entry + AI mockup (compare 10 good, judge match 0.90) - headless test that instances share the imported atlas material - README documents the gallery capture and budget; TODO props pipeline section moved to DONE
This commit is contained in:
1 parent
bb40a98537
commit
2a4e0d1998
12 files changed
+310
-22
No files matched your search
@@ -734,3 +734,21 @@ Completed 2026-11-01 (v0.4.0). Headless **187/187**, Python tooling **53/53**, t
|
|||||||
remove collision; a unit test covers the fallback body, shape, width, and navigation obstacle.
|
remove collision; a unit test covers the fallback body, shape, width, and navigation obstacle.
|
||||||
- [x] Tests: placement validation and its failure modes, variant determinism/range, colliders on
|
- [x] Tests: placement validation and its failure modes, variant determinism/range, colliders on
|
||||||
every placed prop (including obstacle presence), and the fallback collider path.
|
every placed prop (including obstacle presence), and the fallback collider path.
|
||||||
|
|
||||||
|
## Prop Pipeline Checks
|
||||||
|
|
||||||
|
Completed 2026-11-01. Python tooling **56/56**, headless **188/188**, prop gallery **match**
|
||||||
|
(0.90) in the vision judge, `compare` **10 good / 0 bad**.
|
||||||
|
|
||||||
|
- [x] `audit_glb()` now decodes positions/UVs/indices and enforces the art budget
|
||||||
|
(`MAX_TRIANGLES = 400`, `MAX_VERTICES = 600`), rejects degenerate triangles, UVs outside
|
||||||
|
`[0, 1]`, a prop that floats above or sinks below the ground (`min y ~= 0`), and the
|
||||||
|
single-material/atlas contract. The newspaper box sat 3 cm high; its catalogue parts moved
|
||||||
|
down so it rests on y = 0.
|
||||||
|
- [x] Added `capture_prop_gallery.gd`: renders all six props on a studio backdrop (two rows on a
|
||||||
|
display platform) to `docs/screenshots/10-prop-gallery.png`, with a `prop_gallery`
|
||||||
|
catalogue entry and AI mockup, so `compare`/`judge` cover close-range prop art.
|
||||||
|
- [x] Confirmed instances share the imported atlas material (headless test) and documented the
|
||||||
|
triangle/vertex budget in the pipeline docstring.
|
||||||
|
- [x] README lists the gallery capture command and image; the pipeline docstring and audit docs
|
||||||
|
describe the budget checks.
|
||||||
@@ -52,6 +52,9 @@ Session memory for durable decisions, state, and gotchas. Keep concise, remove o
|
|||||||
flat plane, so avoidance (not a baked mesh) routes guests around props. Placements live in
|
flat plane, so avoidance (not a baked mesh) routes guests around props. Placements live in
|
||||||
`PROP_PLACEMENTS` with `placement_problems()` validation; variants come from `variant_for(index)`.
|
`PROP_PLACEMENTS` with `placement_problems()` validation; variants come from `variant_for(index)`.
|
||||||
`smoke_props.gd` must be windowed because the guard reads captured-mouse input.
|
`smoke_props.gd` must be windowed because the guard reads captured-mouse input.
|
||||||
|
- **Prop pipeline (done):** audit enforces 400 tris / 600 verts, ground contact, UV range, no
|
||||||
|
degenerate tris, one material. `capture_prop_gallery.gd` writes `10-prop-gallery.png` (3x2 studio
|
||||||
|
line-up, back row on a platform) and the `prop_gallery` catalogue entry compares it.
|
||||||
- **Presentation pass remaining (TODO):** NPC characters and player movement. Grounding facts: the
|
- **Presentation pass remaining (TODO):** NPC characters and player movement. Grounding facts: the
|
||||||
NPC rig is rigid capsules with a whole-limb swing, and `guard_controller.gd` uses one `accel`
|
NPC rig is rigid capsules with a whole-limb swing, and `guard_controller.gd` uses one `accel`
|
||||||
value. Each task should get its own windowed smoke or capture where it changes feel/looks.
|
value. Each task should get its own windowed smoke or capture where it changes feel/looks.
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ The central design goal is simple:
|
|||||||
|  |  |
|
|  |  |
|
||||||
|  |  |
|
|  |  |
|
||||||
|  |  |
|
|  |  |
|
||||||
|  | |
|
|  |  |
|
||||||
|
|
||||||
*Screenshots are generated by [`src/test/capture_screenshots.gd`](src/test/capture_screenshots.gd) from the current build — day, late-night, wand screening, escalation, and shift-review timeline.*
|
*Screenshots are generated by [`src/test/capture_screenshots.gd`](src/test/capture_screenshots.gd) and [`src/test/capture_prop_gallery.gd`](src/test/capture_prop_gallery.gd) from the current build — day, late-night, wand screening, escalation, shift-review timeline, and the prop lineup.*
|
||||||
|
|
||||||
## ⬇️ Downloads
|
## ⬇️ Downloads
|
||||||
|
|
||||||
@@ -248,6 +248,9 @@ python3 tools/screen_designs.py generate --draft --only wand --force
|
|||||||
# (requires OPENROUTER_API_KEY)
|
# (requires OPENROUTER_API_KEY)
|
||||||
python3 tools/screen_designs.py generate
|
python3 tools/screen_designs.py generate
|
||||||
|
|
||||||
|
# Prop gallery (studio line-up) for close-range prop checks
|
||||||
|
godot --path . --script res://src/test/capture_prop_gallery.gd
|
||||||
|
|
||||||
# Compare docs/screenshots/*.png against the designs
|
# Compare docs/screenshots/*.png against the designs
|
||||||
python3 tools/screen_designs.py compare
|
python3 tools/screen_designs.py compare
|
||||||
python3 tools/screen_designs.py compare --json
|
python3 tools/screen_designs.py compare --json
|
||||||
|
|||||||
@@ -5,20 +5,8 @@ suite **187/187** plus **53** Python tooling tests; the session, PLAN-verb, crow
|
|||||||
timeline, audio, scenario-content, props, and windowed focus/radial smokes pass. Anonymous
|
timeline, audio, scenario-content, props, and windowed focus/radial smokes pass. Anonymous
|
||||||
downloads are enabled on the host.
|
downloads are enabled on the host.
|
||||||
|
|
||||||
The sections below are the rest of the presentation pass: prop pipeline checks, NPC characters,
|
The sections below are the rest of the presentation pass: NPC characters and player movement.
|
||||||
and player movement. Each task is meant to be a small, reviewable slice with its own verification.
|
Each task is meant to be a small, reviewable slice with its own verification.
|
||||||
|
|
||||||
## Better Props
|
|
||||||
|
|
||||||
### Pipeline and checks
|
|
||||||
|
|
||||||
- [ ] Extend `audit_glb()` with a triangle budget, degenerate-triangle test, `TEXCOORD_0` inside
|
|
||||||
the atlas, minimum-y ≈ 0, and a material-count check per prop.
|
|
||||||
- [ ] Add a `prop_gallery` capture to `capture_screenshots.gd` (each prop close-up on a plain
|
|
||||||
backdrop) and wire it to `screen_designs.py` so prop regressions show up in `compare`.
|
|
||||||
- [ ] Verify all prop instances share their imported material (no per-instance duplication) and
|
|
||||||
keep a documented triangle/vertex budget in the pipeline docstring.
|
|
||||||
- [ ] Update the README prop list and the `model_pipeline.py` catalogue docs when new props land.
|
|
||||||
|
|
||||||
## Better NPC Characters
|
## Better NPC Characters
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
@@ -0,0 +1,104 @@
|
|||||||
|
extends SceneTree
|
||||||
|
## Dev helper: render every generated prop side by side on a plain backdrop and
|
||||||
|
## save user://screenshots/10-prop-gallery.png. The image feeds
|
||||||
|
## `tools/screen_designs.py compare` so a prop regression is visible.
|
||||||
|
##
|
||||||
|
## Usage: godot --path . --script res://src/test/capture_prop_gallery.gd
|
||||||
|
|
||||||
|
const OUT_DIR := "user://screenshots"
|
||||||
|
const MODEL_DIR := "res://assets/models/generated/"
|
||||||
|
const PROPS := ["bollard", "bench", "trash_bin", "planter_box",
|
||||||
|
"newspaper_box", "fire_hydrant"]
|
||||||
|
const SPACING := 1.9
|
||||||
|
|
||||||
|
var frames := 0
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
root.size = Vector2i(1280, 720)
|
||||||
|
_build_stage()
|
||||||
|
|
||||||
|
func _build_stage() -> void:
|
||||||
|
var stage := Node3D.new()
|
||||||
|
stage.name = "PropGallery"
|
||||||
|
root.add_child(stage)
|
||||||
|
|
||||||
|
var environment := Environment.new()
|
||||||
|
environment.background_mode = Environment.BG_COLOR
|
||||||
|
environment.background_color = Color(0.52, 0.54, 0.58)
|
||||||
|
environment.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
|
||||||
|
environment.ambient_light_color = Color(0.78, 0.80, 0.84)
|
||||||
|
environment.ambient_light_energy = 0.8
|
||||||
|
var world_env := WorldEnvironment.new()
|
||||||
|
world_env.environment = environment
|
||||||
|
stage.add_child(world_env)
|
||||||
|
|
||||||
|
# Floor and a plain backdrop wall: a studio line-up with no street context.
|
||||||
|
var floor_mesh := PlaneMesh.new()
|
||||||
|
floor_mesh.size = Vector2(14, 8)
|
||||||
|
var floor := MeshInstance3D.new()
|
||||||
|
floor.mesh = floor_mesh
|
||||||
|
floor.material_override = _flat(Color(0.50, 0.51, 0.54), 0.9)
|
||||||
|
stage.add_child(floor)
|
||||||
|
|
||||||
|
var backdrop_mesh := BoxMesh.new()
|
||||||
|
backdrop_mesh.size = Vector3(14, 6, 0.1)
|
||||||
|
var backdrop := MeshInstance3D.new()
|
||||||
|
backdrop.mesh = backdrop_mesh
|
||||||
|
backdrop.material_override = _flat(Color(0.55, 0.57, 0.61), 0.95)
|
||||||
|
backdrop.position = Vector3(0, 3.0, -3.4)
|
||||||
|
stage.add_child(backdrop)
|
||||||
|
|
||||||
|
# Raised back row so both rows read in a single shot.
|
||||||
|
var platform_mesh := BoxMesh.new()
|
||||||
|
platform_mesh.size = Vector3(6.8, 0.8, 1.9)
|
||||||
|
var platform := MeshInstance3D.new()
|
||||||
|
platform.mesh = platform_mesh
|
||||||
|
platform.material_override = _flat(Color(0.38, 0.39, 0.42), 0.85)
|
||||||
|
platform.position = Vector3(0, 0.4, -2.1)
|
||||||
|
stage.add_child(platform)
|
||||||
|
|
||||||
|
var light := DirectionalLight3D.new()
|
||||||
|
light.rotation_degrees = Vector3(-48, -35, 0)
|
||||||
|
light.light_energy = 0.85
|
||||||
|
light.shadow_enabled = true
|
||||||
|
stage.add_child(light)
|
||||||
|
|
||||||
|
var camera := Camera3D.new()
|
||||||
|
camera.position = Vector3(0, 2.1, 6.2)
|
||||||
|
camera.fov = 50.0
|
||||||
|
camera.look_at_from_position(camera.position, Vector3(0, 0.75, -1.0))
|
||||||
|
camera.current = true
|
||||||
|
stage.add_child(camera)
|
||||||
|
|
||||||
|
# 3 x 2 grid: three props on the front row, three on the back row.
|
||||||
|
var columns := [-1.95, 0.0, 1.95]
|
||||||
|
for index in PROPS.size():
|
||||||
|
var prop_name: String = PROPS[index]
|
||||||
|
var packed := ResourceLoader.load(MODEL_DIR + prop_name + ".glb") as PackedScene
|
||||||
|
if packed == null:
|
||||||
|
continue
|
||||||
|
var model := packed.instantiate() as Node3D
|
||||||
|
var col: int = index % 3
|
||||||
|
var row: int = index / 3
|
||||||
|
model.position = Vector3(columns[col], 0.8 if row == 1 else 0.0, -2.1 * row)
|
||||||
|
model.rotation_degrees.y = 14.0
|
||||||
|
stage.add_child(model)
|
||||||
|
|
||||||
|
func _flat(color: Color, roughness: float) -> StandardMaterial3D:
|
||||||
|
var material := StandardMaterial3D.new()
|
||||||
|
material.albedo_color = color
|
||||||
|
material.roughness = roughness
|
||||||
|
return material
|
||||||
|
|
||||||
|
func _process(_delta: float) -> bool:
|
||||||
|
frames += 1
|
||||||
|
if frames < 20:
|
||||||
|
return false
|
||||||
|
var dir := DirAccess.open("user://")
|
||||||
|
if dir == null or not dir.dir_exists("screenshots"):
|
||||||
|
dir.make_dir_recursive("screenshots")
|
||||||
|
var image := root.get_texture().get_image()
|
||||||
|
var err := image.save_png(OUT_DIR.path_join("10-prop-gallery.png"))
|
||||||
|
print("10-prop-gallery.png -> err=%d (%dx%d)" % [err, image.get_width(), image.get_height()])
|
||||||
|
quit(0)
|
||||||
|
return true
|
||||||
@@ -68,6 +68,34 @@ func test_prop_scenes_share_one_atlas_material() -> bool:
|
|||||||
instance.free()
|
instance.free()
|
||||||
return TestUtils.check(problems.is_empty(), "one atlas material per prop (%s)" % str(problems))
|
return TestUtils.check(problems.is_empty(), "one atlas material per prop (%s)" % str(problems))
|
||||||
|
|
||||||
|
func test_prop_instances_share_the_imported_material() -> bool:
|
||||||
|
## Instancing a prop must not duplicate its atlas material; every copy uses
|
||||||
|
## the same resource, so many props stay cheap to draw.
|
||||||
|
var problems: Array[String] = []
|
||||||
|
for prop in WorldBuilder.MODEL_PROPS:
|
||||||
|
var packed := ResourceLoader.load(MODEL_DIR + str(prop) + ".glb") as PackedScene
|
||||||
|
if packed == null:
|
||||||
|
problems.append("%s: no scene" % prop)
|
||||||
|
continue
|
||||||
|
var first := packed.instantiate()
|
||||||
|
var second := packed.instantiate()
|
||||||
|
var a := _first_material(first)
|
||||||
|
var b := _first_material(second)
|
||||||
|
if a == null or b == null:
|
||||||
|
problems.append("%s: missing material" % prop)
|
||||||
|
elif a.get_instance_id() != b.get_instance_id():
|
||||||
|
problems.append("%s: material is duplicated per instance" % prop)
|
||||||
|
first.free()
|
||||||
|
second.free()
|
||||||
|
return TestUtils.check(problems.is_empty(), "prop instances share materials (%s)" % str(problems))
|
||||||
|
|
||||||
|
func _first_material(instance: Node) -> Material:
|
||||||
|
for node in instance.find_children("*", "MeshInstance3D", true, false):
|
||||||
|
var mesh: Mesh = (node as MeshInstance3D).mesh
|
||||||
|
if mesh != null and mesh.get_surface_count() > 0:
|
||||||
|
return mesh.surface_get_material(0)
|
||||||
|
return null
|
||||||
|
|
||||||
func test_world_places_generated_props() -> bool:
|
func test_world_places_generated_props() -> bool:
|
||||||
var root := Node3D.new()
|
var root := Node3D.new()
|
||||||
var builder := WorldBuilder.new(root)
|
var builder := WorldBuilder.new(root)
|
||||||
|
|||||||
+93
-6
@@ -11,7 +11,9 @@ keeps that art direction in one place:
|
|||||||
`generate_textures.py`) supplies the colour palette, so a model can look
|
`generate_textures.py`) supplies the colour palette, so a model can look
|
||||||
like the concept without a 3D-model API;
|
like the concept without a 3D-model API;
|
||||||
* `build` writes standard glTF binary (`.glb`) files that Godot 4 imports as
|
* `build` writes standard glTF binary (`.glb`) files that Godot 4 imports as
|
||||||
`PackedScene`s, and `audit` checks the files are structurally valid.
|
`PackedScene`s, and `audit` checks the files are structurally valid and stay
|
||||||
|
inside the art budget (<= 400 triangles, <= 600 vertices per prop, no
|
||||||
|
degenerate triangles, UVs in the atlas, base at y ~= 0, one material).
|
||||||
|
|
||||||
Commands (run from the repo root):
|
Commands (run from the repo root):
|
||||||
|
|
||||||
@@ -118,13 +120,13 @@ PROPS = [
|
|||||||
"short stand, plain light grey studio background"),
|
"short stand, plain light grey studio background"),
|
||||||
"parts": [
|
"parts": [
|
||||||
{"shape": "box", "size": [0.44, 0.10, 0.38],
|
{"shape": "box", "size": [0.44, 0.10, 0.38],
|
||||||
"at": [0, 0.08, 0], "role": "metal"},
|
"at": [0, 0.05, 0], "role": "metal"},
|
||||||
{"shape": "box", "size": [0.42, 0.72, 0.34],
|
{"shape": "box", "size": [0.42, 0.72, 0.34],
|
||||||
"at": [0, 0.49, 0], "role": "body"},
|
"at": [0, 0.46, 0], "role": "body"},
|
||||||
{"shape": "box", "size": [0.34, 0.30, 0.03],
|
{"shape": "box", "size": [0.34, 0.30, 0.03],
|
||||||
"at": [0, 0.60, 0.18], "role": "glass"},
|
"at": [0, 0.57, 0.18], "role": "glass"},
|
||||||
{"shape": "box", "size": [0.44, 0.10, 0.36],
|
{"shape": "box", "size": [0.44, 0.10, 0.36],
|
||||||
"at": [0, 0.90, 0], "role": "accent"},
|
"at": [0, 0.87, 0], "role": "accent"},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -705,8 +707,58 @@ def build(only="", quiet=False) -> list:
|
|||||||
# Audit.
|
# Audit.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
MAX_TRIANGLES = 400
|
||||||
|
MAX_VERTICES = 600
|
||||||
|
_DEGENERATE_AREA2 = 1e-9
|
||||||
|
|
||||||
|
|
||||||
|
def _read_float_accessor(gltf: dict, binary: bytes, index: int) -> list:
|
||||||
|
"""Decode a float SCALAR/VEC2/VEC3 accessor into a list of tuples/values."""
|
||||||
|
accessor = gltf["accessors"][index]
|
||||||
|
count = int(accessor["count"])
|
||||||
|
kind = accessor["type"]
|
||||||
|
width = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4}[kind]
|
||||||
|
view = gltf["bufferViews"][accessor["bufferView"]]
|
||||||
|
offset = int(view.get("byteOffset", 0)) + int(accessor.get("byteOffset", 0))
|
||||||
|
stride = int(view.get("byteStride", width * 4))
|
||||||
|
values = []
|
||||||
|
for i in range(count):
|
||||||
|
base = offset + i * stride
|
||||||
|
row = struct.unpack_from("<" + "f" * width, binary, base)
|
||||||
|
values.append(row[0] if width == 1 else row)
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def _read_index_accessor(gltf: dict, binary: bytes, index: int) -> list:
|
||||||
|
accessor = gltf["accessors"][index]
|
||||||
|
width = {5121: 1, 5123: 2, 5125: 4}.get(int(accessor["componentType"]), 2)
|
||||||
|
fmt = {1: "B", 2: "H", 4: "I"}[width]
|
||||||
|
view = gltf["bufferViews"][accessor["bufferView"]]
|
||||||
|
offset = int(view.get("byteOffset", 0)) + int(accessor.get("byteOffset", 0))
|
||||||
|
stride = int(view.get("byteStride", width))
|
||||||
|
out = []
|
||||||
|
for i in range(int(accessor["count"])):
|
||||||
|
out.append(struct.unpack_from("<" + fmt, binary,
|
||||||
|
offset + i * stride)[0])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _triangle_area2(a, b, c) -> float:
|
||||||
|
ab = (b[0] - a[0], b[1] - a[1], b[2] - a[2])
|
||||||
|
ac = (c[0] - a[0], c[1] - a[1], c[2] - a[2])
|
||||||
|
cross = (ab[1] * ac[2] - ab[2] * ac[1],
|
||||||
|
ab[2] * ac[0] - ab[0] * ac[2],
|
||||||
|
ab[0] * ac[1] - ab[1] * ac[0])
|
||||||
|
return cross[0] * cross[0] + cross[1] * cross[1] + cross[2] * cross[2]
|
||||||
|
|
||||||
|
|
||||||
def audit_glb(path: Path) -> dict:
|
def audit_glb(path: Path) -> dict:
|
||||||
"""Check a .glb file's container, JSON chunk, and triangle count."""
|
"""Check a .glb file's container, JSON chunk, and mesh contract.
|
||||||
|
|
||||||
|
The mesh must stay in the prop budget, have no degenerate triangles, keep
|
||||||
|
atlas UVs inside [0, 1], sit on the ground (min y ~= 0), and use exactly one
|
||||||
|
atlas material.
|
||||||
|
"""
|
||||||
issues = []
|
issues = []
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
return {"path": str(path), "ok": False,
|
return {"path": str(path), "ok": False,
|
||||||
@@ -765,6 +817,41 @@ def audit_glb(path: Path) -> dict:
|
|||||||
for buffer in gltf.get("buffers", []):
|
for buffer in gltf.get("buffers", []):
|
||||||
if int(buffer.get("byteLength", 0)) > len(data):
|
if int(buffer.get("byteLength", 0)) > len(data):
|
||||||
issues.append("buffer larger than the file")
|
issues.append("buffer larger than the file")
|
||||||
|
if triangles > MAX_TRIANGLES:
|
||||||
|
issues.append(f"triangle budget exceeded ({triangles} > {MAX_TRIANGLES})")
|
||||||
|
# Decode geometry for the deeper checks. A malformed buffer view is already
|
||||||
|
# reported above, so skip quietly when the binary chunk is unusable.
|
||||||
|
try:
|
||||||
|
_, bin_len, _ = struct.unpack_from("<III", data, 12 + 8 + json_len)
|
||||||
|
binary = data[12 + 8 + json_len + 8:12 + 8 + json_len + 8 + bin_len]
|
||||||
|
vertices = 0
|
||||||
|
for mesh in gltf.get("meshes", []):
|
||||||
|
for primitive in mesh.get("primitives", []):
|
||||||
|
attributes = primitive.get("attributes", {})
|
||||||
|
positions = _read_float_accessor(
|
||||||
|
gltf, binary, attributes["POSITION"])
|
||||||
|
vertices += len(positions)
|
||||||
|
if positions:
|
||||||
|
min_y = min(p[1] for p in positions)
|
||||||
|
if abs(min_y) > 0.02:
|
||||||
|
issues.append(
|
||||||
|
f"prop does not sit on the ground (min y {min_y:.3f})")
|
||||||
|
uvs = _read_float_accessor(
|
||||||
|
gltf, binary, attributes["TEXCOORD_0"])
|
||||||
|
if any(not (0.0 <= u <= 1.0 and 0.0 <= v <= 1.0)
|
||||||
|
for u, v in uvs):
|
||||||
|
issues.append("atlas UV outside [0, 1]")
|
||||||
|
indices = _read_index_accessor(
|
||||||
|
gltf, binary, primitive["indices"])
|
||||||
|
for tri in range(0, len(indices), 3):
|
||||||
|
a, b, c = (positions[indices[tri + i]] for i in range(3))
|
||||||
|
if _triangle_area2(a, b, c) < _DEGENERATE_AREA2:
|
||||||
|
issues.append("degenerate triangle")
|
||||||
|
break
|
||||||
|
if vertices > MAX_VERTICES:
|
||||||
|
issues.append(f"vertex budget exceeded ({vertices} > {MAX_VERTICES})")
|
||||||
|
except (KeyError, IndexError, ValueError, struct.error) as exc:
|
||||||
|
issues.append(f"unreadable geometry: {exc}")
|
||||||
return {"path": str(path), "ok": not issues, "issues": issues,
|
return {"path": str(path), "ok": not issues, "issues": issues,
|
||||||
"triangles": triangles}
|
"triangles": triangles}
|
||||||
|
|
||||||
|
|||||||
@@ -495,6 +495,26 @@ SCREENS = [
|
|||||||
(0.02, 0.20, 0.36, 0.65, "reaction log"),
|
(0.02, 0.20, 0.36, 0.65, "reaction log"),
|
||||||
(0.02, 0.94, 0.98, 0.99, "control hints"),
|
(0.02, 0.94, 0.98, 0.99, "control hints"),
|
||||||
],
|
],
|
||||||
|
}, {
|
||||||
|
"name": "prop_gallery",
|
||||||
|
"kind": "scene",
|
||||||
|
"title": "Prop gallery",
|
||||||
|
"source": "tools/model_pipeline.py · docs screenshot set",
|
||||||
|
"ref": "10-prop-gallery.png",
|
||||||
|
"description": "Line-up of the six generated street-furniture props — a bollard, "
|
||||||
|
"bench, trash bin, planter box, newspaper box and fire hydrant — on "
|
||||||
|
"a plain studio backdrop, used as a close-range art check.",
|
||||||
|
"prompt": (
|
||||||
|
"Product line-up photograph of six low-poly 3D street-furniture props on a "
|
||||||
|
"plain light grey studio backdrop with a low display platform: a black steel "
|
||||||
|
"bollard, a wooden bench with black legs, a dark green trash bin, a white "
|
||||||
|
"planter box with a green hedge, a blue newspaper vending box and a red fire "
|
||||||
|
"hydrant. Even soft studio lighting, straight-on wide shot, muted realistic "
|
||||||
|
"colours, no people, no street context"),
|
||||||
|
"regions": [
|
||||||
|
(0.22, 0.14, 0.80, 0.47, "back row: planter, newspaper box, hydrant"),
|
||||||
|
(0.22, 0.38, 0.80, 0.68, "front row: bollard, bench, bin"),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -195,6 +195,43 @@ class AuditTests(unittest.TestCase):
|
|||||||
self.assertFalse(result["ok"])
|
self.assertFalse(result["ok"])
|
||||||
self.assertTrue(result["issues"])
|
self.assertTrue(result["issues"])
|
||||||
|
|
||||||
|
def test_audit_enforces_the_triangle_budget(self):
|
||||||
|
old = model_pipeline.MAX_TRIANGLES
|
||||||
|
model_pipeline.MAX_TRIANGLES = 10
|
||||||
|
try:
|
||||||
|
path = self.root / "bench.glb"
|
||||||
|
path.write_bytes(model_pipeline.build_glb(prop("bench")))
|
||||||
|
result = model_pipeline.audit_glb(path)
|
||||||
|
finally:
|
||||||
|
model_pipeline.MAX_TRIANGLES = old
|
||||||
|
self.assertFalse(result["ok"])
|
||||||
|
self.assertTrue(any("triangle budget" in issue for issue in result["issues"]),
|
||||||
|
result["issues"])
|
||||||
|
|
||||||
|
def test_degenerate_triangle_helper(self):
|
||||||
|
self.assertEqual(
|
||||||
|
model_pipeline._triangle_area2((0, 0, 0), (1, 0, 0), (2, 0, 0)), 0.0)
|
||||||
|
self.assertGreater(
|
||||||
|
model_pipeline._triangle_area2((0, 0, 0), (1, 0, 0), (0, 1, 0)), 0.0)
|
||||||
|
|
||||||
|
def test_audit_reads_positions_and_uvs_from_the_binary(self):
|
||||||
|
parsed = parse_glb(model_pipeline.build_glb(prop("bollard")))
|
||||||
|
gltf, binary = parsed["gltf"], parsed["binary"]
|
||||||
|
primitive = gltf["meshes"][0]["primitives"][0]
|
||||||
|
positions = model_pipeline._read_float_accessor(
|
||||||
|
gltf, binary, primitive["attributes"]["POSITION"])
|
||||||
|
uvs = model_pipeline._read_float_accessor(
|
||||||
|
gltf, binary, primitive["attributes"]["TEXCOORD_0"])
|
||||||
|
indices = model_pipeline._read_index_accessor(
|
||||||
|
gltf, binary, primitive["indices"])
|
||||||
|
self.assertEqual(len(positions), gltf["accessors"][
|
||||||
|
primitive["attributes"]["POSITION"]]["count"])
|
||||||
|
self.assertEqual(len(uvs), len(positions))
|
||||||
|
self.assertEqual(len(indices) % 3, 0)
|
||||||
|
self.assertLess(min(p[1] for p in positions), 0.01)
|
||||||
|
for u, v in uvs:
|
||||||
|
self.assertTrue(0.0 <= u <= 1.0 and 0.0 <= v <= 1.0)
|
||||||
|
|
||||||
def test_build_writes_into_the_models_directory(self):
|
def test_build_writes_into_the_models_directory(self):
|
||||||
old = model_pipeline.MODELS_DIR
|
old = model_pipeline.MODELS_DIR
|
||||||
model_pipeline.MODELS_DIR = self.root
|
model_pipeline.MODELS_DIR = self.root
|
||||||
|
|||||||
Reference in new issue
Block a user