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), andres 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-handledbzp/bsppatches. 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. Becausecdc/cdpreference vertex
positions by index, theobj:positionsre-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;rescarries only the two segment counts and needs no position
pool. -
Round 302: MTL
map_aat onper-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 booleanMaterial::extras["mtl:map_aat"]and round-trips
the exacton/offtoken. The spec documents only theonform,
but the keyword is a boolean state-setter sooffis 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". Withwith_curve_tessellation(samples)enabled, every
constatement now emits a pair of parameter-space
Topology::LineStripseams — one per joined surface edge — on a new
synthetic mesh named"obj:cons". Where round 251 surfaced the eight
raw arguments as the typedScene3D::extras["obj:connectivity"]view,
this pass draws the seam itself ("This information is useful for edge
merging"): each side'scurv2dis resolved through the same
collect_all_curv2_polylinespre-pass thetrim/hole/scrv
passes use, and the[q0, q1]sub-range is walked with the shared
append_curv2_segmentso a connectivity seam is sampled identically to
a special-curve segment. The appendix correspondence (S1(T1(t1))for
t1 ∈ [q0_1, q1_1]joined toS2(T2(t2))fort2 ∈ [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 sharedobj:tessellated_curvesentinel, anobj: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. Aconwithout exactly eight arguments is
dropped from the geometry view; a side whose curve doesn't resolve
(non-positive / undefinedcurv2d, or a zero-length parameter range —
e.g. the spec example's2.0 2.0point-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 themgstate.
Verbatim round-trip is untouched — the encoder filters the seams via the
shared sentinel and replays the originalconline. +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-206scrvpass only emitted the
special curve as a stand-alone parameter-space polyline on the
obj:scrvsmesh; the tessellatedobj:surfacestriangle grid ignored
it, leaving the spec's triangle-edge guarantee unmet. Now everysurf
whose enclosingcstype … endblock carries ascrvdirective has the
special curve embedded into its triangulation. Thescrvis resolved
to a parameter-space polyline (same(u0, u1, curv2d)body grammar and
collect_all_curv2_polylinespre-passtrim/holeuse, 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 provenanceobj:surface_scrv(marker),
obj:surface_scrv_curves(count of special curves that overlapped the
meshed surface), andobj:surface_scrv_vertices(count of synthesised
constraint vertices). Verbatim round-trip is untouched — the synthetic
surface still carries the sharedobj:tessellated_curvesentinel so
the encoder filters it and replays the originalscrvblock 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
provenanceobj:surface_trim_boundary_verticescounts 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_curvesentinel and replays the originaltrim/
holeblock fromScene3D::extras["obj:freeform_directives"]. -
Round 273: typed decomposition of the
trim/hole/scrvloop
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 verbatimobj:freeform_directiveschannel
(which still carries everytrim/hole/scrvline 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 keysloop_kind/
element_kind/cstype/segments. Theloop_kindis exactly
"trim","hole", or"scrv"; theelement_kindis the directive
that opened the enclosingcstype … endblock ("surf"for the
spec-legal host,"unknown"when the loop is seen outside a surface
block); thecstypeslug carries the recognised type from the
enclosingcstypeheader ("bezier"/"rat_bezier"/"bspline"/
"rat_bspline"/"cardinal"/"taylor"/"bmatrix", or
"unknown"), reusing the same disambiguation table theparm/
ctech/stechtyped views use. Thesegmentsarray decomposes
every(u0, u1, curv2d)triple in source order —u0/u1land as
f64,curv2dasi64(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 existingsp/con/parmtyped views. -
Round 266: typed decomposition of the
ctech/stechapproximation-
technique directives per spec §"ctech technique resolution" + §"stech
technique resolution". Parallel to the verbatim
obj:freeform_directiveschannel (which still carries every
ctech/stechline for round-trip), a parse-time-only typed view
now lands onScene3D::extras["obj:approximations"]as an array of
objects with the four stable, lowercase, underscore-separated keys
element_kind/technique/parameters/cstype. The
element_kindis exactly"curve"for actechline and
"surface"for anstechline per spec ("specifies a curve
approximation technique" / "specifies a surface approximation
technique"). Thetechniqueis 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
parametersarray carries the parsedf64resolution arguments in
source order, with per-form aritiescparm/cspace/cparmb= 1
andcurv/cparma= 2; a parameter token that fails to parse drops
the whole line. Thecstypeslug mirrors the existingparmtyped
view'scstypeslot (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.rscover: per-form
decomposition for the threectechand fourstechsub-shapes;
cstypeslug pinning across two different blocks; lossy-on-
malformed policy (wrong arity, non-numeric parameter); unrecognised
technique slug rejection; outside-blockcstype = "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 verbatimobj:freeform_directives
channel (which still carries everyparmline 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 keysdirection/
element_kind/cstype/values. The direction is exactly"u"
or"v"per spec; the element kind ("curv"/"curv2"/"surf")
is decided by the most recentcurv/curv2/surfdirective
inside the currentcstype … endblock; the cstype slug is the
recognised type from the enclosingcstypeheader (one of
"bezier"/"rat_bezier"/"bspline"/"rat_bspline"/
"cardinal"/"taylor"/"bmatrix"), or"unknown"when the
declared type isn't one of those names.valuesis the parsed
array off64— 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 theparmkeyword. 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 everyparmline with its enclosingcstypeblock- element kind. Lines whose direction token isn't exactly
"u"/
"v", or that sit outside any element (nocurv/curv2/surf
seen since the lastcstype), 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
valuesarray — mirrors the lenient-on-malformed policy of the
existing sp / con typed views. Newtests/parm_typed.rssuite (10
tests) covering: two-direction surface block (u + v), Bezier curve
global-parameter single-direction line, curv2 insidecstype rat bezier(verifying the rat_bezier slug), round-trip stability of
the typed view, source-order preservation across multiple
cstype … endblocks, unknown-direction drop, parm-outside-element
drop, absent-key when the file has noparmlines, non-numeric
token drop within a line, and unknown-cstype slug for unrecognised
types.
- element kind. Lines whose direction token isn't exactly
-
Round 251: typed decomposition of the
conconnectivity 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
verbatimobj:freeform_directiveschannel (which still carries
everyconline for round-trip), a parse-time-only typed view now
lands onScene3D::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
asi64; parameter slots (q0_*,q1_*) surface asf64. 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 thesurf_*
/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 existingconnectivity_and_generalsuite (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 nocondirective is present). -
Round 246: typed decomposition of the
sp(special-point) body
statement per spec §"Special point", §"sp vp1 vp …". The verbatim
obj:freeform_directiveschannel still carries everyspline 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 keyselement_kind/vp_index_1based/u/v, in
source order. The element kind is decided by the directive that
opened the enclosingcstype…endblock (curv→v = null
because space-curve special points are 1D per spec;curv2→ both
uandv, 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::Pointsprimitive lands on a new"obj:sps"mesh under
the existingwith_curve_tessellation(samples)knob, one persp
directive, with per-primitive provenance extras
(obj:special_pointmarker,obj:special_point_element_kind,
obj:special_point_vp_refs). The sharedobj:tessellated_curve
sentinel keeps the encoder'sis_tessellated_curvefilter from
re-emitting the synthetic mesh; thespline itself replays
verbatim from the directive array. Negativevpreferences resolve
relative-from-end; references outside the livevppool (and0)
drop silently from both the typed view and the synthetic primitive
without failing the parse.splines outside any opencstype
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/usemapper 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 asmtllib;usemap <name>/
usemap offis captured per-primitive in
Primitive::extras["obj:usemap"]. The state-setter semantics
mirrorusemtl: a mid-stream change opens a fresh primitive that
inherits all the other active state (groups, smoothing/merging
group, display attributes, theusemapbinding whenusemtl
switches, theusemtlmaterial whenusemapswitches). The
encoder replaysmapliblines aftermtlliband ausemapline
per-primitive afterusemtl, 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 aserde_json::Value
object whose keys are decomposed primitive values:blendu/blendv
/clamp/cc(bool, derived from the spec'son/off
arguments),bm/boost/texres(f64),mm([base, gain]
f64pair),o/s/t([u, v, w]f64triple),imfchan
(Stringoverr | g | b | m | l | zper §"-imfchan"), andtype
(Stringover thesphere | cube_top | cube_bottom | cube_front | cube_back | cube_left | cube_rightalphabet 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_typedout of its passthrough
loop so it never appears in serialised MTL. Mirrors the
mtl:illum_propsdecomposition pattern from round 212. Nested
options insidemtl:refl:sphereandmtl:refl:cube[<face>]entries
also gain a siblingoptions_typedfield so per-face reflection
metadata is uniformly structured. The raw:optionsarray 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_typedwithout parsing strings. -
Round 236: MTL
Ka/Kd/Ksspectralandxyzalternative
forms. Spec §"Ka r g b" / §"Kd r g b" / §"Ks r g b" each list the
same three mutually-exclusive formsK* r g b/K* spectral file.rfl factor/K* xyz x y z(mirroring theTftriplet);
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 onMaterial::extras["mtl:K{a,d,s}:spectral"]as a
{file, factor}object (factor defaults to 1.0 per spec) and the
xyz form onMaterial::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:spectral→xyz→ RGB) so a decode → encode cycle
reproduces the operator-written form. ForKdspecifically, the
alt forms suppress the canonicalKd r g bemit driven by
base_color(since the forms are mutually exclusive per spec); the
underlyingbase_colorfield stays at its default so glTF
consumers still see a sensible neutral.Tfwas refactored to
share the newparse_color_statementhelper 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-declaredsurf
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
conafter the last referenced surface'send, 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/.modfile 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 themtllib
andshadow_obj/trace_objcompanion-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
(nocall/cshlines) leave the extras unchanged.Test coverage in the new
connectivity_and_generalsuite (12
tests): all eightconpositional arguments captured, source-order
preservation across the polygonal / free-form boundary,
negative-indexconsurvival,callwith and without positional
args,cshwith 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-keywordcshline 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 formscparm 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 formscparma ures vres,cparmb uvres,
cspace maxlength, andcurv 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 onScene3D::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 asshadow_obj). Surfaces as a plain string
onScene3D::extras["obj:trace_obj"].
The encoder replays the captured
ctech/stechlines verbatim
after the polygonal section (next to the existingcstype/deg
/parm/endblock they accompany), and writes the surviving
shadow_obj/trace_objlines in the file preamble right after
themtllibreferences (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 tomg/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 suiteapproximation_and_companions
covers all threectechforms, all fourstechforms,
shadow_obj/trace_objround-trip and placement, multi-line
last-wins collapse, empty-filename rejection, and a directive
declared outside anycstypeblock. -
Round 218: multi-patch Bezier
surfsurface decomposition. Under
ObjDecoder::with_curve_tessellation(samples: u32), every
cstype bezier/cstype rat beziersurfwhose 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
localt ∈ [0, 1]Bezier parameter. The rational form blends the
per-vertexwweights 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 newobj: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 equalityK = degu × patches_uper
direction stay captured-only. -
Round 212: MTL illumination model property decomposition. For every
in-specillumdirective (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"), andcasts_shadow_on_invisible
(true only for model 10 per spec §"Casts shadows onto invisible
surfaces"). Out-of-specillumintegers (negative or> 10) still
land inmtl:illumso the round-trip stays lossless, but
mtl:illum_propsis omitted (no spec row to mirror). The
decomposition is parse-time-only — the encoder filters
mtl:illum_propsfrom the directive emit pass so re-serialising a
parsed material still produces exactly oneillum Nline, matching
the original byte sequence. -
Round 206: special-curve (
scrv) tessellation. Under
ObjDecoder::with_curve_tessellation(samples: u32), everyscrv
directive inside acstype … endblock (spec §"Special curve",
§"scrv u0 u1 curv2d u0 u1 curv2d …") is now resolved into a
parameter-spaceTopology::LineStrippolyline on a new synthetic
mesh named"obj:scrvs". Ascrvcarries the same
(u0, u1, curv2d)triple shape thattrim/holeuse, 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 samecollect_all_curv2_polylinespre-pass the
round-201 trim/hole clipper uses so ascrvdeclared in one
block can still reference acurv2first defined in any earlier
block. The sharedappend_curv2_segmenthelper 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 referencedcurv2failed to tessellate
(incomplete block state, missing knot vector, malformedvp
index, etc.) are silently dropped, and the surroundingscrv
still produces a partial polyline if at least two vertices
survive. Per-scrvprimitives carry the shared
obj:tessellated_curvesentinel (so the encoder's existing
filter skips them), plus anobj:scrv = truemarker, an
obj:scrv_segmentscount, and anobj:scrv_curv2_refsarray 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 originalcstype/surf/scrv/end
block verbatim — the encoder filters the synthetic polyline out
via the sharedobj:tessellated_curvesentinel exactly like the
un-clipped surface and curv2 paths. -
Round 201: surface trim/hole clipping. Under
ObjDecoder::with_curve_tessellation(samples: u32), everysurf
block whose enclosingcstype … endnow also carries one or more
trim u0 u1 curv2d …(outer) and/orhole 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: everycurv2referenced by atrim/holeis resolved
via a one-pass walk overfreeform_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-188curv2path 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), andobj:surface_hole_loops
(count). The free-form directive sequence still rides on
Scene3D::extras["obj:freeform_directives"]so a decode → encode
cycle replays the originalcstype/surf/trim/hole/
endblock verbatim; the encoder filters the synthetic clipped
surface out via the sharedobj:tessellated_curvesentinel
exactly like the un-clipped surface path.