oxideav-obj
Pure-Rust Wavefront OBJ + MTL 3D mesh codec. Implements the
oxideav_mesh3d::Mesh3DDecoder and Mesh3DEncoder traits, plugging into
the wider OxideAV codec ecosystem.
OBJ is the universal mesh-interchange format published by Wavefront Technologies in the early 1990s as Appendix B of the Advanced Visualizer manual. This crate implements the polygonal subset (the part that modern loaders actually load):
v/vt/vnvertex data — with the optionalw4th component on positions (rational weight per spec §"v x y z w" — preserved verbatim throughPrimitive::extras["obj:vertex_weight"]) and the optionalv/wextra components on UVs. The widely-deployed MeshLab / libigl / Meshroom / OpenCVv x y z r g bper-vertex-colour extension is accepted at parse time, surfaced throughPrimitive::colors[0](alpha pinned to 1.0 since the extension only spells out three channels), and re-emitted at the original token width —xyz,xyzw,xyzrgb, orxyzwrgb— using thePrimitive::extras["obj:vertex_color_present"]bitmap so partial- colouring inputs preserve their per-vertex partition on round-trip rather than fabricating synthetic white. 5-floatvlines are rejected as ambiguous.ffaces in all four index syntaxes (v,v/vt,v//vn,v/vt/vn), with 1-based indexing and the negative-index relative-from-end shorthand. Polygons (n-gons) are fan-triangulated on read; the original per-face arity is stashed inMesh::extras["obj:original_face_arities"]so the encoder can re-emit n-gons rather than triangles.lline elements →Topology::LineStripfor a singlelelement with three or more distinct vertices,Topology::LineLoopwhen the polyline closes (last vertex equals the first; redundant closing index dropped), orTopology::Linesfor multi-lprimitives and 2-vertex segments. The encoder picks the matching emit shape:LineStripwrites the natural index list,LineLoopre-appends the first index to spell out the closing edge, andLinesrejoins contiguous segment pairs into one polyline rather than emitting onel v1 v2per pair.ppoint elements →Topology::Points. Multi-vertexp v1 v2 v3 …lines pack onto one element list; mixing point and face/line elements under oneusemtlsplits into one primitive per topology.mg <group_number> [res]merging-group state-setting → preserved verbatim inPrimitive::extras["obj:merging_group"]; a change mid-stream splits the primitive (mirrorssbehaviour).bevel on/off,c_interp on/off,d_interp on/off, andlod <level>display attributes → captured per-primitive inPrimitive::extras["obj:bevel"]/["obj:c_interp"]/["obj:d_interp"]/["obj:lod"]. Mid-stream changes split the primitive so each one carries one consistent assignment per attribute.o <name>→ oneMeshper object directive (or a single mesh if the file has noo).g name1 name2 …→ multiple group names per line, captured inPrimitive::extras["obj:groups"]and re-emitted on a singlegline.s 1/s off/s 0smoothing groups → preserved verbatim inPrimitive::extras["obj:smoothing_group"]; a smoothing-group change mid-object splits the primitive so each one carries a single consistent assignment.mtllib <file.mtl> [<file2.mtl> …]andusemtl <name>— eachusemtlswitch starts a freshPrimitiveso a multi-material OBJ becomes aMeshwith N primitives, each with its ownMaterialId.maplib <lib1.map> [<lib2.map> …]andusemap <name>/usemap off— the texture-map sibling ofmtllib/usemtlper spec §"maplib" / §"usemap". Library names ride onScene3D::extras["obj:maplibs"]; the per-primitive binding lands inPrimitive::extras["obj:usemap"]. A mid-streamusemapswitch opens a fresh primitive (same state-setter shape asusemtl/s/mg); ausemtlswitch inherits the activeusemapbinding (the two operate independently per spec).- Free-form geometry (
vpparameter-space vertices,cstype,deg,curv,curv2,surf,parm,trim,hole,scrv,sp,end, plus the supersededbzp/bsppatches and the supersededcdcCardinal-curve /cdpCardinal-patch /ressegment-count statements) — captured verbatim intoScene3D::extras["obj:vp"](1-based parallel vertex pool) andScene3D::extras["obj:freeform_directives"](sequence of[keyword, arg1, arg2, …]arrays). The encoder replays both after the polygonal section so a decode → encode round-trip preserves the directive order and arguments. Verbatim by default; opt-in tessellation ofcurv3D space curves,curv22D parameter-space trimming curves, and Bezier / B-spline / Cardinal / Taylor / basis-matrixsurfsurfaces is available viaObjDecoder::with_curve_tessellation(samples)(see the per-round notes below).
The companion MTL parser/serialiser handles:
- Phong colours (
Ka/Kd/Ks/Ke) → glTFbase_color(fromKd) +emissive_factor(fromKe);Ka/Ksand theNsexponent are stashed inMaterial::extrasfor round-trip. Each ofKa/Kd/Ksaccepts the three mutually-exclusive forms per spec — plain RGB (r g b, with g/b defaulting to r),spectral file.rfl [factor](factor defaults to 1.0), andxyz x [y z]CIEXYZ (y/z defaulting to x). The spectral and xyz variants ride on sibling extras keys (mtl:K{a,d,s}:spectral/mtl:K{a,d,s}:xyz) so a re-emit reproduces the operator's chosen form; theKd spectral/Kd xyzvariants additionally suppress the canonicalKd r g bemit driven bybase_color. - Transparency (
ddissolve /Tr = 1 - d) →AlphaMode::Blendbase_color.a. Thed -halo factororientation-dependent variant is detected and re-emitted viaMaterial::extras["mtl:d_halo_factor"].
- Index of refraction (
Ni) and illumination model (illum) → extras. The rawilluminteger lands inMaterial::extras["mtl:illum"]unchanged; for in-spec models 0–10, the parser additionally surfaces the spec's per-model "Properties that are turned on in the Property Editor" summary table (Wavefront MTL spec §"illum illum_#") as a decomposed object inMaterial::extras["mtl:illum_props"]with the nine stable flag keyscolor/ambient/highlight/reflection/ray_trace/transparency_glass/transparency_refraction/fresnel/casts_shadow_on_invisible. Out-of-spec integers (negative or> 10) keep the raw integer but omitmtl:illum_props. The decomposition is parse-time-only; the encoder still emits exactly oneillum Nline per material. - Transmission filter — three mutually-exclusive forms per spec:
Tf r g b(withg/bdefaulting tor),Tf spectral file.rfl factor→Material::extras["mtl:Tf:spectral"], andTf xyz x y z→Material::extras["mtl:Tf:xyz"]. - Reflection sharpness (
sharpness) and displacement / decal / reflection maps (disp↔map_disp,decal↔map_decal,refl↔map_refl) round-trip via extras. - Typed reflection maps —
refl -type sphere fileand the six-facerefl -type cube_top|cube_bottom|cube_front|cube_back|cube_left|cube_rightcubemap. Sphere lands asMaterial::extras["mtl:refl:sphere"]; the six face lines bundle intoMaterial::extras["mtl:refl:cube"](one entry per face) so they don't collapse onto each other under last-write-wins; the encoder re-emits onerefl -type <kind> ... fileline per face / sphere in deterministic order. - Texture references (
map_Kd→base_color_texture,map_Bump→normal_texture,map_detc.) emitted asImageData::External { uri, mime: None }— the caller resolves paths against the OBJ file's directory. Leading-flag valueoption chunks (-blendu,-bm,-mm,-clamp,-imfchan,-o,-s,-t,-texres) are parsed out of the filename and surfaced viaMaterial::extras["mtl:<map_name>:options"]; the encoder splices them back inline. A parallel typed view rides onMaterial::extras["mtl:<map_name>:options_typed"]with stable primitive-valued keys per flag (boolfor theon/offflags,f64forbm/boost/texres,[base, gain]formm,[u, v, w]foro/s/t,Stringforimfchanandtype) so consumers can read each option without re-parsing the raw token array. The typed key is parse-time-only; encoder output is still driven by the raw:optionsarray. - Wavefront-PBR extension (
Prroughness,Pmmetallic,Pcclearcoat,Pssheen,map_Pr/map_Pm) →Material::roughness/Material::metallic/metallic_roughness_texture. map_aat onper-material texture anti-aliasing toggle (spec §"map_aat on") → booleanMaterial::extras["mtl:map_aat"], round-tripped as the exacton/offtoken.
Both decoders are registered against Mesh3DRegistry under the
default-on registry cargo feature; drop the feature for a free-standing
build that only exposes ObjDecoder / ObjEncoder and the
oxideav_mesh3d standalone trait surface.
For path-based loading, obj::parse_obj_from_path resolves
mtllib foo.mtl references against the OBJ file's parent directory
(handles multiple MTL files per line). For round-trip mirroring of
inputs that used negative-from-end indices, the encoder accepts
ObjEncoder::new().with_negative_indices(true) (or the same flag on
obj::SerializeOptions).
Sourcing
The Wavefront spec is mirrored in the OxideAV docs repository:
docs/3d/obj/wavefront-obj-spec.txt— Martin Reddy plain-text Appendix B1 mirror.docs/3d/obj/wavefront-mtl-spec.html— Paul Bourke MTL mirror (carries the originalCopyright 1995 Alias|Wavefront, Inc.notice).docs/3d/obj/paulbourke-obj-reference.html— Paul Bourke OBJ cross-check mirror.
This crate was implemented strictly from those references — no existing OBJ loader (tinyobj, assimp, blender io_scene_obj, three.js OBJLoader) was consulted.
Status
Round 1: polygonal subset + MTL Phong + Wavefront-PBR extension.
Round 2: multi-name g lines, smoothing-group state-setting (split-on-
change), Tf / sharpness / displacement-map round-trip, path-based
loader with mtllib resolution, and an opt-in negative-index encoder.
Round 3: p point elements, mg merging groups, bevel / c_interp
/ d_interp / lod display attributes, MTL map_* option flags
(-blendu, -clamp, -bm, …) preserved through round-trip, MTL
d -halo factor, encoder polyline rejoin.
Round 4: free-form geometry (vp parameter-space vertex pool plus
the verbatim cstype / deg / curv / curv2 / surf / parm /
trim / hole / scrv / sp / end / bzp / bsp directive
sequence) — round-trips through Scene3D::extras without
tessellation.
Round 5: MTL Tf spectral / Tf xyz alternative transmission-filter
forms, refl -type sphere / refl -type cube_* typed reflection-map
sets bundled into mtl:refl:sphere / mtl:refl:cube extras, and
single-l polylines promoted to Topology::LineStrip /
Topology::LineLoop (with closure detection at decode time).
Round 6: per-vertex colour extension (v x y z r g b, MeshLab /
libigl / Meshroom de-facto) accepted on parse, populated on
Primitive::colors[0], and re-emitted at the source's original
3-/4-/6-/7-token width via the obj:vertex_color_present bitmap.
The v 4th w weight component is now preserved through
Primitive::extras["obj:vertex_weight"] rather than silently dropped.
Round 7: opt-in Bezier curve tessellation —
ObjDecoder::with_curve_tessellation(samples) evaluates every
cstype bezier (and cstype rat bezier) curv directive via de
Casteljau's algorithm and emits a real Topology::LineStrip
primitive on a synthetic "obj:curves" mesh; the rational form
uses the per-vertex 4th w weight and projects back to 3D. Each
tessellated primitive carries provenance extras
(obj:tessellated_curve, obj:curve_kind, obj:curve_degree,
obj:curve_u_range, obj:curve_samples). The free-form directive
sequence still rides on Scene3D::extras["obj:freeform_directives"]
so re-encoding regenerates the original cstype / curv / end
section unchanged; the encoder filters synthetic curve primitives
out of the polygonal output. Free-form-section position pool now
rides on Scene3D::extras["obj:positions"] (plus parallel
obj:position_weights / obj:position_colors) so curv /
surf absolute-index references stay valid across a
decode → encode → decode cycle.
Round 8: B-spline / NURBS curve tessellation — the same
with_curve_tessellation(samples) knob now also evaluates
cstype bspline and cstype rat bspline curv directives via the
Cox-deBoor recursive basis-function formula (spec §"B-spline"),
clipped against the [x_n, x_{K+1}] evaluation window of the knot
vector supplied by the most-recent parm u … body statement.
Rational form uses the per-vertex 4th w weight (NURBS form) and
projects the weighted blend back to 3D. The tessellator now does
two-pass per-cstype/end block traversal so the curv header
(which comes before the parm u … body statement per spec) can
still resolve its knot vector. Knot-vector length is validated
against the spec condition len == K + degree + 2 and curves with
incomplete data are skipped silently (the directive remains
captured for round-trip).
Round 9: Cardinal (Catmull-Rom) + Taylor polynomial curve
tessellation — with_curve_tessellation(samples) now also evaluates
cstype cardinal curv directives via the spec §"Cardinal"
conversion to Bezier control points (b0 = c1,
b1 = c1 + (c2 − c0) / 6, b2 = c2 − (c3 − c1) / 6, b3 = c2,
then cubic Bezier blend) on a sliding 4-point window, and cstype taylor curv directives via Horner's-rule polynomial evaluation
P(t) = Σ_{i=0..n} c_i · t^i per spec §"Taylor". Cardinal is cubic
only (non-cubic deg is rejected, matching the spec's "only
defined for the cubic case" requirement); Taylor honours the
[u_min, u_max] parameter clip directly on the curv line.
Synthetic primitives carry the same obj:tessellated_curve /
obj:curve_kind ("cardinal" / "taylor") / obj:curve_degree /
obj:curve_u_range / obj:curve_samples provenance and the
encoder filters them out so the source cstype / curv / end
block replays unchanged.
Round 10: basis-matrix curve tessellation — with_curve_tessellation(samples)
now also evaluates cstype bmatrix curv directives per spec
§"Basis matrix" using the user-supplied (n + 1) × (n + 1) basis
from bmat u (row-major, column index j varying fastest per
spec §"bmat u/v matrix") and the segment stride from
step <stepu> (spec §"step stepu stepv"). Each segment evaluates
P(t) = Σ_i Σ_j B[i][j] · t^j · p_{base + i} over the
control-point window c_{base+1} .. c_{base+n+1} (1-based, base = i·stepu).
The bmat and step keywords are now tracked alongside the other
free-form directives, so they round-trip verbatim through
Scene3D::extras["obj:freeform_directives"] and the encoder
replays the original cstype bmatrix block unchanged. Cubic
Bezier expressed as cstype bmatrix matches the closed-form
Bernstein evaluation; the Hermite spec example interpolates its
endpoints.
Round 11: Bezier surf surface tessellation — the same
with_curve_tessellation(samples) knob now also evaluates surf
elements under a cstype bezier (or cstype rat bezier) header
into a real Topology::Triangles grid on a synthetic mesh named
"obj:surfaces", via the bivariate tensor-product de Casteljau
algorithm (spec §"Rational and non-rational curves and surfaces",
§"Bezier"). Control points are read in the spec's row-major
u-fastest order (§"Surface vertex data — control points": "i = 0
to K1 for j = 0, …"); the surf line's v/vt/vn references are
parsed for their leading position index (negative relative-from-end
indices honoured). A single patch of declared degree deg degu degv
needs exactly (degu + 1) × (degv + 1) control points; mismatched
counts (multi-patch grids, which Bezier can't decompose without a
step stride) are left captured-only. The surface is sampled at a
(samples + 1) × (samples + 1) lattice and triangulated CCW
(front = u-right, v-up per the spec note). Each synthetic primitive
carries provenance extras (obj:tessellated_surface,
obj:surface_kind, obj:surface_degree, obj:surface_u_range,
obj:surface_v_range, obj:surface_samples) plus the shared
obj:tessellated_curve sentinel so the encoder filters it out and
replays the original cstype / deg / surf / parm / end
block unchanged from Scene3D::extras["obj:freeform_directives"].
The rational form uses each control point's 4th w weight and
projects the weighted blend back to 3D.
Round 12: B-spline / NURBS surf surface tessellation — the same
with_curve_tessellation(samples) knob now also evaluates surf
elements under a cstype bspline (or cstype rat bspline) header
into a Topology::Triangles grid on the synthetic "obj:surfaces"
mesh, via the bivariate tensor-product Cox-deBoor formula
S(u, v) = Σ_i Σ_j N_{i,nu}(u) · N_{j,nv}(v) · d_{i,j} (spec
§"B-spline"). The per-direction control-grid size comes from the
parm u / parm v knot vectors ((len(parm u) − degu − 1) × (len(parm v) − degv − 1), spec §"B-spline" condition 6 applied
independently in u and v); the surf range is clipped against each
direction's [x_n, x_{K+1}] evaluation window. The rational (NURBS)
form blends the per-vertex w weights and projects via the weighted
denominator. Reuses the round-8 bspline_basis Cox-deBoor routine,
so a clamped quadratic patch matches the equivalent Bezier patch
sample-for-sample. Synthetic primitives carry the same
obj:tessellated_surface / obj:surface_kind
("bspline" / "rat_bspline") / obj:surface_degree /
obj:surface_u_range / obj:surface_v_range / obj:surface_samples
provenance and the encoder filters them out, replaying the original
cstype / deg / surf / parm u / parm v / end block
unchanged.
Round 13: Cardinal (Catmull-Rom) surf surface tessellation — the
same with_curve_tessellation(samples) knob now also evaluates surf
elements under a cstype cardinal (or cstype rat cardinal) header
into a Topology::Triangles grid on the synthetic "obj:surfaces"
mesh, via the bivariate tensor-product Cardinal evaluation
S(u, v) = Σ_i Σ_j C_i(u) · C_j(v) · d_{i,j} (spec §"Cardinal").
Each parametric direction reuses the spec's Cardinal→Bezier per-
segment conversion (b0 = c1, b1 = c1 + (c2 − c0) / 6,
b2 = c2 − (c3 − c1) / 6, b3 = c2) on a sliding 4-point window: the
u pass collapses every v-row, then a v pass runs over the collapsed
points. Cardinal is cubic-only per spec, so non-3 3 degrees stay
captured-only; the control grid comes from the parm u / parm v
extents (K = parm_count + 1 per direction) or, when parm carries
only the 2-value range, the square single patch (√total). A single
bicubic patch's parametric corners interpolate the interior 2×2
control block exactly (spec: "all but the first and last row and
column of control points are interpolated"). The rat cardinal form
routes to the same evaluator (unit-weight default). Synthetic
primitives carry the same obj:tessellated_surface / obj:surface_kind
("cardinal") / obj:surface_degree / obj:surface_u_range /
obj:surface_v_range / obj:surface_samples provenance and the
encoder filters them out, replaying the original
cstype / deg / surf / parm / end block unchanged.
Round 14: Taylor polynomial surf surface tessellation — the same
with_curve_tessellation(samples) knob now also evaluates surf
elements under a cstype taylor (or cstype rat taylor) header into
a Topology::Triangles grid on the synthetic "obj:surfaces" mesh,
via the bivariate tensor-product Horner-rule polynomial evaluation
S(u, v) = Σ_i Σ_j c_{i,j} · u^i · v^j (spec §"Taylor"). Control
points are the polynomial coefficients laid out row-major u-fastest
(spec §"Surface vertex data — control points"); a single Taylor
patch of declared degree deg degu degv needs exactly
(degu + 1) × (degv + 1) coefficient vectors. The surf s0 s1 t0 t1
range supplies the global parameter clip; Taylor surfaces evaluate
against the raw parameter values directly (not a normalised [0, 1]
re-parameterisation). The spec note in §"Free-form curve/surface
body statements" says the rational form "does not make sense for
Taylor", so rat taylor routes to the same evaluator without per-
vertex weight blending. Synthetic primitives carry the same
obj:tessellated_surface / obj:surface_kind ("taylor") /
obj:surface_degree / obj:surface_u_range / obj:surface_v_range
/ obj:surface_samples provenance and the encoder filters them out,
replaying the original cstype / deg / surf / parm / end
block unchanged.
Round 14 (depth): cargo fuzz harness — fuzz/fuzz_targets/parse_obj.rs
and fuzz/fuzz_targets/parse_mtl.rs drive attacker-controlled bytes
through every public decoder entry point and assert panic-freedom (no
panic / abort / debug-overflow / out-of-bounds index for any input).
The first 180-second parse_obj run found two real crashes that are
now fixed and pinned by regression tests in tests/fuzz_regressions.rs:
parse_face_vertexadmitted an empty leading slot (sof /1 /2andp /13andl /1 /2producedfv.v == 0which then panicked on(fv.v - 1) as usizeunderflow downstream). Fix: require a non-empty position component at parse time so thefv.v >= 1invariant holds end-to-end.tessellate_surfacesallocatedVec::with_capacity(cols * rows)for the Bezier control-grid pool without bounding the product against the declared control-vertex count, sodeg 111111blew past available memory. Fix:checked_add/checked_mulon the grid extents and an early "expected != entry control-count" bail so the allocation never runs for mismatched grids. Same defence applied to thecstype bmatrix(n + 1) × (n + 1)basis-size check. Subsequent 180-second runs againstparse_obj(corpus grew to ~8.8k discovered inputs) andparse_mtl(corpus grew to ~1.1k) finished without further crashes / OOM / timeouts. The fuzz subcrate'sCargo.lockis tracked for reproducible builds; transientfuzz/target/,fuzz/corpus/, andfuzz/artifacts/paths sit on.gitignore. Round 182: basis-matrixsurfsurface tessellation — the samewith_curve_tessellation(samples)knob now also evaluatessurfelements under acstype bmatrix(orcstype rat bmatrix) header into aTopology::Trianglesgrid on the synthetic"obj:surfaces"mesh, via the bivariate tensor-product polynomialS(u, v) = Σ_a Σ_b (Σ_p B_u[a][p] · u^p) (Σ_q B_v[b][q] · v^q) · c_{base_u + a, base_v + b}(spec §"Basis matrix", §"bmat u/v matrix", §"step stepu stepv"). Per-direction basis matrices come frombmat u/bmat v(row-major, column index varying fastest); per-direction segment strides come fromstep stepu stepv. The per-direction control-grid extent inverts the spec relationparm = (K − n) / s + 2toK = (parm − 2) · s + n + 1, applied independently in u and v ("For surfaces, the above description applies independently to each parametric direction."). Multi-patch grids decompose into per-segment patch windows starting at(seg_u · stepu, seg_v · stepv), so the spec §"Examples" cubic Bezier-as-bmatrix surface single-patch case matches the equivalentcstype bezierpatch sample-for-sample. Therat bmatrixqualifier routes to the same evaluator without per-vertex weight blending (matches the round-10 1D curve behaviour). Synthetic primitives carry the sameobj:tessellated_surface/obj:surface_kind("bmatrix") /obj:surface_degree/obj:surface_u_range/obj:surface_v_range/obj:surface_samplesprovenance and the encoder filters them out, replaying the originalcstype/deg/bmat u/bmat v/step/parm u/parm v/surf/endblock unchanged. Round 201: surface trim/hole clipping — the samewith_curve_tessellation(samples)knob now also applies thetrim u0 u1 curv2d …(outer) andhole u0 u1 curv2d …(inner) trimming loops declared inside asurfblock (spec §"Trimming Loops", §"trim", §"hole") to the tessellated surface mesh. Everycurv2referenced by an enclosingtrim/holeis resolved to its parameter-space(u, v)polyline (via the same Bezier / B-spline / Cardinal / Taylor / basis-matrix evaluator the round-188 stand-alonecurv2path uses), and the per-trim/ per-holesegments are concatenated into a closed polygon. Each(samples + 1)²lattice vertex of the surface is then point-in-polygon-tested via Jordan- curve ray casting: a triangle is kept iff all three vertices lie inside at least one trim loop (or there are no trim loops — spec: "If no trim or hole statements are specified, then the surface is trimmed at its parameter range") AND outside every hole loop. This is a conservative clip — boundary cells whose corners straddle a loop edge are dropped wholesale rather than sub-cell re-meshed, so the trim edge stays jagged at the lattice grain. The free-form directive sequence still rides onScene3D::extras["obj:freeform_directives"]so a decode → encode cycle replays the originalcstype/deg/surf/parm/trim/hole/endblock verbatim; the encoder filters the synthetic clipped surface out via the sharedobj:tessellated_curvesentinel. Per-clipped primitive, provenance lands onobj:surface_trimmed = true,obj:surface_trim_loops(count), andobj:surface_hole_loops(count). Curv2 references ontrim/holeare 1-based global (spec §"trim u0 u1 curv2d" — "This curve must have been previously defined with the curv2 statement"); a one-pass walk overfreeform_directivesresolves every curv2 polyline up-front so atrimdeclared in one block can reference acurv2first defined in any earlier block. Round 188: 2D trimming-curve (curv2) tessellation — the samewith_curve_tessellation(samples)knob now also evaluates everycurv2directive (the parameter-space curve referenced bytrim/hole/scrv, spec §"curv2") into aTopology::LineStrippolyline on a new synthetic mesh named"obj:curves2". Acurv2referencesvpparameter vertices (spec §"vp u v w") and lies in the 2D parameter space of the surface it trims, so eachvp (u, v)is lifted into a flat[u, v, 0.0]control point and run through the same Bezier / B-spline / Cardinal / Taylor / basis-matrix evaluators the 3Dcurvpath uses — the sampledx/yare the parameter-space coordinates,zstays0.0. Unlikecurv, acurv2line carries no inlineu0 u1; the B-spline evaluation window comes from the block'sparm uknot vector. The optional 3rdvpcoordinate is the rational weight (default1.0; thevp0.0padding default reads back as1.0for the rational forms). Negativecurv2indices resolve relative-from-end against thevppool (spec §"Special point" examplecurv2 -6 -5 …). Synthetic primitives carry the sharedobj:tessellated_curvesentinel plus aobj:curve2marker and theobj:curve_kind/obj:curve_degree/obj:curve_u_range/obj:curve_samplesprovenance; the encoder filters them out and replays the originalcstype/curv2/parm/endblock unchanged fromScene3D::extras["obj:freeform_directives"]. Round 206: special-curve (scrv) tessellation — the samewith_curve_tessellation(samples)knob now also evaluates everyscrvdirective (spec §"Special curve", §"scrv u0 u1 curv2d u0 u1 curv2d …") into a parameter-spaceTopology::LineStrippolyline on a new synthetic mesh named"obj:scrvs". Ascrvshares the(u0, u1, curv2d)triple shapetrim/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. This round emits the special curve as a stand-alone parameter-space polyline so consumers can resolve it without re-walking the directive stream; round 290 additionally embeds it as actual triangle edges on theobj:surfacesmesh (see below). Thecurv2dreferences 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. Segments whose referencedcurv2failed to tessellate (incomplete block state, missing knot vector, …) are silently dropped; the surroundingscrvstill produces a partial polyline if at least two vertices survive across the successfully-resolved segments. Per-scrvprimitives carry the sharedobj:tessellated_curvesentinel plus anobj:scrvmarker, anobj:scrv_segmentscount, and anobj:scrv_curv2_refsarray of[curv2d_index, u0, u1]provenance triples in source order; the encoder filters them out and replays the originalcstype/surf/scrv/endblock unchanged fromScene3D::extras["obj:freeform_directives"]. Round 218: multi-patch Beziersurfsurface decomposition — the samewith_curve_tessellation(samples)knob now also evaluatessurfelements under acstype bezier(orcstype rat bezier) header whose control mesh spans more than one Bezier patch per parametric direction. Spec §"Bezier" gives the per-direction control count asK = degu × (parm_u_count − 1)(inverting "the number of global parameter values given with the parm statement must be K/n + 1"), and spec §"Surface vertex data — Control points" lays the global control mesh out "as if the surface were a single large patch" with adjacent patches sharing their boundary row / column. Each lattice sample maps to a global parameter(u_g, v_g) ∈ [0, patches_u] × [0, patches_v]; its integer part selects the patch, fractional part is the local Bezier parametert ∈ [0, 1]for tensor-product de Casteljau on the active(degu + 1) × (degv + 1)sub-window. The single-patch case (parmlength 2 per direction, the common form) collapses to the legacy single-sample_bezier_surfacepath. The rational form blends the per-vertexwweights through the same sub-window and projects via the weighted denominator. Synthetic primitives gain a newobj:surface_patches = [patches_u, patches_v]provenance extra when either count exceeds 1, so downstream consumers can recognise the patch seams inside the otherwise- uniform triangle lattice; single-patch surfaces still omit the marker. Multi-patch grids whose control count doesn't satisfy the spec equalityK = degu × patches_ustay captured-only, matching the existing single-patch mismatch behaviour.
Round 223: approximation-technique directives + companion-object references — four previously-dropped spec-defined directives now round-trip.
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 …" — the three loop statements all share the identical
repeating-triple body shape. Parallel to the verbatim
obj:freeform_directives channel (which still carries every 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" / "scrv"; the element_kind is the directive
that opened the enclosing cstype … end block ("surf" for the
spec-legal host, "unknown" outside a surface block); the cstype
slug reuses the parm / ctech / stech disambiguation table
("bezier" / "rat_bezier" / "bspline" / "rat_bspline" /
"cardinal" / "taylor" / "bmatrix", or "unknown"). The
segments array decomposes every (u0, u1, curv2d) triple in source
order — u0 / u1 as f64, curv2d as i64 (negative-from-end
references per spec §"Examples" case 8 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 encoder's
source of truth. 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's
sub-form slug — one of "cparm" / "cspace" / "curv" for curves
and "cparma" / "cparmb" / "cspace" / "curv" for surfaces.
The parameters array is the parsed f64 resolution arguments in
source order, with the spec-defined arities cparm/cspace/cparmb
= 1 and curv/cparma = 2; a malformed parameter token (or wrong
argument arity, or an unrecognised technique slug) drops the line from
the typed view without failing the parse — the verbatim channel still
replays the line byte-faithful. The cstype slug reuses the existing
parm typed view's disambiguation table (one of "bezier" /
"rat_bezier" / "bspline" / "rat_bspline" / "cardinal" /
"taylor" / "bmatrix"); lines that sit outside any open
cstype … end block still surface in the typed view with
cstype = "unknown" so consumers can read the resolution parameters.
The encoder is still driven by the verbatim channel so the typed view
exists purely to spare consumers from re-parsing the per-technique
positional tokens.
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 the only two values the spec defines; the
element_kind ("curv" / "curv2" / "surf") is pinned by the
most recent curv / curv2 / surf directive inside the
surrounding cstype … end block; the cstype slug carries 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 so the typed view
exists purely to spare consumers from walking the directive
sequence to pair 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 (nocurv/curv2/surfseen 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-linevaluesarray — mirrors the lenient-on-malformed policy of the existingsp/contyped views.
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_*) land as i64;
parameter slots (q0_*, q1_*) land as f64. The encoder is still
driven by the verbatim channel so the typed view exists purely to
spare consumers a second pass over the eight positional tokens. 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; consumers that want negative-from-end semantics walk the
typed value through their own resolver.
Round 246: typed decomposition of the sp (special-point) body
statement per spec §"Special point", §"sp vp1 vp …". The verbatim
free-form-directives channel still carries every sp line for round-
trip, but 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 cstype … end block: curv keeps v = null (spec: "For
space curves and trimming curves, the parameter vertices must be 1D");
curv2 surfaces both u and v because the spec describes a
trimming-curve special point as "essentially the same as a special
point on the surface it trims"; surf carries both components (spec:
"For surfaces, the parameter vertices must be 2D"). A companion
synthetic Topology::Points primitive lands on a new "obj:sps" mesh
under the same with_curve_tessellation(samples) knob the other
free-form passes use, one primitive per sp directive, with positions
lifted as [u, v_or_0, 0.0] plus per-primitive provenance extras
(obj:special_point marker, obj:special_point_element_kind string,
obj:special_point_vp_refs array of resolved 1-based vp indices). The
synthetic primitives carry the shared obj:tessellated_curve sentinel
so the encoder's existing is_tessellated_curve filter drops them on
re-emit; the sp line itself replays unchanged from the
obj:freeform_directives array. Negative vp references resolve
relative-from-end per the surrounding free-form pattern; references
outside the live vp pool (including 0) drop silently from both the
typed view and the synthetic primitive without failing the parse, since
the verbatim directive replay handles round-trip independently. 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 on
Scene3D::extras["obj:maplibs"] as a verbatim string array (same
de-duplication policy as mtllib — a name that appears twice on the
same line or repeats on a later maplib line is suppressed). The
per-primitive binding from usemap <name> or usemap off lands on
Primitive::extras["obj:usemap"]. State-setter semantics mirror
usemtl: a mid-stream usemap switch opens a fresh primitive that
inherits the active groups, smoothing / merging group, display
attributes, and usemtl material. A usemtl switch likewise
inherits the active usemap binding (the two operate independently
per spec — one selects a material, the other a texture-map
definition). The encoder replays maplib after mtllib (one
line per unique name to keep diffs grep-friendly) and usemap after
usemtl (one line per primitive carrying the binding). Documents
that don't carry either directive produce neither extras key and
neither encode line.
Round 240: typed decomposition of map_* option flags per MTL spec
§"Options for texture map statements". Parallel to the existing raw
mtl:<map>:options string array (which still drives encoder round-
trip), a typed view lands on mtl:<map>:options_typed whose stable
lowercase keys carry primitive values: blendu / blendv / clamp
/ cc decode to bool (on → true, off → false); bm /
boost / texres decode to f64; mm decodes to a two-element
[base, gain] array; o / s / t decode to a three-element
[u, v, w] array; imfchan and type decode to a String over
their spec-defined alphabets (r | g | b | m | l | z and sphere | cube_top | … | cube_right). Each key appears only when the source
line carried the matching flag; values that don't match the spec's
expected shape (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 parse-time-only — the encoder filters :options_typed
out of its passthrough loop so it never appears in serialised MTL.
Nested options inside mtl:refl:sphere and per-face entries under
mtl:refl:cube[<face>] also gain a sibling options_typed field,
so per-face reflection metadata is uniformly structured.
Round 236: MTL Ka / Kd / Ks spectral and xyz alternative
forms — the three Phong-colour statements now accept the same triplet
of mutually-exclusive forms Tf already did (plain RGB, spectral file.rfl [factor], and xyz x [y z]), matching the spec listings at
§"Ka r g b", §"Kd r g b", §"Ks r g b". The spectral form lands on
Material::extras["mtl:K{a,d,s}:spectral"] as a {file, factor}
object (factor defaults to 1.0 per spec, and the encoder omits the
explicit 1.0 token in that case); the xyz form lands on
Material::extras["mtl:K{a,d,s}:xyz"] as an [x, y, z] array (y and
z default to x per spec). The plain RGB form additionally honours
the spec's single-value broadcast ("If only r is specified, then g,
and b are assumed to be equal to r") for all three statements — the
previous parser required exactly three floats. The encoder picks the
first present sibling key per material in precedence order
spectral → xyz → RGB; Kd spectral / Kd xyz additionally
suppress the canonical Kd r g b line driven by base_color so the
forms remain mutually exclusive on round-trip. Tf was refactored
to share the same parse_color_statement helper without changing
its observable behaviour.
Round 229: connectivity (con) + general-statement (call / csh)
round-trip — three more 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") is 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 alongside the other free-form geometry directives so
its source-order position relative to the surf blocks it references
is preserved. call filename.ext arg1 arg2 … (spec §"General
statement", §"call filename.ext arg1 arg2 …") is the inline include
of a sibling .obj / .mod file with positional argument substitution
and csh command / csh -command (spec §"General statement", §"csh
command") is the shell-execute directive (leading - flagging "ignore
error on non-zero exit"). Both general statements are captured-only
into a new Scene3D::extras["obj:general_directives"] side-channel
array of [keyword, arg1, …] entries in document order; the encoder
replays them at the top of the preamble right after the
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"). The parser deliberately does
NOT recursively resolve call (would require IO + nested-call depth
tracking outside the clean-room parser's scope) nor execute csh
(sandbox-escape trapdoor for any consumer that round-trips untrusted
OBJ input); consumers walk the captured directive sequence themselves. ctech technique resolution (spec §"ctech technique
resolution", three forms cparm res / cspace maxlength /
curv maxdist maxangle) and stech technique resolution (spec
§"stech technique resolution", four forms cparma ures vres /
cparmb uvres / cspace maxlength / curv maxdist maxangle) are
captured into the same Scene3D::extras["obj:freeform_directives"]
verbatim-replay channel as the existing free-form curve/surface
attributes; the encoder emits them inside the original cstype /
deg / parm / end block they were sourced from, preserving
source order. shadow_obj filename (spec §"shadow_obj filename") and
trace_obj filename (spec §"trace_obj filename") surface as plain
strings on Scene3D::extras["obj:shadow_obj"] /
Scene3D::extras["obj:trace_obj"] with per-spec last-wins collapse
("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.")
and re-emit in the preamble right after the mtllib block, matching
the placement in the spec §"Examples" cases 2 and 3. Empty filenames
on either companion directive are dropped at parse time. No semantic
interpretation of the technique parameters — the tessellator's
with_curve_tessellation(samples) knob still controls sample counts
independently.
Round 282: sub-cell trim/hole boundary re-meshing — the round-201
conservative clip dropped any lattice triangle whose three corners
didn't all classify inside the trimmed region, leaving the trim edge
jagged at the lattice grain. Straddling boundary triangles (1 or 2
corners kept) are now clipped against the in/out classification
function (inside ≥ 1 trim loop — or no trim loops, per spec
§"Trimming loops and holes" "If the first trim statement in the
sequence is omitted, the enclosing outer trimming loop is taken to be
the parameter range of the surface" — AND outside every hole loop)
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,
i.e. 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 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 emitted the special
curve only as a stand-alone parameter-space polyline on the
obj:scrvs mesh; the tessellated obj:surfaces triangle grid ignored
it, so the spec's triangle-edge guarantee was 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 (the 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 works 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 the host triangle's
corners (so the embedded curve is exactly as accurate as the
surrounding piecewise-linear facet — no new surface evaluation), and a
segment that already lies along 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 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": "Connectivity connects two surfaces along their trimming
curves. … This information is useful for edge merging." The round-251
pass surfaced the eight raw con arguments as the typed
Scene3D::extras["obj:connectivity"] view; this round draws the seam
itself as drawable geometry. With with_curve_tessellation(samples)
enabled, every con emits a pair of parameter-space
Topology::LineStrip seams — one per joined surface edge — on a new
synthetic mesh named "obj:cons". Each side's curv2d is resolved
through the same collect_all_curv2_polylines pre-pass the trim /
hole / scrv passes use ("This curve must have been previously
defined with the curv2 statement"), 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 fixes the
correspondence — the seam is S1(T1(t1)) for t1 ∈ [q0_1, q1_1] and
S2(T2(t2)) for t2 ∈ [q0_2, q1_2], "identical up to
reparameterization" with endpoints meeting exactly — so 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 and obj:con_peer_surf (the joined
surface's index and its mate's, so a consumer holding one seam finds the
other without re-parsing), obj:con_curv2d, and obj:con_q0 /
obj:con_q1. A con without exactly eight arguments is dropped from
the geometry view (like the typed 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 on side 1) drops on
its own while the other side still emits. The merging-group filter the
spec mentions ("Connectivity between surfaces in different merging
groups is ignored") is a renderer-side pruning decision over the mg
state and is left to the consumer. Verbatim round-trip is untouched —
the encoder filters the seams via the shared sentinel and replays the
original con line from Scene3D::extras["obj:freeform_directives"].
Round 302: MTL map_aat on per-material texture anti-aliasing toggle
— 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 without failing the parse). The serialiser emits the flag
explicitly because the string-only pass-through loop can't carry a
bool-valued extra.
Round 308: the three remaining superseded 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. The system will
convert the data to the new .obj format."), so the parser captures them
on input rather than silently dropping them. 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.
The .mod binary form remains out of scope. Round 282 upgraded the
round-201 conservative trim/hole clip to sub-cell boundary re-meshing,
so the trim edge now follows the loop polygon at bisection precision
rather than the lattice grain (residual approximation: the straight
chord across a loop corner inside its straddling cell); round 290
embeds the scrv special curve as triangle edges on the surface mesh;
round 295 draws the con connectivity seam as a parameter-space
polyline pair on the obj:cons mesh.
License
MIT. See LICENSE.