Uncategorised
qu_prm_viewer
loads a Re-Volt .PRM mesh (via load_prm, see qu_prm_loader.qu) and renders it as a single shaded, vertex-coloured 3D object: orthographic projection, painter's-algorithm depth sorting, per-face Lambertian shading from the file's own vertex normals, filled
catalog/qu_prm_viewer.qu
with the file's own per-vertex ARGB colours -- entirely in Qu, on top of the existing 2D plotting pipeline (`polygon(xs, ys, fill=...)`).
WHY NO 3D ENGINE: read directly before writing a line of this -- plotting.rs has no projection, no camera, no depth buffer; emission is SVG/PDF/TikZ. Building one would be a real subsystem (the eventual Canvas 3D viewer, not yet designed). This gets a real shaded object on screen tonight by staying in 2D the whole way: rotate points, drop Z, sort triangles back-to-front, draw filled polygons in that order. A painter's algorithm has one known failure mode -- interpenetrating geometry, where no single sort order is correct for every pair of triangles -- which does not arise for one ordinary, non-self- intersecting mesh.
DECISIONS MADE HERE, RECORDED RATHER THAN LEFT OPEN (per the ask: choose and record, don't post and wait -- these were flagged as open questions in qu_prm_loader.qu's own board2 write-up):
1. QUADS: triangulated on ingest, fan style -- a quad's corners (0,1,2,3) become triangles (0,1,2) and (0,2,3). Simplest choice that works for both triangles and quads with one code path; a non-planar or non-convex quad would triangulate slightly differently by a different diagonal, but Re-Volt car/track quads are, in practice, planar rectangles. 2. VERTEX ALPHA: ignored. The reference loader's alpha convention is mesh-KIND-dependent (car body vs world geometry) and load_prm's Tables carry no such flag -- rather than guess a kind, every triangle here is drawn fully opaque. Revisit once Canvas 3D actually needs semi-transparency. 3. COORDINATES/WINDING: load_prm's numbers are used exactly as the file stores them (no Y-flip, no winding reversal) -- that loader deliberately did not inherit the reference engine's Unity convention, and a viewer is where a convention gets CHOSEN, not where one gets silently inherited. The camera below is placed to show a recognisable view of THESE coordinates directly. 4. PROJECTION: orthographic (drop the rotated Z, keep it only for depth sorting), not perspective. A stretch for later -- correct, simpler, and immune to a stray vertex near the camera exploding the divide in `x/z`. 5. SHADING: flat per-triangle Lambertian -- each triangle's normal is the average of its three vertex normals (already in the file, already parsed), dotted against a fixed light direction, with a constant ambient floor so a triangle facing away from the light is dim rather than black. 6. TEXTURE: not attempted. The stretch goal, correctly identified as the hard part in an SVG-only pipeline (pattern fills or a per-triangle affine image transform) -- a clean shaded, vertex- coloured mesh that actually renders beats a textured one that does not.
How it works
Self-test: a synthesized cube, 24 vertices (4 PER FACE rather than 8
shared corners, so every vertex carries its own face's true outward normal -- the way a hard-edged Re-Volt asset would, rather than smoothing across an edge) -- written by hand at the byte level, same discipline as qu_prm_loader.qu's own demo, so this file is proven before it ever touches the real local scarab.prm asset (never committed -- third-party asset). This caught two real bugs during development that the loader's own single-poly demo could not have: `mod(a, b)` is a parse error in Qu (`mod` is the infix operator `a mod b`, not a function -- unpack_rgb originally called it as one), and `polygon(fill=...)` defaults to alpha 0.18 (tuned for overlapping density-style fills), which silently let every "hidden" back face show straight through the front ones and would have defeated the entire painter's-algorithm depth sort. Neither survives this test.
THE FILE FORMAT IS POLYS-THEN-VERTS: the write calls below happen in that byte order even though the vertex DATA is listed in the comments face-by-face for readability -- an earlier draft wrote all vertices first and got silently misparsed as a result.
function write_face(w, i0, i1, i2, i3, argb)
write_int16(w, 1) # type: quad bit set
write_int16(w, 0) # tpage
write_int16(w, i0)
write_int16(w, i1)
write_int16(w, i2)
write_int16(w, i3)
write_uint32(w, argb)
write_uint32(w, argb)
write_uint32(w, argb)
write_uint32(w, argb)
write_float(w, 0.0); write_float(w, 0.0)
write_float(w, 1.0); write_float(w, 0.0)
write_float(w, 1.0); write_float(w, 1.0)
write_float(w, 0.0); write_float(w, 1.0)
end function
function write_vert(w, x, y, z, nx, ny, nz)
write_float(w, x); write_float(w, y); write_float(w, z)
write_float(w, nx); write_float(w, ny); write_float(w, nz)
end function
tmp_path = tmp_file()
w = fopen(tmp_path, "wb")
write_int16(w, 6) # poly_count: 6 quad faces
write_int16(w, 24) # vert_count: 4 per face, no sharing
# 0.. 3: back (z=-1) normal (0,0,-1) -- red
# 4.. 7: front (z=+1) normal (0,0, 1) -- green
# 8..11: left (x=-1) normal (-1,0,0) -- blue
# 12..15: right (x=+1) normal ( 1,0,0) -- yellow
# 16..19: top (y=+1) normal (0, 1,0) -- magenta
# 20..23: bottom (y=-1) normal (0,-1,0) -- cyan
write_face(w, 0, 1, 2, 3, 0xFFCC3333) # back -- red
write_face(w, 5, 4, 7, 6, 0xFF33CC33) # front -- green
write_face(w, 8, 9, 10, 11, 0xFF3355CC) # left -- blue
write_face(w, 13, 12, 15, 14, 0xFFCCCC33) # right -- yellow
write_face(w, 16, 17, 18, 19, 0xFFCC33CC) # top -- magenta
write_face(w, 21, 20, 23, 22, 0xFF33CCCC) # bottom -- cyan
write_vert(w, -1.0, -1.0, -1.0, 0.0, 0.0, -1.0)
write_vert(w, 1.0, -1.0, -1.0, 0.0, 0.0, -1.0)
write_vert(w, 1.0, 1.0, -1.0, 0.0, 0.0, -1.0)
write_vert(w, -1.0, 1.0, -1.0, 0.0, 0.0, -1.0)
write_vert(w, -1.0, -1.0, 1.0, 0.0, 0.0, 1.0)
write_vert(w, 1.0, -1.0, 1.0, 0.0, 0.0, 1.0)
write_vert(w, 1.0, 1.0, 1.0, 0.0, 0.0, 1.0)
write_vert(w, -1.0, 1.0, 1.0, 0.0, 0.0, 1.0)
write_vert(w, -1.0, -1.0, -1.0, -1.0, 0.0, 0.0)
write_vert(w, -1.0, -1.0, 1.0, -1.0, 0.0, 0.0)
write_vert(w, -1.0, 1.0, 1.0, -1.0, 0.0, 0.0)
write_vert(w, -1.0, 1.0, -1.0, -1.0, 0.0, 0.0)
write_vert(w, 1.0, -1.0, -1.0, 1.0, 0.0, 0.0)
write_vert(w, 1.0, -1.0, 1.0, 1.0, 0.0, 0.0)
write_vert(w, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0)
write_vert(w, 1.0, 1.0, -1.0, 1.0, 0.0, 0.0)
write_vert(w, -1.0, 1.0, -1.0, 0.0, 1.0, 0.0)
write_vert(w, 1.0, 1.0, -1.0, 0.0, 1.0, 0.0)
write_vert(w, 1.0, 1.0, 1.0, 0.0, 1.0, 0.0)
write_vert(w, -1.0, 1.0, 1.0, 0.0, 1.0, 0.0)
write_vert(w, -1.0, -1.0, -1.0, 0.0, -1.0, 0.0)
write_vert(w, 1.0, -1.0, -1.0, 0.0, -1.0, 0.0)
write_vert(w, 1.0, -1.0, 1.0, 0.0, -1.0, 0.0)
write_vert(w, -1.0, -1.0, 1.0, 0.0, -1.0, 0.0)
close(w)
result = render_prm(tmp_path, "qu_prm_viewer_selftest.svg")
print("self-test: triangles={result.triangles}, polys={result.polys}, verts={result.verts}")
print("(expect 12 triangles, 6 polys, 24 verts -- inspect qu_prm_viewer_selftest.svg for a shaded cube, six faces, distinct colours, no transparency)")
Results
note: this figure uses screen styling and may not hold up in print -- call theme("publication") before saving for a paper (said once per session)
note: fonts render using whatever is installed on the viewer's machine (none are embedded) -- pass embed_fonts=true (fontfamily preset only) or export .tikz for guaranteed appearance
self-test: triangles=12, polys=6, verts=24
(expect 12 triangles, 6 polys, 24 verts -- inspect qu_prm_viewer_selftest.svg for a shaded cube, six faces, distinct colours, no transparency)The whole program
Runnable as it stands: qu run catalog/qu_prm_viewer.qu
# qu_prm_viewer.qu -- loads a Re-Volt .PRM mesh (via load_prm, see
# qu_prm_loader.qu) and renders it as a single shaded, vertex-coloured 3D
# object: orthographic projection, painter's-algorithm depth sorting,
# per-face Lambertian shading from the file's own vertex normals, filled
# with the file's own per-vertex ARGB colours -- entirely in Qu, on top
# of the existing 2D plotting pipeline (`polygon(xs, ys, fill=...)`).
#
# WHY NO 3D ENGINE: read directly before writing a line of this --
# plotting.rs has no projection, no camera, no depth buffer; emission is
# SVG/PDF/TikZ. Building one would be a real subsystem (the eventual
# Canvas 3D viewer, not yet designed). This gets a real shaded object on
# screen tonight by staying in 2D the whole way: rotate points, drop Z,
# sort triangles back-to-front, draw filled polygons in that order. A
# painter's algorithm has one known failure mode -- interpenetrating
# geometry, where no single sort order is correct for every pair of
# triangles -- which does not arise for one ordinary, non-self-
# intersecting mesh.
#
# DECISIONS MADE HERE, RECORDED RATHER THAN LEFT OPEN (per the ask: choose
# and record, don't post and wait -- these were flagged as open questions
# in qu_prm_loader.qu's own board2 write-up):
#
# 1. QUADS: triangulated on ingest, fan style -- a quad's corners
# (0,1,2,3) become triangles (0,1,2) and (0,2,3). Simplest choice
# that works for both triangles and quads with one code path; a
# non-planar or non-convex quad would triangulate slightly
# differently by a different diagonal, but Re-Volt car/track quads
# are, in practice, planar rectangles.
# 2. VERTEX ALPHA: ignored. The reference loader's alpha convention is
# mesh-KIND-dependent (car body vs world geometry) and load_prm's
# Tables carry no such flag -- rather than guess a kind, every
# triangle here is drawn fully opaque. Revisit once Canvas 3D
# actually needs semi-transparency.
# 3. COORDINATES/WINDING: load_prm's numbers are used exactly as the
# file stores them (no Y-flip, no winding reversal) -- that loader
# deliberately did not inherit the reference engine's Unity
# convention, and a viewer is where a convention gets CHOSEN, not
# where one gets silently inherited. The camera below is placed to
# show a recognisable view of THESE coordinates directly.
# 4. PROJECTION: orthographic (drop the rotated Z, keep it only for
# depth sorting), not perspective. A stretch for later -- correct,
# simpler, and immune to a stray vertex near the camera exploding
# the divide in `x/z`.
# 5. SHADING: flat per-triangle Lambertian -- each triangle's normal is
# the average of its three vertex normals (already in the file,
# already parsed), dotted against a fixed light direction, with a
# constant ambient floor so a triangle facing away from the light is
# dim rather than black.
# 6. TEXTURE: not attempted. The stretch goal, correctly identified as
# the hard part in an SVG-only pipeline (pattern fills or a
# per-triangle affine image transform) -- a clean shaded, vertex-
# coloured mesh that actually renders beats a textured one that
# does not.
function load_prm(path)
f = fopen(path, "rb")
a = read_int16(f)
b = read_int16(f)
body = file_size(path) - 4
if a * 60 + b * 24 == body then
poly_count = a
vert_count = b
elseif b * 60 + a * 24 == body then
poly_count = b
vert_count = a
else
poly_count = a
vert_count = b
end if
poly_fields = (("type", "i16"), ("tpage", "i16"),
("vi0", "i16"), ("vi1", "i16"), ("vi2", "i16"), ("vi3", "i16"),
("c0", "u32"), ("c1", "u32"), ("c2", "u32"), ("c3", "u32"),
("u0", "f32"), ("v0", "f32"), ("u1", "f32"), ("v1", "f32"),
("u2", "f32"), ("v2", "f32"), ("u3", "f32"), ("v3", "f32"))
polys = read_structs(f, poly_fields, poly_count)
vert_fields = (("px", "f32"), ("py", "f32"), ("pz", "f32"),
("nx", "f32"), ("ny", "f32"), ("nz", "f32"))
verts = read_structs(f, vert_fields, vert_count)
close(f)
return {poly_count = poly_count, vert_count = vert_count,
polys = polys, verts = verts}
end function
# Packed color is 0xAARRGGBB (the reference loader reads alpha as
# `c >> 24`, red `c >> 16`, etc.) -- alpha discarded per decision 2 above.
function unpack_rgb(c)
r = floor(c / 65536.0) mod 256.0
g = floor(c / 256.0) mod 256.0
b = c mod 256.0
return (r, g, b)
end function
# The colour at poly `i`'s corner `corner` (0..3) -- a plain match rather
# than a computed column name, since a Table's columns are fixed fields
# (c0..c3), not dynamically indexable by a variable.
function corner_color(P, i, corner)
if corner == 0 then
return P.c0[i]
elseif corner == 1 then
return P.c1[i]
elseif corner == 2 then
return P.c2[i]
else
return P.c3[i]
end if
end function
function to_hex(r, g, b)
return "#" + hex2(r) + hex2(g) + hex2(b)
end function
function hex2(v)
v = max(0.0, min(255.0, floor(v + 0.5)))
digits = "0123456789abcdef"
hi = floor(v / 16.0)
lo = v mod 16.0
return substr(digits, hi, 1) + substr(digits, lo, 1)
end function
# Rotate around X by `ax`, then around the (new) Y by `ay` -- a fixed,
# hand-picked view angle chosen to show three faces of a roughly
# axis-aligned mesh at once, the ordinary "isometric-ish" convention.
function rotate3(x, y, z, ax, ay)
y1 = y * cos(ax) - z * sin(ax)
z1 = y * sin(ax) + z * cos(ax)
x2 = x * cos(ay) + z1 * sin(ay)
z2 = 0.0 - x * sin(ay) + z1 * cos(ay)
return (x2, y1, z2)
end function
function normalize3(x, y, z)
n = sqrt(x * x + y * y + z * z)
if n < 1e-12 then
return (0.0, 0.0, 1.0)
end if
return (x / n, y / n, z / n)
end function
# render_prm(path, out_svg) -- the whole pipeline, one call.
function render_prm(path, out_svg)
mesh = load_prm(path)
P = mesh.polys
V = mesh.verts
np = mesh.poly_count
# ---- 1. triangulate: (vi0,vi1,vi2) always, plus (vi0,vi2,vi3) when
# the quad bit (type & 1) is set -- collected as flat parallel lists
# so no per-triangle record type is needed. `pi`/`ca`/`cb`/`cc` track
# which POLY each triangle came from and which of its four corners
# (0..3) each of the triangle's three vertices is, so the right
# colour column can be looked up later -- the second triangle of a
# quad reads corners 0, 2, 3, not 0, 1, 2.
ta = ()
tb = ()
tc = ()
pi = ()
ca = ()
cb = ()
cc = ()
for i = 0 to np - 1
ta = append(ta, P.vi0[i])
tb = append(tb, P.vi1[i])
tc = append(tc, P.vi2[i])
pi = append(pi, i)
ca = append(ca, 0)
cb = append(cb, 1)
cc = append(cc, 2)
if P.type[i] mod 2 == 1 then
ta = append(ta, P.vi0[i])
tb = append(tb, P.vi2[i])
tc = append(tc, P.vi3[i])
pi = append(pi, i)
ca = append(ca, 0)
cb = append(cb, 2)
cc = append(cc, 3)
end if
end for
ntri = length(ta)
# ---- 2. a fixed isometric-ish view: 20 degrees down, 35 degrees
# around -- shows top, front and one side of an axis-aligned object.
ax = 0.35
ay = 0.61
light = normalize3(-0.4, -0.6, -0.7)
ambient = 0.35
depths = zeros(ntri)
for i = 0 to ntri - 1
ia = ta[i]
ib = tb[i]
ic = tc[i]
ra_ = rotate3(V.px[ia], V.py[ia], V.pz[ia], ax, ay)
rb_ = rotate3(V.px[ib], V.py[ib], V.pz[ib], ax, ay)
rc_ = rotate3(V.px[ic], V.py[ic], V.pz[ic], ax, ay)
depths[i] = (ra_[2] + rb_[2] + rc_[2]) / 3.0
end for
# Painter's algorithm: farthest (most negative-toward-camera, i.e.
# LARGEST z into the screen under this rotation) drawn FIRST.
order = argsort(depths)
order = reverse(order)
# `figure()` first, NOT just `figure_size(...)`: sizing the canvas
# does not clear it -- shapes accumulate onto whatever figure state
# already exists (same as matplotlib: `figsize=` alone never implies
# `plt.figure()`). Without this, calling `render_prm` after ANY
# other plotting activity in the same run -- including this file's
# own self-test, if a caller happens to `import` it -- silently
# draws the new mesh on top of the old one's leftover triangles into
# one contaminated SVG. Found by rendering the real scarab.prm right
# after this file's self-test had already run at import time: the
# output had 216 triangles, not 204, the extra 12 an exact byte-for-
# byte match for the self-test cube's own colours.
figure()
figure_size(180, 140, unit = "mm")
axis equal
axis off
hold on
for k = 0 to ntri - 1
i = order[k]
ia = ta[i]
ib = tb[i]
ic = tc[i]
pa_ = rotate3(V.px[ia], V.py[ia], V.pz[ia], ax, ay)
pb_ = rotate3(V.px[ib], V.py[ib], V.pz[ib], ax, ay)
pc_ = rotate3(V.px[ic], V.py[ic], V.pz[ic], ax, ay)
xa = pa_[0]
ya = pa_[1]
xb = pb_[0]
yb = pb_[1]
xc = pc_[0]
yc = pc_[1]
na_ = rotate3(V.nx[ia], V.ny[ia], V.nz[ia], ax, ay)
nb_ = rotate3(V.nx[ib], V.ny[ib], V.nz[ib], ax, ay)
nc_ = rotate3(V.nx[ic], V.ny[ic], V.nz[ic], ax, ay)
navg = normalize3((na_[0] + nb_[0] + nc_[0]) / 3.0,
(na_[1] + nb_[1] + nc_[1]) / 3.0,
(na_[2] + nb_[2] + nc_[2]) / 3.0)
nx_ = navg[0]
ny_ = navg[1]
nz_ = navg[2]
diffuse = max(0.0, nx_ * light[0] + ny_ * light[1] + nz_ * light[2])
shade = ambient + (1.0 - ambient) * diffuse
# Average the triangle's own three corner colours, each looked up
# from the poly's ACTUAL corner (ca[i]/cb[i]/cc[i]) rather than
# assumed to be 0/1/2 -- the second half of a triangulated quad
# is corners 0, 2, 3.
p = pi[i]
ca_rgb = unpack_rgb(corner_color(P, p, ca[i]))
cb_rgb = unpack_rgb(corner_color(P, p, cb[i]))
cc_rgb = unpack_rgb(corner_color(P, p, cc[i]))
r = (ca_rgb[0] + cb_rgb[0] + cc_rgb[0]) / 3.0 * shade
g = (ca_rgb[1] + cb_rgb[1] + cc_rgb[1]) / 3.0 * shade
b = (ca_rgb[2] + cb_rgb[2] + cc_rgb[2]) / 3.0 * shade
# `alpha = 1.0` is NOT decorative: `polygon(fill=...)` defaults to
# alpha 0.18 (tuned for overlapping density-style fills), which
# would make every "hidden" back face show straight through the
# front ones and defeat the entire painter's-algorithm depth sort.
polygon((xa, xb, xc), (ya, yb, yc), fill = to_hex(r, g, b), stroke = "none", alpha = 1.0)
end for
savefig(out_svg)
return {triangles = ntri, polys = np, verts = mesh.vert_count}
end function
backend auto
#%% Self-test: a synthesized cube, 24 vertices (4 PER FACE rather than 8
# shared corners, so every vertex carries its own face's true outward
# normal -- the way a hard-edged Re-Volt asset would, rather than
# smoothing across an edge) -- written by hand at the byte level, same
# discipline as qu_prm_loader.qu's own demo, so this file is proven
# before it ever touches the real local scarab.prm asset (never
# committed -- third-party asset). This caught two real bugs during
# development that the loader's own single-poly demo could not have:
# `mod(a, b)` is a parse error in Qu (`mod` is the infix operator `a mod
# b`, not a function -- unpack_rgb originally called it as one), and
# `polygon(fill=...)` defaults to alpha 0.18 (tuned for overlapping
# density-style fills), which silently let every "hidden" back face
# show straight through the front ones and would have defeated the
# entire painter's-algorithm depth sort. Neither survives this test.
#
# THE FILE FORMAT IS POLYS-THEN-VERTS: the write calls below happen in
# that byte order even though the vertex DATA is listed in the comments
# face-by-face for readability -- an earlier draft wrote all vertices
# first and got silently misparsed as a result.
function write_face(w, i0, i1, i2, i3, argb)
write_int16(w, 1) # type: quad bit set
write_int16(w, 0) # tpage
write_int16(w, i0)
write_int16(w, i1)
write_int16(w, i2)
write_int16(w, i3)
write_uint32(w, argb)
write_uint32(w, argb)
write_uint32(w, argb)
write_uint32(w, argb)
write_float(w, 0.0); write_float(w, 0.0)
write_float(w, 1.0); write_float(w, 0.0)
write_float(w, 1.0); write_float(w, 1.0)
write_float(w, 0.0); write_float(w, 1.0)
end function
function write_vert(w, x, y, z, nx, ny, nz)
write_float(w, x); write_float(w, y); write_float(w, z)
write_float(w, nx); write_float(w, ny); write_float(w, nz)
end function
tmp_path = tmp_file()
w = fopen(tmp_path, "wb")
write_int16(w, 6) # poly_count: 6 quad faces
write_int16(w, 24) # vert_count: 4 per face, no sharing
# 0.. 3: back (z=-1) normal (0,0,-1) -- red
# 4.. 7: front (z=+1) normal (0,0, 1) -- green
# 8..11: left (x=-1) normal (-1,0,0) -- blue
# 12..15: right (x=+1) normal ( 1,0,0) -- yellow
# 16..19: top (y=+1) normal (0, 1,0) -- magenta
# 20..23: bottom (y=-1) normal (0,-1,0) -- cyan
write_face(w, 0, 1, 2, 3, 0xFFCC3333) # back -- red
write_face(w, 5, 4, 7, 6, 0xFF33CC33) # front -- green
write_face(w, 8, 9, 10, 11, 0xFF3355CC) # left -- blue
write_face(w, 13, 12, 15, 14, 0xFFCCCC33) # right -- yellow
write_face(w, 16, 17, 18, 19, 0xFFCC33CC) # top -- magenta
write_face(w, 21, 20, 23, 22, 0xFF33CCCC) # bottom -- cyan
write_vert(w, -1.0, -1.0, -1.0, 0.0, 0.0, -1.0)
write_vert(w, 1.0, -1.0, -1.0, 0.0, 0.0, -1.0)
write_vert(w, 1.0, 1.0, -1.0, 0.0, 0.0, -1.0)
write_vert(w, -1.0, 1.0, -1.0, 0.0, 0.0, -1.0)
write_vert(w, -1.0, -1.0, 1.0, 0.0, 0.0, 1.0)
write_vert(w, 1.0, -1.0, 1.0, 0.0, 0.0, 1.0)
write_vert(w, 1.0, 1.0, 1.0, 0.0, 0.0, 1.0)
write_vert(w, -1.0, 1.0, 1.0, 0.0, 0.0, 1.0)
write_vert(w, -1.0, -1.0, -1.0, -1.0, 0.0, 0.0)
write_vert(w, -1.0, -1.0, 1.0, -1.0, 0.0, 0.0)
write_vert(w, -1.0, 1.0, 1.0, -1.0, 0.0, 0.0)
write_vert(w, -1.0, 1.0, -1.0, -1.0, 0.0, 0.0)
write_vert(w, 1.0, -1.0, -1.0, 1.0, 0.0, 0.0)
write_vert(w, 1.0, -1.0, 1.0, 1.0, 0.0, 0.0)
write_vert(w, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0)
write_vert(w, 1.0, 1.0, -1.0, 1.0, 0.0, 0.0)
write_vert(w, -1.0, 1.0, -1.0, 0.0, 1.0, 0.0)
write_vert(w, 1.0, 1.0, -1.0, 0.0, 1.0, 0.0)
write_vert(w, 1.0, 1.0, 1.0, 0.0, 1.0, 0.0)
write_vert(w, -1.0, 1.0, 1.0, 0.0, 1.0, 0.0)
write_vert(w, -1.0, -1.0, -1.0, 0.0, -1.0, 0.0)
write_vert(w, 1.0, -1.0, -1.0, 0.0, -1.0, 0.0)
write_vert(w, 1.0, -1.0, 1.0, 0.0, -1.0, 0.0)
write_vert(w, -1.0, -1.0, 1.0, 0.0, -1.0, 0.0)
close(w)
result = render_prm(tmp_path, "qu_prm_viewer_selftest.svg")
print("self-test: triangles={result.triangles}, polys={result.polys}, verts={result.verts}")
print("(expect 12 triangles, 6 polys, 24 verts -- inspect qu_prm_viewer_selftest.svg for a shaded cube, six faces, distinct colours, no transparency)")