Skip to content

zone_painter_editing_methods_plan

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

Zone painter — editing methods plan: the remaining legacy set, and the later-Max additions

Status: working plan (2026-07-30). Companion to zone_painter_patch_edit_plan.md (invariants, shipped state, gates m31–m54) and zone_painter_legacy_rollout_spec.md (the legacy panel spec). Written as the resume point for the next sessions: Part A (missing legacy methods) is the next session's work, in the order given. Part B is the queue after it.

House rules that apply to every method below, stated once:

  • Ops ride the shared topo runner (Kind 6 snapshots, writeBack-before-mutation, rebuild(skipWriteBack, keepUndo), anchor-cell fixup) unless they are pure Tier A geometry/value writes, which ride the paint core's stroke records.
  • Transforms live in nelpatch/ as pure struct ops with refusals that print for gates; session glue in patch_topo_ops.cpp; panel buttons are the single op surface and painter.* bindings alias them (the dispatch-blind-spot lesson).
  • Mapper meshes: reads through the eval mirror, writes through the Tier A rule (mapped → Delta, unmapped → stored). Bound vertices are derived — never written.
  • Every method lands with its mNN gate: counts, positions, paint survival, undo byte identity, encoder round-trip, persistence, refusals — and geometry math gets a SCULPTED fixture (the m50 lesson: uniform lattices hide arithmetic bugs).
  • Directional display claims get pinned with a rot-0 fill legend / ring-axis probes, never by eyeballing authored terrain (the m53 lesson).

Part A — the missing legacy methods (next session)

A1. Coplanar / Corner vertex continuity — DONE 2026-07-30 (gate m55)

The per-vertex tangent-continuity type, the legacy viewport right-click pair at vertex level. With a Coplanar vertex, dragging one handle keeps the vertex's other handles coplanar; a Corner vertex frees them.

The bit was pinned empirically before the op existed — a temporary --pm-flags-probe mode on pipeline_max_corpus_test (kept in the tree) swept every PatchMesh of the graphics + snowballs corpora and histogrammed all three element Flags words, cross-tabbing the VERTEX flag against ring-derived tangent coplanarity (best pair-cross plane, max deviation):

  • Vertex Flags carries ONLY the values 0 and 1 corpus-wide. Of 172,115 flags=1 vertices with 3+ handles, 172,103 measure coplanar within 1e-6 (12 in the float-noise tail at ~1e-5); flags=0 spreads to ~0.5 deviation. Bit 0 = PVERT_COPLANAR, corner = 0 — matching the host convention.
  • Bonus pins for the sections below: vec Flags only {0,1} (the interior bit, reconfirmed); patch Flags = 1 (PATCH_AUTO) on 645k patches, 0 (= MANUAL interior) on 1,073 patches across 47 converted-* desert files (real manual data for A2), and bit 1 (PATCH_HIDDEN) on only 4 patches corpus-wide — the corpus is effectively clean of hidden flags, which settles A3 session-only.

As landed:

  • Toggle: value op through the shared runner (the SmGroup shape) — zpSetVertexCoplanar / painter.setVertexCoplanar(on), no-change refusal, Kind 6 undo. Toggling moves NO geometry (deliberate divergence from the host, which planarizes on switch: the corpus flag is authored data, and the constraint does the work at edit time). Scene context menu gains the checked Coplanar/Corner pair at vertex level (zp_vertex_type; tri-state pushed = whole selection carries the type).
  • Constraint: at HANDLE-move time only (a moving corner takes its handles rigidly — a rigid motion keeps the plane). One shared helper (zpCoplanarSiblingReaim + zpVertexTangents in patch_edit_ops, exposed via patch_edit_internal.h) serves the commit (zpApplyPatchXform appends re-aim writes to the same stroke) and the drag preview (zpTanOffset's tail branch) — the parity discipline. The rule: new plane = minimal tilt of the current best pair-cross plane that contains the moved handle's direction (two+ moved dirs span it outright, sign-aligned); siblings re-aim into the plane keeping their own length; negligible (<1e-5 m) or degenerate re-aims write nothing. Bound owners are excluded wholesale (their handles refuse to move anyway).
  • Reads: painter.patchVertFlags(zone, v) and painter.patchCornerVert(zone, p, c) (the RING corner as BaseVertices — the stored edge records' V1/V2 order is arbitrary, a trap the first gate draft walked into).
  • Gate m55: toggle = exactly 1 byte, undo byte-identity, reopen persistence, encoder round-trip, no-change refusal writes nothing; the constrained move asserted against an INDEPENDENT Lua computation of the rule from pre-move positions (sibling re-aimed onto the plane, perpendicular sibling held, lengths kept), one undo restores the whole stroke; corner vertex frees the handles (exactly 1 element written); modifier-stream target line asserted; bound refusal unchanged.

A2. Auto / Manual interior

The second context-menu pair, patch level. Corpus patches are AUTO (PM_PATCH_AUTO); Manual makes the four interior controls real, editable points.

  • Toggle: pm-stream Flags bit write through the runner (value-op shape). Switching auto → manual BAKES the current derived interiors into the stored vecs (they become authored); manual → auto abandons them (re-derived at eval) — state both directions in the op's status line.
  • Editing: interiors join the handle machinery — drawn (violet, to keep the green/white/black/red language clean) and pickable ONLY for selected corners of MANUAL patches, moved through the existing Tier A vec path (they are vecs; the write machinery needs zero changes — the interior identity work from m52's review already gave them their authored Vert=-1/PVEC_INTERIOR shape).
  • Gate mA2: toggle bytes/undo; bake-on-manual matches the auto derivation bit-for-bit; a manual interior moves and persists; auto patches refuse interior picks.

A3. Hide / Unhide All — DONE 2026-07-30 (gate m56)

Vertex/edge/patch level in the legacy Geometry group. On dense zones, the way to work on the back of a cliff.

  • SESSION-ONLY, by the A1 probe's verdict: PATCH_HIDDEN (patch Flags bit 1) is carried by exactly 4 patches over the whole corpus and no vertex ever carries PVERT_HIDDEN — the on-disk bits are preserved verbatim and never interpreted; the hide set is g_PatchHidden, patch-keyed like the selections. Cleared by every working-set rebuild (topology ops shift indices, open/close re-bases zone ids — a stale entry would hide the WRONG patch), surviving plain mode switches (those rebuild the landscape, not the working set).
  • As landed: hide resolves the current level's selection to the PATCHES it touches (vertex level = patches using a selected vertex, edge level = patches of selected edges); the selection then clears — hidden elements cannot stay selected (the orphaned-tangent rule wholesale). Hidden patches drop from the cage collector, the markers, the handle draw, all four pick paths and the overlay arrows; selection setters refuse hidden targets (a vertex is hidden iff ALL its patches are — a rim vertex shared with a visible patch stays workable, which is exactly the work-on-the-back-of-a-cliff case). The LANDSCAPE keeps rendering hidden patches — hiding is a cage/editing concept, not a rendering hole. NOT undoable (display state, like the weld view and the arrows).
  • Panel: Hide + Unhide All row in the Patch rollout (Hide frozen without a level-scoped selection; Unhide All frozen while nothing is hidden). painter.hideSelection() / unhideAll() / patchHidden(zone, p).
  • Gate m56: the REAL pick path (patchClick at the patch's projected centre, with a positive control before the hide) no longer finds a hidden patch; scripted select refuses; unhide restores the pick; hide+save is byte-identical to null-edit; the fully-hidden vertex refuses while the rim vertex selects; a topology rebuild clears the set.

A4. Edge-level Subdivide + Propagate

The spec enables Subdivide at Edge level; Propagate carries the split across neighbours for continuity. This also lays the base for B4 (the directional loop split).

  • Edge subdivide (no propagate): split the selected edges at 0.5 — each ADJACENT patch splits ONCE, along the parameter that crosses the selected edge (a 1→2 split, not our 1→4): the de Casteljau machinery from patch_topo_subdiv.cpp refactors into a single-axis split helper (split rows only, not rows-then-columns). A patch with TWO selected opposite edges splits once (both halves get split edges); with two ADJACENT selected edges it splits both axes (degenerates to the existing 1→4). Tiles: the split axis halves its order per child, corner-anchored copy per half (the m52 tess shape); the other axis untouched.
  • Propagate (checkbox, panel + painter.setSubdividePropagate(on)): the 1→2 split walks across the strip — each split edge's OPPOSITE edge in the adjacent patch joins the set, until the walk closes (loop) or exits an open border. Without propagate, non-split neighbours take the T-junction bind, exactly as the 1→4 op does today.
  • Gate mA4: single-axis counts (+1 patch per split patch), the split IS the curve (sculpted fixture, midpoint checks), tiles corner-anchored per half, propagate walks a full ring on the bassin fixture and stops at the open border, T-binds where propagate is off, undo/round-trip/mapper.

A5. Bevel (extrude + outline)

Sloped walls: extrude, then OUTLINE the top — scale the island's boundary ring in its plane about the island centroid (negative outline = the classic tapered cliff).

  • Composes on topoExtrudePatches: an outline parameter (world metres of in/out per boundary vertex along the ring's outward direction in the island plane). The wall quads' top edges follow the outlined ring; the island's boundary verts (and their riding tangents, the ride-in-file rule) move inward/outward; interior island verts stay. Outward direction per boundary vertex = the XY normal of the boundary chain at that vertex (average of its two boundary edge normals) — pin with a gate fixture.
  • UI: the extrude dialog grows an Outline field (0 default); the legacy Bevel smoothing radios (Start/End Smooth/Linear/None) are about spline-side smoothing — SKIP for zones (walls are fresh paintable surface; smoothing is the no-smooth flag's job), note the deliberate omission.
  • Gate mA5: outlined ring at the exact in-plane offset, walls still weld-clean to both rings, tiling rules hold, undo/round-trip, the extrude gate's scenes re-run with outline 0 (bevel with no outline == extrude, byte for byte).

A6. Extrude "Normal: Local"

The legacy Group/Local radio: extrude along the selection's own normal instead of world Z. For slopes and walls (extruding a ledge out of a cliff face we just built).

  • The extrude vector becomes height * n̂ where n̂ = area-weighted average normal of the selected patches' eval surfaces (Group), or per-island once multi-island selections exist (keep Group semantics now — one selection, one normal). Plumb through the existing (dx, dy, dz) — the transform already takes a full vector; only the glue changes (painter.extrudePatchSelection(h [, mode]), dialog radio, the shift-drag stays Z-constrained — the drag gesture is vertical by design).
  • Gate mA6: extrude a wall patch (from an mA5/m54 fixture) along Local — the island moves along the wall's normal, not Z; Z mode unchanged byte-for-byte on flat fixtures.

A7. Detach with Copy

The legacy Detach dialog's Copy checkbox: duplicate the selection as a new island (or a new brick file) WITHOUT removing it from the source.

  • Element form: topoDetachElements grows a copy mode — instead of rewiring the selection onto duplicates, it clones the selection's patches (and their private elements) as a coincident island; the original stays untouched. Paint copies verbatim.
  • File form: detachToFile with copy = skip pass 2 (the source keeps the selection) — the new brick is written, nothing else changes; NOT undoable-needed (no session mutation at all — the save is not undone anyway).
  • Panel: a Copy checkbox row beside Detach. painter.detachPatchSelection([copy]) / detachToFile(name [, copy]).
  • Gate mA7: copy-element leaves the source byte-identical except the appended island; weld-back of the copy onto the original refuses (coincident non-boundary) or merges cleanly (decide + pin); copy-to-file leaves the session byte-identical to null-edit.

A8. Reset paint

The painter's ResetPatch (paint.cpp): clear a zone's paint wholesale — default tiles, white colors, displace 0. One click on dense misuse beats scripting fillTile loops.

  • Pure paint-core op over every patch of the target zone (fillTile(-1) + fillColor white
    • fillDisplace 0 through the existing ops, ONE stroke). Danger-guarded: the panel button confirms through a modal (the save-dialog pattern); scripts call it bare.
  • Gate mA8: painted fixture resets to the empty state, one undo restores byte-exact, reset+save == a never-painted null-edit save where the fixture authored no paint (assert on a synthetic-painted workspace, not on authored zones).

A-order: A1 → A3 → A4 → A5+A6 (one arc) → A2 → A7 → A8. A1 first (touches every handle edit; smallest data risk once the bit is pinned), A3 second (pure ergonomics, independent), A4 before A5 (both refactor subdivision/extrusion internals). The deferred set (named selections, bind drag mode, attach pick mode, Tile level) stays deferred on its stated dependencies; Add Tri / View-Render Steps / Tiled-mode group / Keep Mapping / Material ID remain CLOSED as not-applicable.


Part B — the later-Max additions

B1. Sculpt mode: the tile-level ghost + patch fitting (Kaetemi's design)

Paint deformation is unusable at coarse vertex level — a control point bends 32 m of surface, the brush thinks in 2 m. The ghost fixes the granularity mismatch: the brush edits an invisible tile-level displacement field, and the PATCHES ARE FITTED to it.

  • The ghost: per-patch grids of 3D DISPLACEMENT VECTORS on the tile-corner lattice ((OrderS+1)×(OrderT+1), the color lattice), seam samples aliased coherently across patches exactly as color painting already does. NOT a heightmap — displacements are vectors, so cliff walls and any patch orientation sculpt the same way. Session-scoped to sculpt mode; the mode also snapshots the BASE surface (eval at entry) the displacements are relative to. The ghost PERSISTS across strokes — successive strokes keep targeting the accumulated fine surface, never a fit-of-fit (that smearing is the whole failure mode of naive approaches).
  • Brushes: Push/Pull (displace along the sample's surface normal; world-Z as an option), Relax (average neighbouring samples' displaced positions — operates on the ghost, so it is cheap and resolution-true). Radius/hardness/opacity ride the color brush's existing stroke UX; falloff measured in world distance to the sample's DISPLACED position (walls sculpt from the side).
  • The fit (the load-bearing part): after each stroke, LEAST-SQUARES fit the affected patches' controls to base+ghost over the WHOLE domain — corner-sampling interpolation is wrong (it leaves the between-corner surface unconstrained: the lumpiness). Solve GLOBALLY over the region's UNIQUE controls (corners and tangents are shared through the edge records; a per-patch fit cracks the seams): unknowns = unpinned corner verts
    • tangent vecs (3 coords, same SPD system); AUTO interiors fold in as linear functions of corners+tangents (not unknowns; manual interiors are); pinned = controls of untouched patches, bound vertices, locked-border vertices (the ligo contract). Small sparse SPD system — Gauss–Seidel or a dense solve over the mapped unknowns. Untouched samples carry zero displacement and anchor the fit to the base — partial strokes do not disturb the rest of a patch.
  • Writes/undo: the fitted control deltas commit as ONE Tier A stroke (mapper-aware for free); each stroke's record carries its GHOST DELTA (sparse: the samples it touched) so undo/redo restores the ghost with full fidelity — the undo stack preserves the ghost, not just the controls. Topology ops / working-set rebuilds drop the mode (base invalid).
  • Display: the FITTED surface, through the ordinary live push — the artist sees what will save, never chases detail patches cannot hold. Optional later: a residual overlay.
  • Gate mB1: sculpt a known bump on a sculpted fixture, assert the fitted surface's residual against base+ghost under a bound; seams C0 across the fit region (shared corners single-valued by construction — assert eval agreement); locked borders hold; stroke undo restores controls AND ghost; the fit is deterministic (same script, same bytes).

B2. Soft selection

Falloff-weighted transforms: move/rotate/scale scale each element's delta by a falloff weight from the selection. Pairs with the existing per-element delta machinery (the weight multiplies the per-element world delta before the object-space conversion). Falloff radius on the panel; weights by world distance to the selection's nearest member. The on-disk soft-selection params chunk (0x3440) stays PRESERVED verbatim — our soft selection is session state, not the Max chunk (note the divergence). Preview must use the same weights (zpVertOffset/zpTanOffset gain the weight through one shared predicate — the parity discipline). Gate: weighted move positions hand-computed, weight 0 elements byte-untouched.

B3. Make Planar / flatten to height

Set the selection's Z to a plane: the pivot's Z (default), a typed height, or the best-fit plane (full Make Planar). Trivial per-element Tier A deltas; handles ride. Panel: Flatten button + height field beside the tess block; painter.flattenSelection([z]). Gate: plateau positions exact, tangents ride, undo byte-exact.

B4. Directional loop split

The SwiftLoop/Connect equivalent on the quad grid: a single-axis 1→2 split propagated along the patch strip — add a row of patches along a cliff line without densifying everything. IS mA4's propagate with the walk defaulted on and a pick gesture: click an edge, the loop previews (overlay highlight through the wireframe pass), click commits. Builds directly on the mA4 machinery; the gesture waits for mA4.

B5. Bridge

Build patches connecting two OPEN edge chains (two rims, two islands): per matched edge pair, one quad — the extrude wall builder generalized to arbitrary rings (it already handles chains, shared vertical edges, orientation against both sides). Matching rule: equal-length chains pair in order; refuse mismatched lengths (state it — no interpolated N:M bridging). Tiling: along-chain order matches each side where they agree, else the max; across = distance-derived like extrude walls. Gate: bridge two detached islands' facing rims, weld-clean seams, paint empty, undo/round-trip.

B6. Snap-to-grid moves

Snap the committed move (and the drag preview, parity rule) to the 2 m tile lattice or the 160 m cell lattice — the ligo discipline for border work. A toggle + step choice on the panel; applies to the world delta before per-element conversion. Gate: snapped move lands exactly, unsnapped byte-path unchanged when off.

B7. Noise

Seeded procedural displacement on the selection (the old Noise modifier's role): painter.noiseSelection(seed, amplitude, scale [, octaves]), Z-only default. The seed makes it recorder-replayable (the painter.seed precedent). Per-element Tier A deltas; bound/border rules as ever. Gate: same seed same bytes, different seed different, amplitude 0 refuses.

B-order: B3 (trivial, immediately useful) → B2 → B1 (the big one; B2's falloff plumbing feeds it) → B4 (on mA4) → B5 → B6 → B7.


Part C — cross-cutting notes

  • New gates continue the mNN series (mA1.. map to m55.. in file names when built; the letters here are planning names only).
  • Every value-op (A1, A2 toggle, SmGroup precedent) follows the m52 shape: runner ride, no-change refusal, modifier-target assertion.
  • The wireframe now has TWO phases (depth-tested 3D + screen-space) — new overlays pick their phase deliberately: world annotations (loop preview, bridge preview, sculpt residual) go 3D; pixel-true widgets stay 2D.
  • The panel keeps growing: when the Patch rollout crowds, the level-swapped properties block (the legacy third-rollout pattern, already specced) is the pressure valve.

Clone this wiki locally