-
Notifications
You must be signed in to change notification settings - Fork 114
zone_painter_editing_methods_plan
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 inpatch_topo_ops.cpp; panel buttons are the single op surface andpainter.*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).
The per-vertex tangent-continuity type, the legacy viewport right-click pair at vertex level. The biggest editing-quality gap: with a Coplanar vertex, dragging one handle keeps the vertex's other handles coplanar (the surface stays smooth through the corner); a Corner vertex frees them. Today every handle moves independently (Lock Handles is the rigid-group gesture, a different thing).
-
Data:
SPmVert.Flagscarries the type. PIN THE BIT EMPIRICALLY first (corpus probe against ring-derived behaviour + the Max patch.h convention), exactly as the PVEC_INTERIOR probe did for vecs — do not guess. Expect authored zones to carry real values. -
Write path: a value op through the topo runner (the SmGroup shape — pm-stream field
write, no topology change).
painter.setVertexCoplanar(on)over the vertex selection; scene context menu gains the checked pair at vertex level (the menu is already the home of the user pivot). -
Behaviour: the constraint applies at HANDLE-move time, in
zpApplyPatchXformand the preview (one predicate, the zpTanSelectedEffective discipline): when the moved handle's owner is Coplanar, the owner's other handles re-aim to keep all handles and the corner coplanar — the legacy rule is re-aim DIRECTION onto the plane defined by the moved handle and the corner, preserving each handle's own length. Port the exact rule fromnel_patch_libif present; otherwise implement the Max-documented behaviour and pin it in the gate with hand-computed positions. - Gate mA1: flag write bytes/undo/reload; a handle move on a Coplanar vertex moves the sibling handles onto the common plane (hand-computed), on a Corner vertex moves only itself; mapper + bound refusals unchanged.
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.
Vertex/edge/patch level in the legacy Geometry group. On dense zones, the way to work on the back of a cliff.
- Decide persistence FIRST with a probe: Max stores hidden flags in the element Flags words — check whether the corpus carries any and whether the loader honours them. If the corpus is clean, prefer SESSION-ONLY hide (a per-zone std::set keyed like the selections) — cheaper, no byte churn, and matches how the tool treats display state (weld view, arrows). If corpus files carry hidden flags, honour and write them (value-op shape).
- Behaviour: hidden patches drop from the cage collector, the pick paths, and the overlay arrows; hidden elements refuse selection; ops on a selection ignore hidden members (they cannot be selected). The LANDSCAPE keeps rendering them — hiding is a cage/editing concept, not a rendering hole (state this; it differs from Max and is right for terrain).
-
Panel: Hide + Unhide All buttons (all levels except Tile), sub-object-scoped like
Delete.
painter.hideSelection()/unhideAll(). - Gate mA3: hidden patch is unpickable (patchClick through the real path), ops skip it, unhide restores, session-only = no byte change (or flag bytes + undo if persisted).
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.cpprefactors 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.
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: anoutlineparameter (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).
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.
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:
topoDetachElementsgrows 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:
detachToFilewith 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.
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.
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).
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.
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.
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.
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.
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.
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.
- 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.