Qu

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

printed
  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)
Drawn by running the program above.

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)")