Skip to content

zone_painter_patch_edit_plan

Jan Boon edited this page Jul 29, 2026 · 30 revisions

Zone painter — patch editing: state, invariants, and the node/object model

Status: working plan (2026-07-28). Companion to zone_painter_ui_stories.md and pipeline_max_design.md §14-paint. Written as a resume point.

The first milestone of the patch-editing story is complete and gated. The node/object model change is done, and with it the multi-file defect that was open when this document was first written. Five further pieces landed on top: the display-frame fixes, the model change itself, the out-of-range rebuild, welded-seam propagation, and the edge and patch sub-object levels.


Part 1 — What is shipped

Mode and levels. M enters patch mode (CPaintMouseListener::ModePatch, mode 4). Digits 15 pick the sub-object level, using the legacy plugin's EP_OBJECT / VERTEX / EDGE / PATCH / TILE values from rpo.h, so a level means the same thing in the tool as in the file it came from. Entering the mode lands on Object level. Vertex, Edge and Patch all select and move; Tile selects a level and draws the cage only.

The digit row is shared with tile-set selection and only one binding is ever live, because TileSetDigits is scoped ZPKS_PAINT and SubObjectDigits is ZPKS_PATCH. This is what the mode-scope mask in the key table was built for — no ordering trick, no "and not in patch mode" guard.

SRPatchMesh::SelLevel is deliberately not written back. Looking at a zone in vertex mode is not an edit, and writing it would dirty every file the artist opened.

Cage and markers. The control cage draws from the display CPatchInfo, not from the evaluated SPatchMesh: that data is already world space and already carries a shared-corner identity in BaseVertices[], which is NeL's own per-zone vertex index. A corner touched by four patches is one vertex, one marker, one selection entry.

Vertex markers are small solid squares (solid squares; crosses read as hatching at cage density), snapped to the pixel grid in both size and centre so they cannot shimmer between pixel counts as the camera moves. White is free, black is bound, red is selected.

Selection. Click selects, Ctrl adds, Alt removes, a click on nothing clears. Identity is (node, vertex) and the rules on top of it are per-object — see Part 3.

Edge and Patch levels keep their own selection sets (g_PatchEdgeSel keyed on the ordered corner PAIR, so an edge drawn by two patches is one edge; g_PatchFaceSel on (zone, patch)) and project them onto the vertex set. The level's set is the authority and the projection is recomputed from scratch, which is what makes dropping one of two edges that shared a corner leave that corner selected. Every rebuild re-applies the weld and alias rules.

Edge picking measures against the DRAWN chain V → T → T → V, not the straight line between corners — a patch edge with real tangents bows visibly away from it. Patch picking is point-in-quad on the projected corners, nearest first.

The cage is drawn in two passes, plain then selected. A shared edge is drawn once per patch, so a single pass lets the neighbour drawn later paint over the highlight; a selected patch came out with two red edges and two blue ones depending on patch order.

Patch mode intercepts mouse-down ahead of the existing Button == leftButton exact-equality test, which otherwise drops Ctrl+left and Alt+left — precisely the pair add/remove needs.

Handles. Tangent handles are selectable and movable. Identity is the PatchMesh Vecs index (SPmPatch::Vec[8], exactly as V[4] indexes Verts), so a handle shared by two patches along a common edge is one handle. Ownership is derived from the patch table (tangent 2e belongs to corner e, 2e+1 to corner (e+1)&3) rather than read from SPmVec::Vert, which is absent on Max 3 files.

Handles are drawn for SELECTED corners only, so the corner selection has to survive picking a handle — drop it and the handle vanishes under the click. Both sets are then non-empty, and the handles win: clicking a handle is the artist saying "this one now", and the vertex stays the vertex selected while you drag its handle. With no handle selected the corner is the target and its handles ride it, once.

Transforms. Move, rotate and scale, on W/E/R. R was ModeProp and is now SCALE in patch mode, resolved the way the digit row was: ModeProp became ZPKS_PAINT, so exactly one binding is ever live.

Pivot. Five modes — selection centre, world centre, centre of all objects (held DURING an interaction, re-fitted after), centre of the selected objects, and a user point placed from the scene right-click. Object centres are BOUNDING-BOX centres, not vertex averages: an average is pulled towards whichever part is finely tessellated.

Gizmo. Drawn at every level that moves something, on the PIVOT (not the selection: the artist has to see what the next rotate will turn around before turning it), with three axes, three plane handles whose inner corners meet on the origin, and an invisible screen handle that lights all three. Sized by the fit-at-rest model: it holds a world length, re-fitted only between interactions, hidden entirely while the view moves. Everything else is in pixels so arrowheads and pick radii are unaffected by axis foreshortening.

Move and write. Dragging commits through the geometry write-target policy, with undo, and the live landscape follows without a rebuild — except past the packed range, where the zone is rebuilt (Part 3).

Review pass (2026-07-28), input path. A full review of the story found four interactive defects the gates structurally cannot see — painter.patchClick and the dev drag hooks bypass the mouse listener, which is exactly where all four lived:

  • The viewer's mouse-down still gated patch-mode clicks on SubVertex, so the edge and patch levels could neither select nor grab the gizmo with a real mouse. The dispatch now covers the same three levels the gizmo draw does. Watch for this shape: a scripted-input gate proves the machinery behind the input, never the input dispatch itself.
  • A gizmo drag now captures the pointer: guiWantsMouse() used to swallow the move and the release when the pointer crossed a toolbar mid-drag, freezing the drag and leaving it armed after the button went up.
  • A Ctrl or Alt click that misses keeps the selection (usual behaviour); only a plain click on nothing clears.
  • The gizmo stays on the pivot during a scale drag — s_DragDelta is set by scale too (the raw plane-hit delta the factor derives from), and only a MOVE rides the drag now.

Plus one recorder gap: the preamble now snapshots the tangent selection after the corner snapshot.

Drag cancel (same day). right-click-while-dragging now cancels the transform, and ESC cancels it ahead of the board-close/quit chain (before this, ESC mid-drag quit the tool out from under the drag). Cancelling drops the drag state and pushes the untouched cage back over the live-update preview; no core op ran, so undo, dirty flags and the recorder see nothing. Finding it exposed a second dead branch of the same shape as the dispatch bug: the right-click scene-menu handler sat BELOW the patch block's unconditional return, so the scene context menu had never been reachable from a real right-click either — it now lives inside the block, cancel first, menu otherwise. The pointer capture also covers mouse-down while a drag is live, so the cancelling right click lands wherever the pointer is. And the code moved: patch edit lives in patch_edit_ops.cpp (state and operations) and patch_edit_ui.cpp (drawing and picking), sharing the drag state through patch_edit_internal.hscript_and_ui.cpp had grown to 4.6k lines, past the 2.5k-per-TU refactor rule. Split verified lossless; full battery green both before and after.

Bind, unbind, edge no-smooth + the Patch rollout (2026-07-29, first Tier B milestone). The three blob-only ops landed, with the panel as their op surface:

  • Ops in the paint core (opEditBinds / opSetEdgeFlags): the pristine-carrier discipline extended beyond tile records to the per-vertex bind records and per-patch EdgeFlags — the carrier blob is re-encoded by the existing save path, so no stream encoder was needed, exactly as the Part 6 research predicted. Undo Kinds 4 (whole on-disk bind record, caches included, so an unbind → undo → save is byte-identical) and 5 (one flag word, reusing the colour-record fields). A new RpStateChanged callback fans the display update per OBJECT, like the geometry callback.
  • Unbind releases whole groups (the legacy UnbindRelatedVertex rule: everything bound to the same target patch+edge goes together — a 25/50/75 trio references its BIND_50 anchor through PrimVert and has no meaningful half-released state).
  • Bind validates with CheckBind (nel_patch_lib): single (vertex joined to both target-edge ends by open edges) or triple (a five-vertex chain; selecting the middle vertex is enough, the companions are discovered). The panel button binds each selected free vertex onto the NEAREST valid open edge — a bindable vertex sits on its target edge, so the nearest-valid rule is the legacy drag gesture's answer without a drag. painter.bindPatchVertex(zone, vert, patch, edgeSlot) is the explicit form the recorder emits.
  • The bind snap: new binds re-run the eval bind refresh (updateBindingInfo / updateBindingPos) on a COPY and write what moved through the Tier A geometry path in the same undo stroke — vertex onto its bindWhere point, rebuilt tangent caches, fed auto interiors. Harvest is RESTRICTED to the new bind's own closure: the corpus refresh is not bit-idempotent in this build (stored data carries the reference exporter's exact x87 bits; ours lands within an ulp), and an unrestricted harvest overwrote ~266 Max-authored values per bind before the restriction. On already-snapped data the snap writes exactly nothing.
  • No-smooth writes bit 0 of EdgeFlags on EVERY patch side of each selected edge (legacy setSmoothFlags), with the legacy tri-state checkbox behaviour (mixed → clear).
  • Display: bind state and flags sync into every sibling node's eval mirror, BindEdges and the smooth-flag bits re-derive IN PLACE (zpRederiveBindEdges, the two buildPatchInfo passes rewritten to not touch geometry the display gates pin), and the affected landscape zones rebuild through zpLiveZoneInfo — bind structure is invisible to refreshTesselationGeometry.
  • The Patch rollout (roll_patch, from the rollout spec): sub-object level toolbar with toggle-off-to-Object rule, selection readout, Bind/Unbind (vertex level), No smooth (edge level), live status line. Buttons freeze outside their level.
  • Gates: e2e/m40_bind_edge_ops.sh — exact byte counts per op (21-byte unbind, 20-byte cache-only rebind, 2-byte both-sides no-smooth), reload persistence, undo chains back to byte-identity, snap-back of a deliberately moved vertex, and unbind-through-instance == unbind-through-source.

Part 2 — Invariants discovered, not to be re-derived

These cost real time to establish. Changing any of them needs a reason.

Overlays run after editorUI->draw(), which leaves the driver in NLGUI's 2D setup. World -space CDRU lines silently draw nothing there. Every patch overlay projects through the camera by hand and emits 2D lines, exactly as zpDrawZoneOutline already did. Also note CDRU::drawLine's 2D overload takes the driver before the colour while the 3D one takes it after — the world-space version compiles fine and draws nothing.

There are two render paths, the interactive loop and --screenshot, each with its own overlay pass. Anything new goes in one helper both call.

Bound vertices are derived, not authored. RPatchMesh::UpdateBindingPos recomputes BIND_25, BIND_50, BIND_75 and BIND_SINGLE alike by interpolating the target edge's Bézier, so the test is Binded, not the bind type. Keying on "not BIND_SINGLE" shows nothing at all on real data: zonematerial-bassin-ilot_croix has 94 bound vertices and every one is a single. A bound vertex must never accept a move — it is recomputed on load, so the write could not survive a round trip.

The geometry write target is not the tile carrier, and what it stores is not the same quantity in every case. Per output vertex of the topmost edit-patch modifier (its output is displayed, so the policy is local to it and never recurses):

case write holds
mapper record with Vert >= 0 the record's Delta (0x1130) a delta from its Original
unmapped the stored 0x1140 position an absolute object position
no modifier stack the base RklPatch PatchMesh position an absolute object position

That last column is load-bearing and cost a real bug: a display update that read the stored value as a position dropped mapper-path vertices at roughly the node origin, with byte-perfect file output. The geom-changed callback therefore carries the object-space DELTA, which is well defined for every target; undo passes the difference of its two stored values.

All three are 12-byte in-place overwrites; the mapper record is a flat 32-byte stride (OriginalStored, Vert, Original[3], Delta[3]), so its Delta has a computable offset. No chunk resizes, no re-encode — an untouched file stays byte-identical. Tier A covers every path.

Survey, 40 lacustre ligo files / 72 zone nodes: 67 modifier slots all carrying a 0x1140; 46 nodes with a mapper; 2752 mapped vertices; 26 nodes with no modifier stack at all. Neither the mapper path nor the base path is an edge case.

ObjectTM is not the display frame. It is the frame the FILE authored, which is the display frame only for a node sitting at the board origin untransformed. SPaintZone::DisplayTM is the full object → displayed-world transform and is what any recomputation must use; the three routines that move display geometry (translateZonesXY, cloneInstanceZone, placeContextRange) each compose onto it. instanceDisplayTM derives its matrix by SAMPLING transformInstanceXY rather than restating the rotation table, so the matrix and the vertices cannot drift apart.

Geometry dirtiness needs its own flag. isZoneDirty re-encodes the RPO pristine copy; a vertex move writes the PatchMesh or a mapper delta, which no RPO encode can see. Without markGeomDirty a moved zone reports clean and the save skips the file.

Undo re-resolves its target rather than caching a CStorageRaw*, which would not survive a working-set rebuild — and re-running the policy is the only way to be sure undo cannot drift from the forward path. A whole selection lands as one stroke.

Live surface updates need no rebuild in range. CZone::refreshTesselationGeometry re-derives every tessellation vertex from computeVertex, so writing control points and refreshing is the whole mechanism. Bind neighbours must be refreshed with the moved patches or the surface cracks along the seam.

Control points are 16-bit fixed point (CVector3s) around PatchBias/PatchScale, one LSB per PatchScale — a few millimetres on a normal zone. The live surface therefore sits on a coarser lattice than the .max, which keeps full float precision. The range check belongs to CZone::setPatchGeometry and must test what pack() can represent (sint16), not the 32760 computeBBScaleBias divides by: the bbox is sized from vertices plus a noise margin while tangents may lie outside that hull, so corpus control points legitimately sit between the two. Checking at 32760 declines zones nobody edited.

A zone rebuild must take its data from the LIVE zone, not from the display cage. pz.Patches carries the geometry, but its tile records are the ones assembly loaded: every tile, colour and lumel painted since lives in the landscape zone and the pristine carrier. So CZone::retrieve the live zone whole and replace only what is changing. Building from the cage gets the geometry right and reverts the terrain — a trap walked into twice, once by the out-of-range rebuild and again by the mode-switch rebuild, which is why both now go through one zpLiveZoneInfo. The bind data cannot come from the retrieve either: that reflects how the zone is built right now, so unwelded → welded would find the cross-zone binds already gone. It comes from the paint zone, filtered by the weld state.

Byte identity is measured against the --null-edit output, never the source .max — the OLE container is rewritten on any save. Sessions must run --no-hint-stamp --no-thumbnail or hint/thumbnail bytes drown the signal.

A byte gate cannot see the display. Both frame bugs above wrote perfect bytes. Display correctness is gated through painter.patchVertexPos, which returns the world position the marker is drawn at.


Part 3 — The model: nodes and objects

There is no "primary" node and no second-class "instance". An SPaintZone is a node: an object plus a transform. Several nodes may carry the same Node pointer, and that pointer is the object — one storage, one paint carrier, one set of vertices.

Three explicit per-node facts, none of them an id comparison:

field means
Editable the object may be written through this node (a property of the object)
InFile the .max holds this node; session-added ones are never saved back
DisplayTM object → where this node is drawn

kInstanceZoneIdBase survives only as an id allocator: session-added nodes take landscape ids from 10000, above the per-file bases (index * 1000), which is what caps a session at ten open files. Comparing a zone id against it to decide anything else is how the display frame and the write frame drifted apart in the first place.

Consequences, all gated in m35:

  • Every node of an editable object is editable. An edit through any of them writes the one object, and every node showing it follows through its own transform (zpGeomVertChanged fans out over the object, not over a zone id).
  • The same edit expressed in each node's own displayed space writes byte-identical output, including through a 90°-rotated node. This is the strongest form of the claim that the per-node transforms are right, and it is why the rotated/mirrored refusal is gone.
  • Prop mode has no display-copy case to exclude: zone properties are appdata on the node pointer the nodes share.
  • Selection identity is (object, vertex). One underlying vertex reached through two nodes is one selection — selecting it twice would apply the drag to a single storage location twice, with two object-space deltas that do not even agree. Different vertices of one object through different nodes share nothing and stay allowed: authoring an edge from whichever node shows it best is the point.

Welds belong to painting, not to editing. weldPaintZones derives cross-zone binds and border vertices at load by scanning for coincident edges, so tile transitions can cross a border and checkSeams can find one. CZone::compile then does not merely bind welded zones, it aliases their corners —

BaseVertices[cur] = zone->getBaseVertex(vertto);      // zone.cpp:551

— so the two sides of a seam become one CTessVertex. A patch edit that breaks the seam is therefore structurally invisible: there is one vertex there, holding whichever position was written last, and the render shows a continuous surface that neither .max describes. Measured on a welded seam, moving one side 40 units drags the neighbour's corner up with it.

So patch mode rebuilds the landscape with cross-zone binds and border vertices stripped, and the seam is left to show. Only CROSS-zone binds go; a zone stays bound to itself or its interior falls apart too. The paint-zone data is untouched either way — paint_core reads BindEdges straight from it — so a mode switch is a landscape rebuild, not a reload, and the paint core is not re-initialised: it holds no CZone pointers (every access is m_Landscape->getZone(id)), so carriers, dirty flags and undo survive.

Seam propagation went with it. Beyond being unnecessary once the seam is visible, propagating across a session's welds made the same edit mean different things depending on which files happened to be open — and for ligo bricks the border profile is a contract across every brick sharing that edge type, not a pairwise fact about the two that are loaded.

No T-junction cracks appear along an unedited seam: the welded/unwelded difference on an unedited session is 0.4% of pixels, scattered shading noise from independent corner smoothing, not geometry.

Out of range. A move past the zone's packed bounds rebuilds the zone, which recomputes PatchBias/PatchScale. Commit path only — during a drag the preview lives in a temporary cage while the display patches still hold the un-moved shape, so a rebuild would faithfully rebuild the OLD geometry and pay for it every frame. The surface lags past the boundary until release. Seam neighbours are collected before the removeZone that unbinds them, and the rebuilt zone's own patches are dropped from the refresh set since the pointers gathered for it die with it.


Part 4 — Gates

e2e/m34_patch_move_byte_gates.sh — the .max bytes. Three files chosen so all three write targets are covered and none can pass by luck (the gate asserts which target the log reports):

file target
material-fond modifier PatchMesh
material-bassin mapper delta
zonematerial-bassin-1 base PatchMesh (no modifier stack)

Each asserts: patch mode entered with nothing moved is byte-identical to the baseline; one vertex moved 1.5 along Z changes exactly 2 bytes; two vertices moved then one undo returns to the baseline byte for byte.

e2e/m35_patch_display_frame_gates.sh — everything a byte gate cannot see:

case asserts
m35-1 all three write targets: the cage follows the edit, undo restores it, the live push accepts
m35-2 a second editable file placed one board cell away edits in the frame it is drawn in
m35-3 two nodes on one object: fan-out through each transform, and both halves of the alias rule
m35-4 the node used to reach the object does not change the bytes (rot 90 vs the first node)
m35-5 an out-of-range move rebuilds the zone and keeps its tiles (band mean 0.147 vs 0.198 for authored ground)
m35-6 patch mode builds the zones apart, asserted on the COUNT of cross-zone binds and border vertices dropped; plus a paint → patch → paint round trip proving tiles survive both rebuilds

A render comparison used to stand where that count is, and it was unsound, not merely fragile: in the welded build the shared CTessVertex ends up holding whichever patch refreshed last, so how visible the alias is depends on refresh order rather than on whether the weld is there. It passed and failed on ordering. Worth remembering as a shape — a pixel diff that happens to correlate with the thing you mean is not a test of it.

e2e/m36_patch_sub_object_levels.sh — the edge and patch levels:

case asserts
m36-1 an edge move writes exactly its two corners, and undo restores them
m36-2 dropping one of two edges that shared a corner keeps that corner — the projection rule
m36-3 switching level clears, and a patch move writes exactly its four corners
m36-4 picking: a click finds a face, an edge-level sweep crosses the cage, a click on nothing clears

painter.patchClick exists for m36-4: a script that calls selectPatchEdge proves the selection machinery and nothing about whether clicking on an edge finds that edge, and --screenshot has no pointer.

e2e/m37_patch_tangent_handles.sh — handles: the interaction rule (a handle move leaves its corner), the ride rule (a corner move takes its handles exactly once), and bytes on all three write targets. The delta path matters most: it proves the vec half of the mapper payload starts at 8 + vertCount*32, since the second count word is not padding.

e2e/m38_pivot_modes.sh — the five pivot modes, in a session where all three "centre" answers differ, plus the gizmo following the pivot.

e2e/m39_rotate_scale.sh — rotate and scale against hand-computed positions, that the pivot decides the result, and that the arbitrary-axis form agrees with the enumerated one.

Dev hooks, alongside the existing ZONE_PAINTER_ZOOM_EXTENTS: ZONE_PAINTER_GIZMO_HOVER=<handle> forces a handle hot, ZONE_PAINTER_GIZMO_DRAG="h:x,y,z" forces a live drag. A --screenshot run has no pointer, so without these the hover and drag states cannot be seen, let alone gated. The drag hook pins state every frame, so an interactive session with it set cannot drag normally.


Part 5 — Backlog

Sequencing note: the model change is done, so the sub-object levels are no longer blocked.

  • Tile sub-object level: selects a level and draws the cage; does not edit. Unlike edge and patch it is not a geometry selection at all — the painter already addresses tiles through the paint modes, so what this level should do is an open question rather than missing work.
  • Tier B (encodePatchMesh) for anything topological. Everything geometric so far is Tier A; the 2026-07-29 bind/unbind/no-smooth milestone is Tier B's blob-only first step (no encoder involved — see Part 1).
  • Unbind as an explicit op with group semantics DONE 2026-07-29 (whole-group release; the design reasoning lives in Part 1). Auto-unbind on drag stays rejected: distance cannot discriminate "dragged off deliberately" from "followed along in a multi-selection", the discriminator has to be whether the bind's target edge is itself moving, and a silent unbind opens a crack in the surface.
  • Cursors: the manifest above zpSelectMode lists what is needed; several need art rather than code.
  • Patch-mode rollout: STARTED 2026-07-29 — roll_patch carries the level toolbar (toggle-off), selection readout, Bind/Unbind and No smooth. Still from the spec: the Filter Vertices/Vectors pair, Lock Handles, named selections (deferred pending a design pass against board sessions), the level-swapped properties block (smoothing groups, U/V tess, tile properties), and the icon faces for the level toolbar (text until the art pass). The panel spec — rollout inventory, per-level enable rules, icon and cursor motifs — is zone_painter_legacy_rollout_spec.md.
  • Bind command mode (the legacy sticky drag-from-vertex-onto-edge): the current nearest-valid-edge button covers the authoring cases; a drag gesture needs the bind cursor art and a mouse-listener command mode, and waits for both.

Cross-file seams are now the artist's job, deliberately. With the zones drawn apart, an edit that breaks a border shows as a gap and both sides can be selected and moved together — every file's nodes are editable and selection is per-node. Nothing propagates on its own.


Part 6 — Tier B research (2026-07-28, all three questions closed)

Survey: 1231 ligo .max (all four ecosystems), 6943 RklPatch objects, 1539 modifier PatchMesh copies, 833 mappers. Tooling: pipeline_max_dump now prints a Claimed: line per RklPatch (id(size)=bytes for small raws, C<sum> for containers) and hex-dumps every claimed raw under PMAX_DUMP_CLAIMED_HEX=1.

6.1 The PatchMesh chunk stream — every id accounted for

The element streams (0x0BD6/0x0BE0 verts, 0x0BC2/0x0BCC vecs, 0x0BD1/0x0BD2 edges, 0x0BEA/0x0BF4 patches) were already decoded. The rest:

id size is encoder must
0bb9 4 = 4 (all 6943) PatchMesh version marker preserve
0bb8 4 = 0 (all) constant scalar preserve
0c94 4 ∈ {0,2} per-object scalar, topology-independent preserve
0bfe 4, low16 ≡ 0, high16 varies garbage/high-half field preserve
0c12 4, bitfield {0, 0x401, 0x20401, 0x800, …} display flags preserve
0c1c 4 ∈ {1,2,4} saved sub-object level preserve
0c26 / 0c30 / 0c3a 4+4·ceil(n/32) vert / patch / edge selection BitArray (int32 count + dword-padded bits), n = the matching element count, zero misfits over the corpus regenerate
0c44 / 0c6c / 0c76 8 = (5,0)/(1,1)/(0,0) (all) constant pairs preserve
0c4e / 0c58 / 0c62 C, 16 children 0x300d–0x301e the three TessApprox blocks (viewport/render/disp) preserve
0d5c / 0d7a 4 = 5 / 0 (all) mesh steps / constant preserve
0d66 4 ∈ {0,1} (30 ones) boolean (showInterior-shaped) preserve
3440 24, one fixed 6-int pattern (all) soft-selection params preserve
0d48 / 0d52 count / count×52 HookPoint array — 13 ints, the HookPoint layout (indices into points, vecs, patches, edges) remap
0c80 4+64·patches TVPatch array: int32 header + 16 ints per patch (4 corner tv + 8 handle + 4 interior, −1 unassigned) regenerate
0c8a 8+12·n PatchTVert array: [int32 hdr][int32 count][count × UVW Point3] regenerate

0c80/0c8a appear in exactly the same 2745 objects (the mapped/KeepMapping subset — the zonematerial-converted-* heritage class). The selection BitArrays are present in a subset (2458/1737/5654) — presence rules fall out of the identity gate, absence is preserved.

So the encoder's job splits cleanly: regenerate the four element streams, three selection BitArrays, hooks, and map-channel arrays; preserve every remaining chunk verbatim.

6.2 The cached Mesh stream — verbatim is safe, from source

The Mesh chunks (0x0906…0x2398) are RPatchMesh::mesh, written by RPO::Save via Mesh::Save. RPO::Load reads them back — but both RPatchMesh constructors set every validity interval to NEVER and nothing in Load validates them, so the first Display / GetRenderMesh / SubObjectHitTest hits BuildMesh, sees invalid channels, and rebuilds from the patches. The loaded content is never used. Policy: keep the chunks byte-verbatim through any edit; they only have to remain parseable. (Confirmed off rpo.cpp / nel_patch_mesh.cpp.)

6.3 Modifier-stack topology — the corpus already contains it

151 modifier PatchMesh copies have element counts matching no base object in their file — in-corpus Edit Patch topology edits, both directions: deletions (zonetransition-foret-plateau-1: base 100v/78p → modifier 95v/71p) and additions (zonematerial-converted-165_eg: 68v/53p → 70v/54p). No reference files need authoring.

Specimen decoded end-to-end (zonematerial-foret-26_landmark_e_ring2, base 200v/1552vec/181p, output 196v/1504vec/175p):

  • Each modifier's local data carries a full OUTPUT PatchMesh under 0x1140, same chunk grammar as the base stream. Both stacked modifiers (NeL Painter over NeL Patch Edit) carry their own copy.
  • The Edit Patch mapper (0x1130) is INPUT-indexed: one 32-byte record per input element {OriginalStored=1, Vert = output index or −1 for deleted, Original[3], Delta[3]}, vec half after the vert half. Ring2: 200 records, 8 deleted, outputs 192–195 unmapped = added elements exist only in the output 0x1140 — which is exactly why the write-target policy already has the unmapped → stored-0x1140-position branch.
  • The NeL Painter slot's 0x4001 blob is sized to the OUTPUT topology (v9, 175 patches) while the base 0x08FD still carries 181 — the topmost-carrier policy again, now confirmed for topology and not just values.

6.4 What this means for the Tier B sequence

  1. encodePatchMesh = one grammar serving the base stream and every 0x1140. First gate: decode→encode byte-identity over all 6943 base + 1539 modifier instances (ctest #25 pattern) BEFORE any op exists.
  2. Topological ops rewrite: the affected PatchMesh stream(s), the three selection BitArrays, hooks, map channels, the mapper (input records keep their slots; deleted outputs flip Vert; added outputs stay unmapped), and the topmost 0x4001 via the existing setRPatch. Tile/colour/bind reconciliation ports from nel_patch_lib/nel_patch_mesh.cpp.
  3. The 151 topology-edited files are the semantic reference corpus for op behaviour.
  4. Still open from the earlier gap list (design, not research): undo Kind 4 raw snapshots, painted-state migration across shifted patch indices, and the display re-evaluation path.

Paint survives topology — a headline goal, not a chore (Kaetemi, 2026-07-29). In the legacy editor any topological edit ruined the painted tiles: its reconciliation reset the painted arrays wholesale, with no index mapping. Our ops are required to compute an explicit old→new element mapping anyway (the mapper records, hook remap, and selection BitArrays all rewrite from it), and the paint blob's per-patch data (tiles, colors, edge flags) is indexed parallel to the patch array — so the same mapping carries the paint: surviving patches keep their tiles verbatim, deleted patches drop theirs, added patches get defaults. Subdivide can go further: a patch's tile grid is regular, so the four children inherit the parent's quadrants (halving per-patch U/V tess while it stays ≥ 1), preserving the painted appearance through the split. This is the concrete artist-facing payoff of Tier B beyond the ops themselves, and the acceptance bar for every topological op: a painted zone edited topologically must keep its paint everywhere the surface survived.

NeL customizations: tile/colour/bind reconciliation and RPatchMesh maintenance follow nel_patch_lib / nel_patch_paint (Nevrax). Structural topology ops follow the on-disk stream semantics in 6.1–6.3, the 151-file reference corpus, and standard Bezier-patch math.

Clone this wiki locally