Mesh authoring

Aether's native authoring representation is a small s-expression DSL, not a triangle-editing model. aether-mesh parses the text into a typed tree, evaluates primitives and transforms into fixed-point polygons, cleans the polygon stream, and tessellates only where a triangle consumer needs it. The aether.kit.mesh guest actor is the file-backed viewer: it reads a .dsl or minimal .obj, atomically replaces its cached filled DrawTriangles and DSL outline loops on success, and renders them on every Render stage.

Decision map

ADRStatusWhat still governs
ADR-0026Acceptedprimitive-composition, palette indices, s-expression data
ADR-0051Acceptedstructural syntax plus torus and sweep
ADR-0052Acceptededit text and reload; no retained vertex/face editor state
ADR-0053Acceptedlibrary-only mesher boundary
ADR-0054Superseded by ADR-0062historical BSP/CSG rationale only
ADR-0055Superseded by ADR-0062historical origin; reusable cleanup code was retained
ADR-0056Acceptedconstrained Delaunay tessellation for non-convex faces and holes
ADR-0057Acceptedn-gon polygons, not triangles, are the canonical face form
ADR-0062Acceptedno union, intersection, or difference in v1

ADR-0026 said conventional imports would not be supported. Current code is narrower in ambition but broader in practice: MeshViewer accepts a minimal Wavefront OBJ subset in addition to the native DSL. Treat that as a viewer compatibility path, not a second structural authoring model; OBJ becomes a flat triangle cache and cannot round-trip through the DSL AST.

Mental model

Keep the layers distinct:

  1. Text data is parsed by aether_mesh::parse into Node. It is Lisp-like syntax but never evaluated as code.
  2. The AST retains primitives, transforms, structural repetition, and a palette index per primitive. serialize emits a canonical s-expression; original whitespace and comments are not retained.
  3. Canonical geometry is Polygon: a fixed-point CCW outer loop, zero or more CW hole loops, a face-normal hint, and one u32 color index.
  4. Wire/render geometry is triangles. tessellate_polygon converts a canonical face at the upload boundary; mesh offers a direct triangle convenience path.
  5. The viewer actor maps color indices through its eight-entry RGB palette and caches filled DrawTriangles separately from DSL outer and hole loops. It resends the faces directly and rebuilds the loops as eye-facing slate stroke ribbons for the active camera on every Render.

The library has no renderer or mailbox dependency. The viewer is guest code in the multi-actor aether-kit wasm module. aether-kit exports several types, so a load that names no export is refused with the module's export list (ADR-0241 §9); the mesh actor must be selected as aether_kit@aether.kit.mesh. Export membership is in aether-kit/src/lib.rs. The viewer's config names the camera whose eye its outlines face ({"camera": "aether.kit.camera:main"}, which is also the default): load aether_kit@aether.kit.camera under that key first, and send aether.render.view_from naming it so the renderer draws through the same camera. A viewer loaded before its camera is refused at load, naming the path.

DSL vocabulary

Every primitive has exact positional arity and requires :color <u32>. Structural nodes do not take color; child primitives carry it.

FamilyForms
solidsbox, cylinder, cone, wedge, sphere
profile/pathlathe, extrude, torus, sweep
structurecomposition, translate, rotate, scale, mirror, array

The typed variants and their fields are the authority in ast.rs; exact arity, keyword, and error behavior is in parse.rs. A representative model is:

(composition
  (box 2 0.5 1 :color 5)
  (translate (0 0.75 0)
    (array 3 (0.6 0 0)
      (cylinder 0.15 1 8 :color 3))))

One input contains exactly one top-level form. Unknown heads and keywords, wrong arity, dotted lists, missing color, malformed vectors/profiles, and trailing forms are parse errors. composition may contain any number of children. Vectors are ordered (x y z). Rotation uses an axis vector and an angle passed to the math layer in radians. mirror accepts only the symbols x, y, or z, and emits its child plus the child's reflection across that axis-plane through the local origin.

sphere takes a radius and an icosphere refinement level: level 0 is the 20-face icosahedron and each level quadruples the faces, up to a cap of 4 (5120 faces); a higher level is a mesh error.

sweep takes a 2D profile and a 3D waypoint path. Optional :scales must have exactly one scalar per waypoint. It is capped by default; :open true omits the end caps. The mesher transports a frame along the path and stitches adjacent rings. Boolean-looking heads are not parked syntax: they are unknown nodes and fail parsing.

Public library operations

The crate-root exports are intentionally small:

Rust operationPurpose
parse(&str)DSL text to typed Node, or ParseError
serialize(&Node)canonical DSL text; parse/serialize/parse preserves the tree
mesh_polygons(&Node)evaluate to canonical n-gon Polygons
tessellate_polygon(&Polygon)display-time fixed-point triangles
mesh(&Node)convenience pipeline directly to colored float Triangles
to_obj(&[Triangle])inspection export grouped by palette index
surface_net(...)separate dense-scalar-volume boundary extractor

See lib.rs, polygon.rs, and surface_net.rs. Node, Polygon, Point3, and Triangle are library types, not mail kinds.

The geometry pipeline simplifies identity transforms, emits primitive n-gons, then runs exact-grid cleanup: weld vertices, repair T-junctions to a fixed point, merge coplanar same-color boundaries, and remove short-edge slivers. The public polygon path groups boundary loops deterministically into outers and holes. The triangle path runs cleanup followed by CDT. The implementation is in mesh.rs, cleanup/, and tessellate/.

Convex faces without holes use a fan fast path. Concave faces or faces with holes use integer constrained Delaunay triangulation. If CDT fails, the public display helper logs a warning and fan-triangulates the outer and each hole independently. That keeps geometry visible but fills holes incorrectly; a CDT warning is a correctness signal, not harmless noise.

Viewer mail surface

The request and result intentionally use different kind prefixes:

Mail kindRust payloadMeaning
aether.kit.mesh.loadaether_kit::mesh::LoadMeshread and load namespace + relative path
aether.mesh.load_resultaether_kinds::MeshLoadResultechoes path, ok, optional error, and warnings

Send the request to the loaded actor mailbox aether.kit.mesh. Although an older kind comment calls it fire-and-forget, the current handler preserves an available reply target and returns MeshLoadResult after fs read and parse settle. Await the reply when automation needs a definitive outcome.

Extension matching is case-insensitive. DSL input must be UTF-8; OBJ is read directly from bytes and validates the recognized numeric tokens:

  • .dsl uses parse → mesh_polygons → tessellate_polygon. Filled faces use color % 8 in the viewer palette. Every outer and hole loop is retained alongside those faces, lifted slightly along its polygon normal, and solved into a slate eye-facing stroke when the active camera eye replies. Its width is angular, so the raster weight does not collapse edge-on or thin with distance.
  • .obj accepts positive one-based and relative negative v position indices in f faces, including slash-form references. Faces are fan-triangulated. Normals, UVs, groups, materials, smoothing, and other directives are ignored; every face is soft blue and receives no outline. Malformed coordinates or face indices and references outside the positions defined so far are errors.

Any read, DSL UTF-8, extension, parse, mesh, or OBJ-index failure leaves the entire prior face-and-outline cache untouched and returns ok: false. The current loader does not produce non-fatal warnings, though the reply reserves that vector. The actor and request kind are in aether-kit/src/mesh/mod.rs, mesh/kinds.rs. The shared importer is in aether-mesh/src/obj.rs; the shared reply type is in aether-kinds/src/lib.rs.

Geometry invariants and failure modes

  • Every meshed coordinate is snapped to a signed 16:16 grid. Inputs must be finite and remain within inclusive ±256 after transforms. Anything outside that asset-local range returns MeshError::OutOfRange; put world-scale placement outside the authored mesh. See fixed.rs.
  • Primitive faces are wound CCW from outside. Mirroring reverses the reflected copy's vertices to preserve outward winding. Degenerate faces can collapse and be skipped rather than producing an error.
  • N-gon outer loops are CCW and holes are CW around plane_normal. Convert Point3 to float only at the render/upload boundary when topology matters.
  • Cleanup stage invariant checks currently log warnings rather than aborting. A non-simple loop, surviving twin edge, or unrepaired T-junction in logs means the canonical output may be damaged.
  • The parser and evaluator have no authored-complexity budget or nesting-depth cap. The one bounded count is the sphere level, capped at 4 because its face count grows exponentially. Large segment/array counts or deeply nested trees can spend substantial CPU, memory, or stack. Validate or bound generated input at the producer when it is not trusted.
  • serialize assumes a valid finite in-memory AST and panics if asked to print a non-finite float. Parsed non-finite geometry is rejected later at the fixed-point boundary.

Lifecycle and chassis caveats

MeshViewer subscribes only to the Render lifecycle stage. It performs file I/O and atomically replaces the cache when a load reply arrives. On each render it sends cached faces immediately. At wire it subscribes to the view of the camera its config names (aether.render.view_subscribe) and keeps the eye of each ViewProjection the camera sends; a DSL cache with outline loops rebuilds and submits them for that eye on each render. A camera that has not published a view yet leaves the filled mesh visible and omits only the outlines. OBJ caches contain no outline loops. A chassis without Render, such as the production headless Tick-only graph, rejects the subscription; the actor can load but never submits geometry. The minimal hub does not host guest gameplay actors. Use desktop or the render-capable SubstrateHarness for visual verification. See Lifecycle and Rendering & camera.

Where to change or extend it

  • A new DSL form must update Node, parser, serializer, mesher, round-trip tests, and reference docs together. If it changes the v1 vocabulary, amend ADR-0051 or add a superseding decision.
  • Reintroducing booleans is not a parser-only change. It must explicitly supersede ADR-0062 and supply topology, resource bounds, native/wasm parity, and adversarial geometry coverage.
  • Change canonical topology or tessellation in aether-mesh, keeping n-gons authoritative per ADR-0057. Do not bake viewer palette or render types into the library.
  • Add a viewer file format in aether-kit/src/mesh, preserving whole-cache atomic replacement and a structured MeshLoadResult. A compatibility importer should not silently become a new native authoring representation.
  • The editable loop remains file-first: write DSL through aether.fs, send aether.kit.mesh.load, inspect the result, and capture a frame. There is no current set_text editor mail or retained face/vertex mutation API.