Skip to content

v0.0.4

Latest

Choose a tag to compare

@MagicalTux MagicalTux released this 15 Jun 05:13
· 9 commits to master since this release
9e23ab3

Other

  • round 308 — capture superseded cdc / cdp / res statements for verbatim round-trip
  • round 302 — MTL map_aat per-material texture anti-aliasing toggle
  • Round 295: draw con connectivity seam as parameter-space polyline pair
  • Round 290: embed scrv special curve as surface triangle edges
  • Round 282: sub-cell trim/hole boundary re-meshing on tessellated surfaces
  • Round 273: typed trim / hole / scrv loop accessor on Scene3D::extras
  • Round 266: typed ctech / stech approximation-technique accessor
  • Round 254: typed parm u/v body-statement accessor on Scene3D::extras
  • Round 251: typed con connectivity accessor on Scene3D::extras
  • drop release-plz.toml — use release-plz defaults across the workspace
  • Round 246: typed sp (special-point) accessor + synthetic-primitive pass
  • Round 243: maplib + usemap rendering-identifier pair
  • Round 240: MTL map_* options typed decomposition
  • Round 236: MTL Ka/Kd/Ks spectral + xyz alternative forms
  • Round 229: connectivity (con) + general-statement (call / csh) round-trip
  • Round 223: ctech / stech / shadow_obj / trace_obj round-trip
  • Round 218: multi-patch Bezier surf surface decomposition
  • Round 212: MTL illum model property decomposition
  • Round 206: scrv special-curve tessellation
  • Round 201: surface trim/hole clipping against curv2 loops

Added

  • Round 308: the three remaining superseded OBJ statements — cdc
    (Cardinal curve), cdp (Cardinal patch), and res useg vseg (the
    segment-count reference/display statement) per spec §"Superseded
    statements" — now round-trip verbatim through
    Scene3D::extras["obj:freeform_directives"], joining the
    already-handled bzp / bsp patches. The spec marks all five
    read-only ("This release is the last release that will read these
    statements. … read in the file and write it out."), so the parser
    captures them on input rather than silently dropping them into the
    lenient-loader fall-through. Because cdc / cdp reference vertex
    positions by index, the obj:positions re-emit condition now also
    fires for those keywords so the referenced position pool survives a
    decode → encode → decode cycle even when no polygonal element consumes
    it; res carries only the two segment counts and needs no position
    pool.

  • Round 302: MTL map_aat on per-material texture anti-aliasing toggle
    per spec §"map_aat on" ("Turns on anti-aliasing of textures in this
    material without anti-aliasing all textures in the scene"). The flag is
    surfaced as a boolean Material::extras["mtl:map_aat"] and round-trips
    the exact on / off token. The spec documents only the on form,
    but the keyword is a boolean state-setter so off is accepted
    symmetrically; any other or missing argument drops the line silently
    (lenient-loader convention) without failing the parse. The serialiser
    emits the flag explicitly (the string-only pass-through loop can't carry
    a bool) ahead of the other pass-through map keywords.

  • Round 295: connectivity (con) seam tessellation — spec §"Connectivity
    between free-form surfaces", §"con surf_1 q0_1 q1_1 curv2d_1 surf_2 q0_2
    q1_2 curv2d_2". With with_curve_tessellation(samples) enabled, every
    con statement now emits a pair of parameter-space
    Topology::LineStrip seams — one per joined surface edge — on a new
    synthetic mesh named "obj:cons". Where round 251 surfaced the eight
    raw arguments as the typed Scene3D::extras["obj:connectivity"] view,
    this pass draws the seam itself ("This information is useful for edge
    merging"): each side's curv2d is resolved through the same
    collect_all_curv2_polylines pre-pass the trim / hole / scrv
    passes use, and the [q0, q1] sub-range is walked with the shared
    append_curv2_segment so a connectivity seam is sampled identically to
    a special-curve segment. The appendix correspondence (S1(T1(t1)) for
    t1 ∈ [q0_1, q1_1] joined to S2(T2(t2)) for t2 ∈ [q0_2, q1_2],
    "identical up to reparameterization", endpoints meeting exactly) means
    the two emitted seams are the two sides of one weld. Per-seam
    provenance: the shared obj:tessellated_curve sentinel, an obj:con
    marker, obj:con_side (1/2), obj:con_surf + obj:con_peer_surf
    (the joined surface's index and its mate's), obj:con_curv2d, and
    obj:con_q0 / obj:con_q1. A con without exactly eight arguments is
    dropped from the geometry view; a side whose curve doesn't resolve
    (non-positive / undefined curv2d, or a zero-length parameter range —
    e.g. the spec example's 2.0 2.0 point-join) drops on its own while
    the other side still emits. The spec's merging-group exclusion
    ("Connectivity between surfaces in different merging groups is ignored")
    is left to the consumer's renderer-side pruning over the mg state.
    Verbatim round-trip is untouched — the encoder filters the seams via the
    shared sentinel and replays the original con line. +9 tests
    (connectivity_seams.rs).

  • Round 290: special-curve (scrv) embedding as surface triangle edges
    (spec §"Special curve": "A special curve is guaranteed to be included
    in any triangulation of the surface. … the line formed by
    approximating the special curve with a sequence of straight line
    segments will actually appear as a sequence of triangle edges in the
    final triangulation"). The round-206 scrv pass only emitted the
    special curve as a stand-alone parameter-space polyline on the
    obj:scrvs mesh; the tessellated obj:surfaces triangle grid ignored
    it, leaving the spec's triangle-edge guarantee unmet. Now every surf
    whose enclosing cstype … end block carries a scrv directive has the
    special curve embedded into its triangulation. The scrv is resolved
    to a parameter-space polyline (same (u0, u1, curv2d) body grammar and
    collect_all_curv2_polylines pre-pass trim / hole use, but left
    open — a special curve is a constraint, not a closed region), then each
    straight segment is forced to coincide with a chain of triangle edges.
    The constraint runs on the final kept geometry after the round-282
    trim/hole re-mesh, so trimming and special-curve embedding compose. The
    embedder operates on the triangle soup with no adjacency structure: any
    triangle whose interior a segment crosses is split so the chord between
    the two boundary hits becomes an edge; crossing vertices are
    deduplicated on a quantised parameter grid (watertight across adjacent
    splits); each synthesised vertex's 3D position is the barycentric blend
    of its host triangle's corners (so the embedded curve is exactly as
    accurate as the surrounding piecewise-linear facet — no new surface
    evaluation is introduced); and a segment already coincident with
    existing lattice edges counts as embedded with no split. New
    per-surface provenance obj:surface_scrv (marker),
    obj:surface_scrv_curves (count of special curves that overlapped the
    meshed surface), and obj:surface_scrv_vertices (count of synthesised
    constraint vertices). Verbatim round-trip is untouched — the synthetic
    surface still carries the shared obj:tessellated_curve sentinel so
    the encoder filters it and replays the original scrv block from
    Scene3D::extras["obj:freeform_directives"].

  • Round 282: sub-cell trim/hole boundary re-meshing (spec §"Trimming
    loops and holes", §"trim u0 u1 curv2d …", §"hole u0 u1 curv2d …").
    The round-201 conservative clip dropped any lattice triangle whose
    three corners didn't all classify inside the trimmed region, so the
    trim edge stayed jagged at the lattice grain. Straddling boundary
    triangles (1 or 2 corners kept) are now clipped against the in/out
    classification function instead of dropped wholesale: each crossing
    lattice edge is bisected in parameter space until the inside/outside
    frontier is pinned (24 rounds ≈ 2⁻²⁴ of the edge length), the
    synthesised boundary vertex — 3D position interpolated linearly
    along the lattice edge, the same piecewise-linear approximation the
    triangle lattice itself carries — is appended after the
    (samples + 1)² lattice block, and the kept sub-polygon (corner
    triangle for 1-kept, quad split into two triangles for 2-kept) is
    emitted with the original CCW winding. Crossings are cached per
    undirected lattice edge so adjacent straddling cells share their
    boundary vertex and the re-meshed rim stays watertight;
    sub-triangles whose parameter-space area collapses below 10⁻⁶ of a
    lattice cell (loops grazing a lattice line exactly) are suppressed
    rather than emitted as degenerate slivers, and boundary vertices
    left unreferenced by that suppression are garbage-collected from
    the vertex pool. On an axis-aligned square loop sitting between
    lattice lines the kept area now matches the analytic trimmed area
    to within the chord-across-corner error (~0.4 % observed at
    8 samples) where the conservative whole-cell staircase missed
    ≈ 60 % of the loop on the same fixture. New per-primitive
    provenance obj:surface_trim_boundary_vertices counts the
    synthesised vertices (0 when every straddling cell collapsed to
    suppressed slivers). Verbatim round-trip is untouched — the encoder
    still filters synthetic surfaces via the shared
    obj:tessellated_curve sentinel and replays the original trim /
    hole block from Scene3D::extras["obj:freeform_directives"].

  • Round 273: typed decomposition of the trim / hole / scrv loop
    body statements per spec §"Trimming loops and holes" / §"trim u0 u1
    curv2d …" / §"hole u0 u1 curv2d …" and §"Special curve" / §"scrv u0
    u1 curv2d …". All three share the identical repeating-triple body
    shape (the keyword followed by one or more (u0, u1, curv2d)
    triples). Parallel to the verbatim obj:freeform_directives channel
    (which still carries every trim / hole / scrv line for
    round-trip), a parse-time-only typed view now lands on
    Scene3D::extras["obj:trim_loops"] as an array of objects with the
    four stable, lowercase, underscore-separated keys loop_kind /
    element_kind / cstype / segments. The loop_kind is exactly
    "trim", "hole", or "scrv"; the element_kind is the directive
    that opened the enclosing cstype … end block ("surf" for the
    spec-legal host, "unknown" when the loop is seen outside a surface
    block); the cstype slug carries the recognised type from the
    enclosing cstype header ("bezier" / "rat_bezier" / "bspline" /
    "rat_bspline" / "cardinal" / "taylor" / "bmatrix", or
    "unknown"), reusing the same disambiguation table the parm /
    ctech / stech typed views use. The segments array decomposes
    every (u0, u1, curv2d) triple in source order — u0 / u1 land as
    f64, curv2d as i64 (negative-from-end references, which the
    spec §"Examples" case 8 special-curve example uses, are echoed as-is
    without resolution). A line whose argument count isn't a positive
    multiple of three, or any of whose tokens fail to parse, drops from
    the typed view without failing the parse (the verbatim channel stays
    the source of truth for the encoder). Mirrors the lossy-on-malformed
    policy of the existing sp / con / parm typed views.

  • Round 266: typed decomposition of the ctech / stech approximation-
    technique directives per spec §"ctech technique resolution" + §"stech
    technique resolution". Parallel to the verbatim
    obj:freeform_directives channel (which still carries every
    ctech / stech line for round-trip), a parse-time-only typed view
    now lands on Scene3D::extras["obj:approximations"] as an array of
    objects with the four stable, lowercase, underscore-separated keys
    element_kind / technique / parameters / cstype. The
    element_kind is exactly "curve" for a ctech line and
    "surface" for an stech line per spec ("specifies a curve
    approximation technique" / "specifies a surface approximation
    technique"). The technique is the spec-defined sub-form slug
    ("cparm" / "cspace" / "curv" for curves; "cparma" /
    "cparmb" / "cspace" / "curv" for surfaces); unrecognised
    technique tokens drop the whole line from the typed view. The
    parameters array carries the parsed f64 resolution arguments in
    source order, with per-form arities cparm/cspace/cparmb = 1
    and curv/cparma = 2; a parameter token that fails to parse drops
    the whole line. The cstype slug mirrors the existing parm typed
    view's cstype slot (one of "bezier" / "rat_bezier" /
    "bspline" / "rat_bspline" / "cardinal" / "taylor" /
    "bmatrix", or "unknown"). The encoder is still driven by the
    verbatim channel — the typed view exists purely so consumers don't
    have to re-parse the per-technique positional tokens. New tests in
    tests/approximation_and_companions.rs cover: per-form
    decomposition for the three ctech and four stech sub-shapes;
    cstype slug pinning across two different blocks; lossy-on-
    malformed policy (wrong arity, non-numeric parameter); unrecognised
    technique slug rejection; outside-block cstype = "unknown"
    fallback; absent-key behaviour; and decode → encode → decode
    stability of the typed view.

  • Round 254: typed decomposition of the parm u … / parm v … body
    statement per spec §"parm u/v" + §"Free-form curve/surface body
    statements". Parallel to the verbatim obj:freeform_directives
    channel (which still carries every parm line for round-trip), a
    parse-time-only typed view now lands on
    Scene3D::extras["obj:parms"] as an array of objects with the four
    stable, lowercase, underscore-separated keys direction /
    element_kind / cstype / values. The direction is exactly "u"
    or "v" per spec; the element kind ("curv" / "curv2" / "surf")
    is decided by the most recent curv / curv2 / surf directive
    inside the current cstype … end block; the cstype slug is the
    recognised type from the enclosing cstype header (one of
    "bezier" / "rat_bezier" / "bspline" / "rat_bspline" /
    "cardinal" / "taylor" / "bmatrix"), or "unknown" when the
    declared type isn't one of those names. values is the parsed
    array of f64 — the global parameters for Bezier / Cardinal /
    Taylor / basis-matrix elements, or the knot vector for B-spline /
    NURBS elements per the spec's twin role for the parm keyword. The
    encoder is still driven by the verbatim channel — the typed view
    exists purely so consumers don't have to walk the directive
    sequence pairing every parm line with its enclosing cstype block

    • element kind. Lines whose direction token isn't exactly "u" /
      "v", or that sit outside any element (no curv / curv2 / surf
      seen since the last cstype), drop from the typed view without
      failing the parse (the verbatim channel still replays them
      byte-faithful). Non-numeric value tokens drop from the per-line
      values array — mirrors the lenient-on-malformed policy of the
      existing sp / con typed views. New tests/parm_typed.rs suite (10
      tests) covering: two-direction surface block (u + v), Bezier curve
      global-parameter single-direction line, curv2 inside cstype rat bezier (verifying the rat_bezier slug), round-trip stability of
      the typed view, source-order preservation across multiple
      cstype … end blocks, unknown-direction drop, parm-outside-element
      drop, absent-key when the file has no parm lines, non-numeric
      token drop within a line, and unknown-cstype slug for unrecognised
      types.
  • Round 251: typed decomposition of the con connectivity statement
    per spec §"Connectivity between free-form surfaces" / §"con surf_1
    q0_1 q1_1 curv2d_1 surf_2 q0_2 q1_2 curv2d_2". Parallel to the
    verbatim obj:freeform_directives channel (which still carries
    every con line for round-trip), a parse-time-only typed view now
    lands on Scene3D::extras["obj:connectivity"] as an array of
    objects with the eight stable, lowercase, underscore-separated keys
    surf_1 / q0_1 / q1_1 / curv2d_1 / surf_2 / q0_2 /
    q1_2 / curv2d_2. Integer slots (surf_*, curv2d_*) surface
    as i64; parameter slots (q0_*, q1_*) surface as f64. The
    encoder is still driven by the verbatim channel — the typed view
    exists purely so consumers don't have to re-parse the eight
    positional tokens themselves. Lines that don't carry exactly eight
    arguments, or whose integer / float slots fail to parse, drop from
    the typed view without failing the parse (the verbatim channel
    still replays them byte-faithful). Negative indices in the surf_*
    / curv2d_* slots are echoed as-is — the typed view doesn't
    resolve them against the surface / curv2 streams because surfaces
    aren't numbered in the captured directive sequence. Test coverage
    in the existing connectivity_and_general suite (6 new tests
    covering eight-key decomposition, round-trip stability, multi-line
    source order, negative-index echo, malformed-line drop policy, and
    absent-key when no con directive is present).

  • Round 246: typed decomposition of the sp (special-point) body
    statement per spec §"Special point", §"sp vp1 vp …". The verbatim
    obj:freeform_directives channel still carries every sp line for
    round-trip; a parse-time-only typed view now lands on
    Scene3D::extras["obj:special_points"] as an array of objects with
    the stable keys element_kind / vp_index_1based / u / v, in
    source order. The element kind is decided by the directive that
    opened the enclosing cstypeend block (curvv = null
    because space-curve special points are 1D per spec; curv2 → both
    u and v, matching the spec's "essentially the same as a special
    point on the surface it trims" note; surf → both components per
    the 2D-parameter-vertex requirement). A companion synthetic
    Topology::Points primitive lands on a new "obj:sps" mesh under
    the existing with_curve_tessellation(samples) knob, one per sp
    directive, with per-primitive provenance extras
    (obj:special_point marker, obj:special_point_element_kind,
    obj:special_point_vp_refs). The shared obj:tessellated_curve
    sentinel keeps the encoder's is_tessellated_curve filter from
    re-emitting the synthetic mesh; the sp line itself replays
    verbatim from the directive array. Negative vp references resolve
    relative-from-end; references outside the live vp pool (and 0)
    drop silently from both the typed view and the synthetic primitive
    without failing the parse. sp lines outside any open cstype
    block are omitted from the typed view (no enclosing element kind to
    resolve against) but still appear in the verbatim replay.

  • Round 243: OBJ rendering-identifier pair maplib / usemap per spec
    §"maplib filename1 filename2 ..." and §"usemap map_name/off". Both
    are siblings to the already-supported material identifiers
    (mtllib / usemtl) but cover the texture-map library rather than
    the material library. maplib lib1.map lib2.map ... lines land in
    Scene3D::extras["obj:maplibs"] as a verbatim string array with
    the same de-duplication policy as mtllib; usemap <name> /
    usemap off is captured per-primitive in
    Primitive::extras["obj:usemap"]. The state-setter semantics
    mirror usemtl: a mid-stream change opens a fresh primitive that
    inherits all the other active state (groups, smoothing/merging
    group, display attributes, the usemap binding when usemtl
    switches, the usemtl material when usemap switches). The
    encoder replays maplib lines after mtllib and a usemap line
    per-primitive after usemtl, both byte-stable through a decode →
    encode → decode cycle. Documents that don't carry either directive
    produce neither extras key and neither encode line — the round-trip
    contract is "preserve only what the operator wrote".

  • Round 240: typed decomposition of map_* option flags per MTL spec
    §"Options for texture map statements". Parallel to the existing raw
    Material::extras["mtl:<map>:options"] string array (which still
    drives encoder round-trip), the parser now surfaces
    Material::extras["mtl:<map>:options_typed"] as a serde_json::Value
    object whose keys are decomposed primitive values: blendu / blendv
    / clamp / cc (bool, derived from the spec's on / off
    arguments), bm / boost / texres (f64), mm ([base, gain]
    f64 pair), o / s / t ([u, v, w] f64 triple), imfchan
    (String over r | g | b | m | l | z per §"-imfchan"), and type
    (String over the sphere | cube_top | cube_bottom | cube_front | cube_back | cube_left | cube_right alphabet per §"refl -type"). Each
    key is present only when the source line carried the matching flag;
    malformed argument values (e.g. -clamp maybe, -imfchan q) drop
    silently from the typed view but stay verbatim on the raw :options
    array so encoder output keeps its byte-for-byte source-order
    guarantee. The typed key is consumed at parse time only — the
    encoder explicitly filters :options_typed out of its passthrough
    loop so it never appears in serialised MTL. Mirrors the
    mtl:illum_props decomposition pattern from round 212. Nested
    options inside mtl:refl:sphere and mtl:refl:cube[<face>] entries
    also gain a sibling options_typed field so per-face reflection
    metadata is uniformly structured. The raw :options array remains
    the canonical source of truth — consumers who need exact source
    text should keep reading it; consumers who want named accessors can
    now read :options_typed without parsing strings.

  • Round 236: MTL Ka / Kd / Ks spectral and xyz alternative
    forms. Spec §"Ka r g b" / §"Kd r g b" / §"Ks r g b" each list the
    same three mutually-exclusive forms K* r g b / K* spectral file.rfl factor / K* xyz x y z (mirroring the Tf triplet);
    rounds 1..5 only handled the RGB form so a real-world MTL using a
    spectral or CIEXYZ colour spec was a parse error. The spectral form
    now lands on Material::extras["mtl:K{a,d,s}:spectral"] as a
    {file, factor} object (factor defaults to 1.0 per spec) and the
    xyz form on Material::extras["mtl:K{a,d,s}:xyz"] as an [x, y, z]
    array (y/z default to x per spec). RGB single-value broadcast
    (Ka 0.4[0.4, 0.4, 0.4] per spec "If only r is specified, then
    g, and b are assumed to be equal to r") is now honoured for all
    three statements (previously the parser required exactly 3 floats).
    The encoder picks the first present sibling key per material
    (precedence: spectralxyz → RGB) so a decode → encode cycle
    reproduces the operator-written form. For Kd specifically, the
    alt forms suppress the canonical Kd r g b emit driven by
    base_color (since the forms are mutually exclusive per spec); the
    underlying base_color field stays at its default so glTF
    consumers still see a sensible neutral. Tf was refactored to
    share the new parse_color_statement helper without changing its
    observable behaviour.

  • Round 229: connectivity (con) + general-statement (call / csh)
    round-trip. Three previously-dropped spec-defined directives now
    survive a decode → encode cycle verbatim:

    • con surf_1 q0_1 q1_1 curv2d_1 surf_2 q0_2 q1_2 curv2d_2 (spec
      §"Connectivity between free-form surfaces", §"con surf_1 q0_1
      q1_1 curv2d_1 surf_2 q0_2 q1_2 curv2d_2") — a top-level free-form
      geometry statement that ties two previously-declared surf
      blocks together along a shared trimming-curve segment for edge
      merging. Captured into the existing
      Scene3D::extras["obj:freeform_directives"] verbatim-replay
      channel; the encoder emits it in source order alongside the
      other free-form geometry directives (the worked example in spec
      §"Connectivity between free-form surfaces" §"Example 1" places
      con after the last referenced surface's end, which the
      captured-sequence ordering preserves).
    • call filename.ext arg1 arg2 … (spec §"General statement",
      §"call filename.ext arg1 arg2 …") — inline include of a sibling
      .obj / .mod file with positional argument substitution.
      Captured-only; the parser does NOT recursively resolve the
      referenced file (would require IO + nested-call depth tracking
      outside the clean-room parser's scope). Consumers can re-resolve
      against the captured filename + arg vector themselves.
    • csh command / csh -command (spec §"General statement",
      §"csh command") — shell-execute a UNIX command, with a leading
      - flagging "ignore error on non-zero exit". Captured-only; the
      parser deliberately does NOT execute the captured command
      (sandbox-escape trapdoor for any consumer that round-trips
      untrusted OBJ input). The leading-dash failure-tolerant variant
      is preserved verbatim through the round-trip so the semantic
      distinction doesn't get silently inverted.

    Both general statements land on a new
    Scene3D::extras["obj:general_directives"] side-channel array of
    [keyword, arg1, arg2, …] entries in document order; the encoder
    replays them at the top of the preamble (right after the mtllib
    and shadow_obj / trace_obj companion-file block). Source-line
    position relative to the polygonal section is NOT preserved by
    design — the spec is silent on placement ("The call statement can
    be inserted into .obj files using a text editor"). Empty inputs
    (no call / csh lines) leave the extras unchanged.

    Test coverage in the new connectivity_and_general suite (12
    tests): all eight con positional arguments captured, source-order
    preservation across the polygonal / free-form boundary,
    negative-index con survival, call with and without positional
    args, csh with and without the leading-dash failure-tolerant
    flag, observable-side-effect probe confirming the parser does NOT
    exec the captured shell command, combined-mixed-input round-trip
    through the trait surface, absent-key omission for sources with no
    general statements, and bare-keyword csh line acceptance.

  • Round 223: approximation-technique directives and companion-object
    references. Four spec-defined directives that were previously dropped
    on read now round-trip cleanly:

    • ctech technique resolution (spec §"ctech technique resolution")
      — three forms cparm res (constant parametric subdivision),
      cspace maxlength (constant spatial subdivision), and
      curv maxdist maxangle (curvature-dependent subdivision). All
      three ride the existing
      Scene3D::extras["obj:freeform_directives"] verbatim-capture
      channel alongside the other free-form curve/surface attributes.
    • stech technique resolution (spec §"stech technique resolution")
      — four forms cparma ures vres, cparmb uvres,
      cspace maxlength, and curv maxdist maxangle. Captured into
      the same free-form directive sequence.
    • shadow_obj filename (spec §"shadow_obj filename") — top-level
      last-wins shadow-caster companion file ("Only one shadow object
      can be stored in a file. If more than one shadow object is
      specified, the last one specified will be used."). Surfaces as
      a plain string on Scene3D::extras["obj:shadow_obj"].
    • trace_obj filename (spec §"trace_obj filename") — top-level
      last-wins ray-tracing reflection-target companion file (same
      last-wins semantics as shadow_obj). Surfaces as a plain string
      on Scene3D::extras["obj:trace_obj"].

    The encoder replays the captured ctech / stech lines verbatim
    after the polygonal section (next to the existing cstype / deg
    / parm / end block they accompany), and writes the surviving
    shadow_obj / trace_obj lines in the file preamble right after
    the mtllib references (matching the placement in the worked
    examples under spec §"Examples", cases 2 and 3). Empty filenames
    on either companion directive are dropped at parse time, mirroring
    the lenient-loader behaviour already applied to mg / s / g.
    Captured-then-replayed only — no semantic interpretation of the
    technique parameters, so the tessellator's existing
    with_curve_tessellation(samples) knob continues to control sample
    counts independently. New test suite approximation_and_companions
    covers all three ctech forms, all four stech forms,
    shadow_obj / trace_obj round-trip and placement, multi-line
    last-wins collapse, empty-filename rejection, and a directive
    declared outside any cstype block.

  • Round 218: multi-patch Bezier surf surface decomposition. Under
    ObjDecoder::with_curve_tessellation(samples: u32), every
    cstype bezier / cstype rat bezier surf whose control mesh
    spans more than one Bezier patch per parametric direction is now
    decomposed into per-patch tensor-product de Casteljau evaluations
    on the active (degu + 1) × (degv + 1) sub-window of a shared-
    boundary global control mesh (spec §"Bezier" K/n + 1 = parm_count
    inverse formula plus §"Surface vertex data — Control points":
    "the control points are ordered as if the surface were a single
    large patch"). Each global lattice sample maps to
    (u_global, v_global) ∈ [0, patches_u] × [0, patches_v]; the
    integer part selects the active patch, the fractional part is the
    local t ∈ [0, 1] Bezier parameter. The rational form blends the
    per-vertex w weights through the sub-window and projects via the
    weighted denominator. Single-patch surfaces (the common case)
    still route through the legacy single-de-Casteljau path. Synthetic
    primitives gain a new obj:surface_patches = [patches_u, patches_v]
    provenance extra when either count exceeds 1; single-patch
    surfaces omit the marker. Multi-patch grids whose control count
    doesn't satisfy the spec equality K = degu × patches_u per
    direction stay captured-only.

  • Round 212: MTL illumination model property decomposition. For every
    in-spec illum directive (models 0–10 per Wavefront MTL spec
    §"illum illum_#" summary table and the §"Illumination models" p.5-30
    long-form equations), the parser now surfaces the spec's "Properties
    that are turned on in the Property Editor" table alongside the raw
    integer, so consumers can branch on shading flags without
    re-deriving the table. The decomposition lands at
    Material::extras["mtl:illum_props"] as a JSON object with nine
    stable boolean keys: color (true for models 0–9, spec §"Color
    on …" entries), ambient (true for 1–9, spec §"Ambient on"),
    highlight (true for 2–9, spec §"Highlight on" carried through
    models 3+ which inherit Blinn-Phong via the §"Illumination models"
    formulas), reflection (true for 3–9, spec §"Reflection on"),
    ray_trace (true for 3–7; spec §"Ray trace off" explicitly turns
    it off for 8 and 9), transparency_glass (true for 4 and 9, spec
    §"Transparency: Glass on"), transparency_refraction (true for 6
    and 7, spec §"Transparency: Refraction on"), fresnel (true for 5
    and 7, spec §"Reflection: Fresnel on"; explicitly false for 6 per
    spec §"Reflection: Fresnel off"), and casts_shadow_on_invisible
    (true only for model 10 per spec §"Casts shadows onto invisible
    surfaces"). Out-of-spec illum integers (negative or > 10) still
    land in mtl:illum so the round-trip stays lossless, but
    mtl:illum_props is omitted (no spec row to mirror). The
    decomposition is parse-time-only — the encoder filters
    mtl:illum_props from the directive emit pass so re-serialising a
    parsed material still produces exactly one illum N line, matching
    the original byte sequence.

  • Round 206: special-curve (scrv) tessellation. Under
    ObjDecoder::with_curve_tessellation(samples: u32), every scrv
    directive inside a cstype … end block (spec §"Special curve",
    §"scrv u0 u1 curv2d u0 u1 curv2d …") is now resolved into a
    parameter-space Topology::LineStrip polyline on a new synthetic
    mesh named "obj:scrvs". A scrv carries the same
    (u0, u1, curv2d) triple shape that trim / hole use, but
    unlike those it is not a closed loop — the spec describes it
    as a "sequence of curves which lie on a given surface to build a
    single special curve" that must appear as a sequence of triangle
    edges in the surface's final triangulation. Until surface-aware
    triangulation honours that constraint, the special curve is
    emitted as a stand-alone parameter-space polyline so consumers
    can resolve it without re-walking the directive stream. Curv2d
    references are 1-based global per spec ("This curve must have
    been previously defined with the curv2 statement"), resolved
    against the same collect_all_curv2_polylines pre-pass the
    round-201 trim/hole clipper uses so a scrv declared in one
    block can still reference a curv2 first defined in any earlier
    block. The shared append_curv2_segment helper concatenates each
    (u0, u1) slice in source order (with the first vertex of each
    segment-after-the-first dropped to avoid duplicate-at-join);
    segments whose referenced curv2 failed to tessellate
    (incomplete block state, missing knot vector, malformed vp
    index, etc.) are silently dropped, and the surrounding scrv
    still produces a partial polyline if at least two vertices
    survive. Per-scrv primitives carry the shared
    obj:tessellated_curve sentinel (so the encoder's existing
    filter skips them), plus an obj:scrv = true marker, an
    obj:scrv_segments count, and an obj:scrv_curv2_refs array of
    [curv2d_index, u0, u1] provenance triples in source order. The
    free-form directive sequence still rides on
    Scene3D::extras["obj:freeform_directives"] so a decode → encode
    cycle replays the original cstype / surf / scrv / end
    block verbatim — the encoder filters the synthetic polyline out
    via the shared obj:tessellated_curve sentinel exactly like the
    un-clipped surface and curv2 paths.

  • Round 201: surface trim/hole clipping. Under
    ObjDecoder::with_curve_tessellation(samples: u32), every surf
    block whose enclosing cstype … end now also carries one or more
    trim u0 u1 curv2d … (outer) and/or hole u0 u1 curv2d … (inner)
    loops (spec §"Trimming Loops") has its tessellated triangle grid
    clipped against those loops. The clip works in parameter (u, v)
    space: every curv2 referenced by a trim / hole is resolved
    via a one-pass walk over freeform_directives (1-based global
    ordering per spec §"trim u0 u1 curv2d" — "This curve must have
    been previously defined") into the same Bezier / B-spline /
    Cardinal / Taylor / basis-matrix polyline the stand-alone
    round-188 curv2 path produces, with the requested [u0, u1]
    sub-range mapped linearly into the curve's own evaluation window.
    Per-loop segments concatenate into a closed polygon. Each
    surface-lattice vertex's (u, v) is point-in-polygon-tested via
    the standard Jordan-curve ray cast: a triangle survives iff all
    three vertices lie inside at least one trim polygon (or there are
    no trim loops, in which case the surface's full parameter
    rectangle is taken as the implicit outer loop per spec — "If no
    trim or hole statements are specified, then the surface is trimmed
    at its parameter range") AND outside every hole polygon. This is
    a conservative clip — boundary cells whose corners straddle a loop
    edge are dropped rather than re-meshed sub-cell, so the trim edge
    stays jagged at the lattice grain. Provenance lands on the
    synthetic primitive's extras: obj:surface_trimmed = true,
    obj:surface_trim_loops (count), and obj:surface_hole_loops
    (count). The free-form directive sequence still rides on
    Scene3D::extras["obj:freeform_directives"] so a decode → encode
    cycle replays the original cstype / surf / trim / hole /
    end block verbatim; the encoder filters the synthetic clipped
    surface out via the shared obj:tessellated_curve sentinel
    exactly like the un-clipped surface path.