-
Notifications
You must be signed in to change notification settings - Fork 114
zone_painter_patch_edit_plan
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.
Mode and levels. M enters patch mode (CPaintMouseListener::ModePatch, mode 4). Digits
1–5 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_DragDeltais 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.h — script_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-patchEdgeFlags— 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 newRpStateChangedcallback fans the display update per OBJECT, like the geometry callback. -
Unbind releases whole groups (the legacy
UnbindRelatedVertexrule: everything bound to the same target patch+edge goes together — a 25/50/75 trio references its BIND_50 anchor throughPrimVertand 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
EdgeFlagson EVERY patch side of each selected edge (legacysetSmoothFlags), 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 twobuildPatchInfopasses rewritten to not touch geometry the display gates pin), and the affected landscape zones rebuild throughzpLiveZoneInfo— bind structure is invisible torefreshTesselationGeometry. -
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.
Full-story review pass (2026-07-30, second half of the day). A four-way review (input/UI, topo glue, transform layer, plus the ops/undo/write layer read directly) over everything above, all gates green before and after. What it found, grouped by theme:
-
Preview/commit parity, again (the m35 display gates cannot see interaction paths):
a handle did not ride a corner selected through a SIBLING node (the ride check keyed on
the displayed zone's id, so an instance's cage tore mid-drag and the live preview pushed
the torn surface; commit was right, so it snapped at release); and a handle of a BOUND
corner previewed a motion the commit refuses. Both fixed in
zpTanOffset. - Cancel reaches the innermost live thing: the right-click/ESC chains disarmed an armed-but-idle target weld BEFORE cancelling a live gizmo drag (reachable: arming only claims presses that land on a vertex), so the "cancelled" drag committed on release. Mode/level/W-E-R switches now cancel live drags too (the key table stays live mid-drag), and the paint-stroke release uses a button MASK test (NeL folds modifiers into Button; an exact compare left the stroke open when released with Ctrl held).
-
Recorder preamble: pivot state is recorded on change only, so the preamble now
emits
setUserPivot(x,y,z)(new absolute op) +setPivotMode- a recorded rotate/scale re-derives its pivot from the MODE at replay, and a recording started on a non-default pivot replayed around the wrong anchor. Same gap class as the tangent-selection preamble fix above. -
Attach's stale target id (HIGH): the source close re-bases every zone id, and the
post-close
zpFindPaintZoneMut(targetZone)could abort - or with three files open, MERGE INTO THE WRONG FILE. The target is now re-resolved by NODE across the close. -
Anchor-cell fixup generalized: delete / add quad / weld / target weld / the
detach-to-file source pass move the authored footprint origin exactly like attach/move
do, and got the same whole-cell
CellX/CellYcompensation (deleting the west row of a multi-cell zone slid the survivors a cell on the next board rebuild). Instance-addressed ops resolve the file through the object's in-file zone. - Subdivide read a grid its own phase 1 had corrupted (HIGH, transform layer): the plain split halves the reused tangent records in place, and phase 2 built the parent 4x4 grid from those slots - EXACT on a uniform lattice (the gate fixtures), wrong on any sculpted patch (dented centre, wrong interiors). The grid is now captured in a phase 0. A worthwhile shape: a "byte-exact" fixture can be exactly the geometry on which an arithmetic bug vanishes.
- Interior-vec identity: authored interiors carry PVEC_INTERIOR (bit 0) and Vert = -1 (verified empirically over the corpus); add-quad and subdivide created them tangent-shaped (Vert = corner, flags 0) and subdivide even rewrote reused interiors' Vert. Fixed to match the convention Max and the detach ownership derivation key on.
-
Mapper meshes: stored positions are stale caches. Evaluation rebuilds a mapped
output as input + Delta, so any transform that READS geometry read pre-edit values on
mapper files, and subdivide's writes to reused (possibly mapped) slots silently did not
show. The transforms now take the session's EVAL MIRROR for effective reads
(
evalPm), subdivide shifts mapped records' Deltas on write, detach copies duplicates from eval (the invisible seam stayed invisible only on unmapped files), and weld measures its threshold on what the artist sees. Weld's no-position-write rule was already correct - it is the model the rest now follows. - Assorted: encode-failure mid-op now restores the pre-op stream from the snapshot it
already holds (a partial carrier state could otherwise reach a later save); every topo
refusal prints to stdout for the gates; a weld fused-edge remap chain now root-chases
(three-plus coincident edges, theoretical on this corpus); the stale
AllObjectspivot cache invalidates on any geometry change, not only drag end; deadzpPatchVertDragOffsetremoved.
Ride-in-file + panel completion (2026-07-30 evening, gates m50/m51/m52).
- A corner move now writes its tangent handles into the FILE. Found by a save+reopen probe while building the m50 fixtures: the display rode the handles, the stream did not — the saved surface was pinched and nobody could see it until reload (the m34 "exactly 2 bytes" gate was pinning the bug). The write takes every vec slot the moving corner owns, each through its own per-element delta (a rotate turns handles about the pivot — and the preview now rides at the handle's own transform image, not the corner's offset, so preview and commit agree for rotate/scale too). m34/m35/m36/m37/m39 updated to the element counts; m50-1 pins reopen persistence.
-
m50, the fixtures arithmetic cannot hide on: the sculpted pair-subdivide centre
check (computes the true bicubic centre AND the would-be corrupted one, asserts the
fixture discriminates — margin 0.027 on the bassin pair), and the mapper scenes (move
first so the caches go stale, then subdivide / detach and assert the results follow
the evaluated geometry). New read accessors:
patchInteriorPos,tangentScreenPos. -
Selection block (m51): Filter Vertices / Vectors gate vertex-level PICKING only
(both-off refused; the panel freezes the other box), and Lock Handles expands a handle
move over the corner's owner group through ONE predicate
(
zpTanSelectedEffective) consumed by preview, live push and commit alike. - Surface Properties (m52): the smoothing-group grid (tri-state click: a bit on ALL selected patches clears, off or mixed sets; Clear All), per-patch U/V tess with the legacy corner-anchored keep (a painted marker survives shrink and grow-back), and Balance onto the max per axis. Both are carrier-value ops riding the shared topo runner — SmGroup in the PatchMesh stream, tile orders in the rp blob — so writeBack/encode/rebuild sequencing and Kind 6 undo come for free. Tess refuses patches touching binds (NeL derives bound-edge tessellation from the target's orders). The legacy Material-ID group is deliberately skipped: zones do not consume Max material ids.
- Panel plumbing: the Painter column's height cap raised (the new blocks clipped at the
old 300), and
zp_checkbox_rowgrew alabelwparam — its invisible full-width label hit-target overlapped any checkbox placed beside it.
Orientation arrows + turn seam wipe (2026-07-30 night, Kaetemi's stories; m42 reworked, m53 new).
-
Orientation arrows — the display that makes Turn CW/CCW visible at all (a turn
moves no geometry, only the frame the tiles ride). One toggle, two layers, both from
the legacy: the painter's ToggleArrows recipe verbatim (every bank tile gains an
ADDITIVE
arrowlayer, so each painted tile shows its frame, rotation included; off restores the pristine bank and invalidates every patch), plus the patch editor's thin centred line arrow per patch on the overlay, drawn with the cage's style, pointing along MINUS v (ring edge 0's axis, reversed) — the direction a rot-0 tile's ADDITIVE arrow renders, pinned empirically with a rot-0 fill legend against the ring axes, so the two layers agree per tile instead of contradicting each other (Kaetemi caught the sign from one screenshot; the additive pass is authoritative — it is the renderer's own tile UV transform, shared with the diffuse; the same legend also proved the additive arrows DO turn with a Turn, the far view had only hidden it among the authored per-tile rotations). Panel Arrows checkbox, key C in PATCH scope (the legacy painter's C; paint scope owns C for ModeColor — the digit-row trick again),painter.setShowArrows. TRAP: the arrow tile lives at the_texture_tilesROOT, which the bank resolve does not index, and a MISSING additive texture whitewashes the whole terrain (NeL's missing-texture fallback) — the toggle registers it by probing up from the bank path. m53 gates by screenshot byte-diffs with a repeat-render determinism control (scene 0): arrows change the patch frame, a turn is VISIBLE with arrows on, the additive layer renders in tile mode. -
Turn seam wipe (Kaetemi): a turn rotates the painted grid, so the tiles along any
edge the patch shares — neighbor patch or bind junction — no longer transition into
the neighbor's paint. Chosen fix: the invalid rows EMPTY on the turned patch's own
side (
wipeTurnedSeamsin the transform). The auto-fix alternative (recomputing transitions) would repaint the NEIGHBOR inside the turn's undo stroke; the painter's own transition pass is the repaint tool. Fully open edges keep their tiles, so an isolated patch's turn stays a true bijection — m42's four-turns identity now runs on a detached-element island (detach first, then four turns == the detach alone, byte for byte), and m42-1 asserts the three shared rows wipe while the zone-border row keeps its paint.
Extrude (2026-07-30 late, Kaetemi's story; gate m54). The legacy Extrude recomposed
from the tool's own proven pieces — topoExtrudePatches in its own TU:
- The selection's boundary splits EXACTLY as detach-to-element does it (the transform now
exports its boundary pairs via
STopoDetachBoundary), WALLS bridge the two rings — one quad per shared boundary edge, vertical edges shared between adjacent walls, the bottom reusing the original edge record and the top the island's copy — and the island then translates by the height. One Kind 6 stroke, one undo, through the shared runner. - Wall tiling (Kaetemi's requirement): the horizontal order matches the island patch's order along that edge (tiles line up across the top seam), the VERTICAL order comes from the height at the standard 2 m/tile density, clamped 1..4. Walls start with empty tiles and white colors (the add-quad rule).
- Open zone-border edges rise WITHOUT a wall — a ligo border profile is a cross-file contract, and walling it would break the brick's edge. A self-touching selection outline refuses (three walls would over-share a vertical edge).
- The island translate is mapper-aware through the Tier A rule (mapped elements move through their DELTAS, unmapped through stored; bound vertices skipped — derived).
-
Two entry paths: the panel Extrude button pops the height modal (weld-dialog
pattern, seeded with the last height), and SHIFT-drag at patch level runs a
Z-constrained drag through the ordinary gizmo machinery — same preview (the selection
lifts; walls land at release), same cancel paths — committing extrude instead of move.
painter.extrudePatchSelection(h)/extrudeDragAt(x0,y0,x1,y1)for scripts. - m54: wall count (3 on the bassin fixture — the open west border rises wall-less), exact +h ring over the staying originals, wall tess 2x4 (8 m at 2 m/tile x the top patch's 4), island paint survives, walls empty, persistence, encoder round-trip, ONE undo to byte-identity, the drag path, mapper eval + undo, refusals. Visual: a negative extrude digs a clean pit (all walls visible from inside — single-sided landscape).
- Legacy Bevel (and Outlining/Normal-group/Bevel-smoothing) stays deferred.
These cost real time to establish. Changing any of them needs a reason.
Overlays are TWO phases now (2026-07-30 late). The WIREFRAME — cage lines and frame
arrows — draws in a depth-tested 3D pass BEFORE editorUI->draw() (zpDrawPatchWire3DAll):
an x-ray faint pass with the depth test off (hidden parts stay readable), then the solid
pass with a negative Z-bias (the landscape's own decal convention) and no depth write, so
terrain in front occludes the cage instead of the cage painting over everything. The
SCREEN-SPACE phase still runs after editorUI->draw() — markers, handles, gizmo, rubber
lines, HUD — and the original constraint still applies there: the driver is in NLGUI's 2D
setup, world-space CDRU lines silently draw nothing, everything projects by hand. 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 in the 2D phase.
The ZONE_PAINTER_GIZMO_DRAG dev pin lives in the 3D pass (it runs first each frame).
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.
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 (
zpGeomVertChangedfans 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.
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.
The forward plan for everything below (and the later-Max additions — sculpt ghost,
soft selection, flatten, loop split, bridge, snap, noise) is now its own page:
zone_painter_editing_methods_plan.md. Part A there (the missing legacy methods:
Coplanar/Corner, Auto/Manual interior, Hide, edge subdivide + propagate, Bevel,
extrude-along-normal, Detach Copy, Reset paint) is the NEXT SESSION'S work, in its
stated order. This backlog stays as the historical record of what closed when.
Sequencing note: the model change is done, so the sub-object levels are no longer blocked.
-
Detach REFRAMED (Kaetemi 2026-07-30): detach-to-element, not detach-to-file. The panel Detach (and
painter.detachPatchSelection()) now means: split the selection's boundary — duplicate every vertex and edge shared with the unselected rest — so the selection becomes its own fully-connected ISLAND inside the same RklPatch. Nothing moves (the seam is invisible until edited, the unwelded-display philosophy), the zone still exports as ONE node — load-bearing, because the ligo brick export hard-refuses a file with multiple unfrozen patch nodes ("multiple NelPatchMesh, can't export"), which is also why detach-as-new-scene-node is the WRONG mapping here. Binds crossing the cut release; paint is untouched (nothing dies, nothing renumbers). Element selection lands with it: "object" within a zone = fully-connected patch set; the Element button at patch level expands the face selection over shared-vertex connectivity (painter.expandSelectionToElement()); a double-click gesture can join later with the cursor/input pass. The detach-to-FILE story is SHELVED (still correct and gated, script-only aspainter.detachToFile(name)): it makes sense for splitting a multi-cell zone into bricks, but zones reference meshes and there is no fresh-zone-file-creation story yet — a byte-clone of the source file is the wrong foundation for that. Revive it when file creation is designed properly (what a new brick carries: hints, embeds, meshes, SI). -
Move selection to the neighbor zoneDONE 2026-07-30 (Kaetemi's cross-file op — no legacy equivalent; re-stated same day after going missing from this doc; gate m47). "Move" means detach/attach in ONE stroke, never a spatial shift: the patches change which FILE owns them while keeping their world position. Patch level, scene right-click menu "Move to zone" compass (N…NW, greyed where no editable board neighbor sits;zpMoveDirTargetresolves block adjacency); the op is extract (complement-delete on a copy, eval positions) →topoAppendMeshinto the destination (display-transform reorientation) →topoDeletePatchesfrom the source. BOTH files stay open, zone set unchanged → one Kind 6 stroke with TWO snapshots (each with its own anchor-cell delta) — ONE undo restores both files byte-exactly. Paint travels, pending paint included.painter.movePatchSelectionToZone(dst)/moveDirTarget(dir). -
Weld distance popupDONE 2026-07-30: the panel Weld button pops the distance modal (seeded with the last-used threshold, Weld/Cancel); OK welds the vertex selection at the confirmed distance and the value becomes the next seed. ScriptedweldPatchSelection(threshold)unchanged. -
Target-weld dragDONE 2026-07-30 (gate m48):topoWeldVertInto— the directed variant of the weld plan (explicit target keeps position and identity; the fused-seam survivor is now the edge whose endpoints did NOT move, cluster weld included). Panel Target toggle arms the command mode; dragging a vertex onto another welds it INTO the target — the drag is a SELECTION gesture, nothing is displaced; rubber line + candidate diamond overlay; right-click/ESC cancel the drag, then disarm. After add-quad the fresh corners weld straight onto the rim (the "snap loop" — no placement step at all).painter.weldVertexInto(zone,src,dst)/weldDragAt(x0,y0,x1,y1)/vertexScreenPos(zone,vert); dev hook ZONE_PAINTER_WELD_DRAG for overlay shots. -
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 semanticsDONE 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
zpSelectModelists what is needed; several need art rather than code. -
Patch-mode rollout: STARTED 2026-07-29 —
roll_patchcarries the level toolbar (toggle-off), selection readout, Bind/Unbind and No smooth.The Filter Vertices/Vectors pair, Lock Handles(DONE 2026-07-30, m51),the level-swapped properties block: smoothing groups, U/V tess(DONE 2026-07-30, m52 — with Balance; tile properties still pending the Tile-level design question; the Material-ID group deliberately skipped, zones do not consume Max material ids). Still from the spec: named selections (deferred pending a design pass against board sessions) 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 — iszone_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.
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.
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.
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.)
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.
-
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. DONE 2026-07-29:nelpatch/rpo_dataencodePatchMesh(pm, chunks, err)rewrites the regenerate-class chunks IN PLACE (element streams with count chunks, the three selection BitArrays as their single-0x2700 wrappers, hooks 0x0D48/0x0D52, map channel 0x0C80/0x0C8A) and leaves every preserved chunk untouched; chunk objects are reused so per-chunk header-width flags survive, and element containers are inserted/erased as counts change (the ops direction is already plumbed). The decode side now captures chunk PRESENCE per optional child (an absent counted chunk and a present count-0 chunk are different bytes), the BitArrays/hooks/map channel, and flags Max 3 streams asEdgesReconstructed— reconstructed (derived) edge tables refuse to encode. Observed child orders are load-bearing and differ per container: vectors store owner 0x0410 BEFORE patch list 0x0406, patches lead with Type 0x0424 (PMAX_DUMP_CLAIMED_TREE=1onpipeline_max_dumpprints them). Gate: ctest #26pipeline_max_pm_encode_corpus→pm_encode_corpus.py→pipeline_max_corpus_test --pm-modify-save-test(whole-file null-edit proof, the rpo-modify-save shape: every stream must stay byte-identical after every PatchMesh in the file is decoded and re-encoded in place). -
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 existingsetRPatch. Tile/colour/bind reconciliation ports fromnel_patch_lib/nel_patch_mesh.cpp. FIRST OP LANDED 2026-07-29: Delete Patches. The machinery it established:-
Write target settled: the TOPMOST edit-class modifier slot carrying an RFINALPATCH
0x4001, else the base RklPatch — the same slot the tile-carrier and geometry policies
pick. Settled by reading the painter modifier's own eval (
PaintPatchData::Apply, Nevrax): withEPD_HASDATAit replaces the incoming mesh WHOLESALE from its storedfinalPatch/rfinalPatch, exactly like Edit Patch — so the topmost stored stream is authoritative in the host too, whatever its class. Lower slots and the base stay untouched. -
Pure transform layer (
nelpatch/patch_topo):topoDeletePatchesmutates the decoded pm+rp(+mapper) structs and reports old→new maps. Sweep rule: an element dies iff it is used by a deleted patch and by no survivor; untouched elements are never visited. The rp side is theDeleteAndSweepport (bind groups touching dead elements release whole; per-patch paint records travel VERBATIM with their surviving patch — the paint-survival contract at the data level). Refuses non-empty hook tables (no corpus case exists to validate a remap against). -
Sequencing invariant: writeBack (flush pending paint into carrier bytes) BEFORE
the storage mutation, then
rebuildWorkingSet(skipWriteBack=true)— the rebuild's own write-back would re-encode the stale pre-op pristine over the transformed blob. The flag exists for exactly this caller. -
Topology undo (same day): Kind 6 raw snapshots. The op captures the decoded
target-stream structs pre and post transform (post-writeBack, so flushed paint is
part of the restored state); the record lands AFTER a successful rebuild via
opTopoStroke, andrebuildWorkingSet(keepUndo=true)+CPaintCore::init(keepUndo)preserve the stacks across the re-init — sound because the zone set is unchanged and the Kind 6 record at the top restores older records' index space before they replay (session open/close keeps clearing, their records reference zones that may vanish). Undo/redo re-encode the matching side through the corpus-proven codecs and settopoRestorePending; every undo surface (U/I keys, painterscriptundo/redoop lines) funnels throughzpHandleTopoRestorePending, which rebuilds once with skipWriteBack+keepUndo — the replay bypassing this was found the hard way (the luapainter.undogoes through the op-line executor, not the key handler). Proven: delete → undo → redo → undo → save is BYTE-IDENTICAL to the null-edit baseline on both the base-stream and the modifier+mapper fixtures. The fix that made it exact: fresh chunks created by the encoder inherit their surviving siblings' 64-bit header width (applyChunkWidth/findChunkWidth) — a 32-bit default inside a 64-bit stream shifted every later byte by 8 per chunk. - Gates:
e2e/m41_topo_delete.sh— paint travels through the index shift (in-session and across save+reopen), mapper files keep eval positions bit-stable, bind targets release exactly their records, refusals leave topology alone, and every saved edited file passes--pm-modify-save-test(our output is byte-round-trip encoder input). - Script surface:
painter.deletePatchSelection()+ read accesspainter.patchCount/painter.tileAt; panel Delete button at patch level. Detach + Attach LANDED 2026-07-30 — the file-level pair; the topology op list is closed: -
Detach (
zpDetachPatchSelection, patch level, one zone at a time): one file carries one editable zone and the FILE is the brick identity, so the legacy "detach as a new object" maps to "detach as a new file". The new brick is the current file with the complement deleted, written through the atomic copy-save; the source then loses the selection through the normal delete flow — both passes are the corpus-proven delete transform, so paint survives on BOTH sides. The new file is NOT opened into the session (zone set unchanged → the source-side delete stays Kind 6 UNDOABLE; the brick stays on disk across undo — a save is not undone). Names collision-bump<source>-det,-det2, …;findWorldZonere-lists on a miss so the fresh brick opens from the board immediately. Gate m45: markers travel to compacted indices, detach source == plain delete BYTE FOR BYTE, undo byte-identity on both write targets, refusals create nothing. -
Attach (
zpAttachZone(target, src), script-level; a panel pick mode waits for cursor art): the source zone's mesh appends onto the target's write-target stream reoriented through the two DISPLAY transforms (world positions keep, mirrored placements refuse — winding would flip); positions come from the source's EVAL mirror (mapper-applied), paint/binds from its freshly-flushed STORED stream, and the per-patch paint travels verbatim (tiles live in the patch frame). The source FILE is saved and closed — the brick on disk keeps everything, nothing is lost. The close clears undo (zone-set change); the attach lands as the fresh stack's first Kind 6 stroke, so undo rolls the TARGET back while the source just stays closed. The ANCHOR-CELL fixup is load-bearing: board placement re-derives from the authored footprint origin every rebuild, and the merged geometry extends that corner — the op movesCellX/CellYby the whole-cell delta (and the snapshot replays it both ways) so the zone stays put in the world. Gate m46: pending source paint travels, both sides keep world positions, undo byte-identical, instance/self refusals. -
Found by detach's mid-op copy-save, fixed first (own commit): editing after an
in-session save was BROKEN —
saveWholeFile's clean/build/disown cycle left the live scene disowned (RklPatch claimed runs cleared), so the next carrier write-back failed ("setRPatch failed"). The save now re-parses the scene right after serializing (typed instances are created at serial time and survive the cycle, so session pointers stay valid). Gate m31-5: paint/save/paint/save == single-save session byte for byte.
-
Write target settled: the TOPMOST edit-class modifier slot carrying an RFINALPATCH
0x4001, else the base RklPatch — the same slot the tile-carrier and geometry policies
pick. Settled by reading the painter modifier's own eval (
-
The 151 topology-edited files are the semantic reference corpus for op behaviour.
-
Still open from the earlier gap list (design, not research):
undo raw snapshots(DONE — Kind 6, see the first-op entry above),painted-state migration across shifted patch indices(DONE — the per-patch records travel through the transform's remap),the display re-evaluation path(DONE —rebuildWorkingSetwith the skipWriteBack/keepUndo flags). Remaining ops:subdivide,add quad(add TRI deferred — the zone corpus is all quads),weld,detach/attach,turn CCW/CW— the op list is CLOSED (2026-07-30).Add Quad + Weld LANDED 2026-07-29 — together they close the practical authoring loop, gated end to end as one story:
-
Add Quad (
topoAddQuads, edge level, open edges only): grows one quad per selected open edge, mirroring the owner across it (far corners = point reflections through the shared corners; far tangents = the shared curve translated) — a C1-ish seed the artist then places. Shared side reuses the open edge record (it gains its second patch); the new patch takes the owner's tile ORDERS with EMPTY tiles and white colors — fresh paintable surface. Refuses shared edges and bind-target edges (growing one would bury the T-junction — the m44 refusal scene proves both on the ilot_croix fixtures). -
Weld (
topoWeldVerts, vertex level, threshold): TARGET-WELD shape — clusters within the threshold merge onto their lowest member, which KEEPS ITS OWN position. No position is written at all, which makes the op mapper-safe by construction (a mapper-driven vertex's stored position is dead bytes; writing it would silently not show). When a merge makes two OPEN edges coincide they FUSE — the stitch: the surviving edge keeps its tangents and the other side's patch rewires its edge AND tangent slots to them, so both sides render one curve. Refuses bound cluster members, three-patch edges, and patch-degenerating merges. -
THE LOOP GATE (
e2e/m44_topo_add_weld.sh): delete a patch (hole) → add a quad on a rim edge (the mirror seed lands roughly in the hole; on a regular grid one far corner lands EXACTLY on the opposite rim) → move the two fresh corners onto the rim corners → weld at 0.01 — the zone is whole again, patch AND vertex counts back to the originals, the stitched file round-trips the encoder, and the five-stroke undo chain (delete, add, move ×2, weld) restores the null-edit baseline byte for byte. -
A real latent bug found and fixed on the way: the RklPatch claimed list ALIASES
entries of the source chunk list; structural edits only touched
m_Claimed, leaving the source list with dangling pointers after deletions and missing containers after additions (a later Tier A geometry write could not find a freshly added vertex).setPatchMeshnow rebuilds the aliased run in place — and the first fix attempt taught the classic lesson again: an insert-position iterator captured at the first list erase is invalidated by the later ones; track it after EVERY erase. -
painter.addQuadPatchSelection()/painter.weldPatchSelection(threshold)/painter.vertexCount(zone); panel Add (edge level) and Weld (vertex level, legacy 0.1 default) buttons. Topo-op refusal reasons now print to stdout for the gates.
Subdivide LANDED 2026-07-29 (
topoSubdividePatches, own TUnelpatch/patch_topo_subdiv.cpp) — the paint-inheritance headline, delivered:- Exact bicubic split: de Casteljau at 0.5 both directions over the 4×4 control grid (long-double midpoint cascades), so the surface is unchanged — the screenshot shows no seam and the split IS the geometry Max would derive.
- Children keep the parent's ring orientation (each child's ring starts at its sub-domain origin corner), which is the load-bearing choice: every child's grid aligns with the parent's, so the painted tiles copy by plain quadrant translation — no rotation, no layer transform. Child NbTiles = parent − 1; colors copy with the midlines duplicated; outer edge flags follow their parent edge. The m43 marker gate pins all four quadrants: corner markers land exactly at the children's matching corners, across save+reopen.
- T-junctions by construction: an edge shared with an UNSELECTED patch is not split — the neighbor keeps the whole original edge, the children ride two new half edges, and the midpoint binds BIND_SINGLE (primary = itself) onto the neighbor's slot. The midpoint IS the curve's bindWhere 0.5 point, so the bind refresh derives exactly it. An edge shared between two SELECTED patches truly splits — no bind.
- Slot reuse discipline: a split edge reuses its record for the first half and its tangent slots for the outer controls, so vec owners never change; parent interior slots go to child Q(0,0); the mapper needs NO rewrite (pure addition: input slots keep, added outputs stay unmapped) — the m43 mapper scene shows vertex 0's eval bit-stable.
- Refuses: Max 3 streams, hooks, map-channel meshes (child TVPatch assignment needs a design pass), tile order 1 (cannot halve), bound corners, and patches that are bind TARGETS (splitting the junction would strand its bound vertices) — the m43 refusal scene subdivides ilot_croix patch 229 and asserts nothing changed.
- Gates
e2e/m43_topo_subdivide.sh: quadrant markers, 3 exact T-binds with record shape asserted, save+reopen persistence, encoder round-trip of the subdivided file, subdivide→undo byte-identical, the adjacent-pair scene (+6 patches, 4 outer binds, shared edge unbound), the modifier+mapper scene, and the refusal. -
painter.subdividePatchSelection()+painter.rawTile(the raw-record negative control is now scriptable); panel Subdiv button at patch level.
Turn CCW/CW LANDED 2026-07-29 (
topoTurnPatches, the legacyTurnPatchport through the shared topo-op skeleton — the second op cost a fraction of the first). Per selected quad patch: the internal rings rotate (V/Interior/Edge by one, Vec by two), the tile grid transposes with its tessellation orders swapped and every layer rotated a quarter CCW, colors follow on the (+1) lattice, and bind records onto a turned patch decrement their edge slot; CW is three CCW (the legacy rule — one code path, provably its own inverse). Two deliberate corrections over legacy, both keeping data attached to the same GEOMETRIC edge/corner: the no-smooth edge flags rotate with the edge ring (legacy silently moved smoothing breaks to different edges on turn) and the map-channel TVPatch entries rotate with their corner/handle/interior groups. Gatese2e/m42_topo_turn.sh: paint follows the frame (the (u,v) tile lands at (OrderT−1−v, u)), FOUR turns save byte-identical to the baseline (bijection proof, no undo involved), the seven-stroke undo chain is byte-identical, binds follow the ring (edge 0 → 3 → 0 on the ilot_croix fixture), the turned file round-trips the encoder, and the modifier-stream target persists. Dev hookZP_TOPO_DEBUG_GRID=1dumps the pre/post tile grids around any topo transform — it is what caught the first test's wrong assumption (patch 5 of bassin-1 is 16×16, not 8×8). -
Add Quad (
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.