code wiki / (root) / nx_texpaint_lib.nx

nx_texpaint_lib.nx source

↩ module page · 684 lines · 38004 B

1// nx_texpaint_lib.nx -- TEXTURE PAINTING: a brush stroke placed on the SURFACE that lands in the correct 2// texel of the baked PBR map set, and that does not TEAR where the UV parameterization is cut. 3// /compare/dcc DC7, and the board says why it matters: texture-painting is the ONE axis on that board where 4// all four rivals are Best -- Blender paints directly onto the mesh, ZBrush PolyPaint needs no UVs assigned 5// in advance, Maya has the 3D Paint Tool, and ArmorPaint exists for precisely this. 6// 7// CHECK-BEFORE-BUILD, MEASURED NOT ASSUMED. nx_capsearch over 6892 organs (1375 tools + 5517 libs, 8// corpus_complete=1) returned a glyph STROKE PEN, four 2D FRAMEBUFFER painters and one mesh SCULPT brush -- 9// nothing that writes a texel of a texture. nx_absent then returned tp_stroke ABSENT-PROVEN at 10// coverage_complete=1 corpus_complete=1 over buildroot/runtime. The estate BAKES maps (ntb_bake) and 11// GENERATES a whole PBR set per genome (ntb_bake_pbr); it has never PAINTED one. 12// 13// THE ATLAS CONTRACT IS READ OUT OF THE INCUMBENTS, NEVER INVENTED. Two files own it and both were read: 14// nx_nxa_texc_lib.nx -- the TEXC section. Payload words [nv, stride=3, grid g, reserved] then per vertex 15// [u q16 0..65535][v q16 0..65535][owning joint]. Joint j owns ONE TILE of a g x g 16// grid, tile width tw = 65536/g in UV units, so charts from different bones are 17// DISJOINT BY CONSTRUCTION. The unwrap is CYLINDRICAL around the bone: u is the 18// angle (a full circle mapped onto the tile) and v is the clamped position along 19// the parent->joint segment. 20// nx_nxa_texbake_lib.nx -- the atlas itself: res*res*3 u8 RGB row-major, the TM_IMG contract, and a map 21// set of albedo, specular, gloss and tangent-space normal. 22// EVERYTHING BELOW FOLLOWS FROM THOSE TWO FACTS. In particular the map COUNT is a parameter of the map set 23// rather than a constant in this file: a second copy of NTB_NMAP here would be a duplicate ruler that drifts 24// the day the PBR set grows a fifth map. 25// 26// WHERE THE SEAM IS, AND WHY THE NAIVE PAINTER TEARS. Because the unwrap is cylindrical, EVERY chart carries 27// its own wrap seam: the texel column at the left edge of a tile and the column at the right edge are the 28// SAME PLACE ON THE BODY, cut apart so the cylinder could be laid flat. A brush disc centred near that cut 29// covers surface on both sides of it, but in the atlas the far side is a whole tile-width away. A painter 30// that clips the disc to the tile rectangle -- which is what a painter must do, or paint leaks onto another 31// bone -- writes one side and drops the other, and the asset renders with a hard-edged half-stroke: THE 32// TEAR. This is the defect that makes naive 3D painting unusable and it is the reason DC7 exists. 33// 34// THE FIX IS A METRIC, NOT A HEURISTIC. tp_stroke_core paints with the PLANAR metric and therefore tears -- 35// it is shipped, callable and named, because a negative control you can still run is worth more than a 36// paragraph saying you once deleted the fix and watched a test fail. tp_seam_bleed then writes exactly the 37// texels the planar pass could not reach: those whose CYLINDRICAL distance to the centre is inside the brush 38// while their planar distance is not. The two sets are disjoint by that definition, so no texel is ever 39// written twice, and their union is the full disc. tp_stroke is the composition of the two. 40// u WRAPS AND v DOES NOT, and that asymmetry is the geometry rather than a preference: u is an angle around 41// the bone and its two ends ARE identified; v is a position along the bone and its ends are the joint and 42// its parent. Wrapping v would paint the shoulder from the wrist. 43// 44// THE BLEED MARGIN IS DERIVED FROM THE BRUSH RADIUS AND THE ATLAS RESOLUTION, WITH A REASON ON BOTH TERMS: 45// M = min(r, W/2), W = the chart's texel width = res/g, resolved from the atlas resolution and the TEXC 46// grid rather than declared anywhere. 47// r -- a disc of radius r spills at most r texels past the cut, so a window wider than r examines 48// texels that cannot possibly be reached. This term makes the search cost proportional to the 49// brush and not to the chart. 50// W/2 -- past half the chart width the wrapped image of the disc would overlap its own planar image, and 51// the same texel would take the stroke twice: a double-darkened band exactly AT the seam, which is 52// the second-order form of the very defect this function removes. 53// The margin is not decoration: tp_stroke REFUSES when r exceeds it, by name (TP_E_RADIUS_CHART), rather 54// than clipping the brush -- a clipped brush paints a different stroke than the one that was asked for and 55// the difference is invisible in the result. So the min() is live in both directions: it selects r for every 56// brush that fits, and it selects W/2 exactly when the stroke must be refused. 57// 58// HISTORY IS COMPOSED, NEVER RE-INVENTED. Every stroke is ONE operation on nx_editstack_lib -- the estate's 59// operation stack, whose undo is a REPLAY FROM THE BASE rather than an inverse operation, which is what makes 60// the bit-exact claim checkable. There is no stroke list in this file. The op record carries four argument 61// words; a stroke needs seven numbers, so three packed words carry them and every packing multiplier is a 62// SPAN DERIVED FROM A DECLARED DOMAIN (tp_value_span, tp_strength_span, the channel-mask span) rather than a 63// chosen shift. es_apply's SET writes the first of those words into the live cell, so the live cell vector 64// carries a per-stroke change detector for the map-channel-value dimension and NOTHING MORE; the 65// authoritative record is the operation, read back through es_op_field, and the bit-exact claim is made over 66// the RENDERED MAP BYTES via tp_maps_digest. Saying which digest carries the claim is the whole point: a 67// digest over a projection of the state would acquit a change it cannot see. 68// 69// COST IS DECLARED. tp_stroke is O(r^2) texels; tp_render is O(strokes * r^2) because it replays from the 70// base, which is the price the operation stack charges for never drifting, paid deliberately. 71// ALL INTEGER, so replay is bit-exact by construction and no float ever enters. 72// 100% sovereign. No hardware writes (Rule 26). license_tier: ORIGINAL 73import "nx_syscalls.nx" 74import "nx_vecmath.nx" 75import "nx_editstack_lib.nx" 76 77// ---- declared domains. Every packing multiplier below is a SPAN computed from one of these. ---- 78const TP_I64_BYTES: i64 = 8 79const TP_CHAN: i64 = 3 // RGB. The TM_IMG contract read from nx_nxa_texbake_lib: res*res*3 u8. 80const TP_CH_SPAN: i64 = 8 // 2^TP_CHAN: the number of distinct channel masks. Guarded by a tooth 81 // that recomputes it by doubling, so it cannot drift from TP_CHAN. 82const TP_VALUE_MAX: i64 = 255 // the u8 channel range of the atlas element type 83const TP_ONE: i64 = 1000 // permil, the estate's fixed-point unit for a normalised 0..1 quantity 84const TP_STRENGTH_MAX: i64 = 1000 // == TP_ONE, so full strength reaches the target value EXACTLY 85const TP_BARY_ONE: i64 = 1000 // barycentric coordinates in permil; the triple must sum to this 86const TP_UV_SPAN: i64 = 65536 // NT_Q16 from nx_nxa_texc_lib -- the codomain of a TEXC u or v word 87const TP_HALF: i64 = 2 // the divisor in W/2; named so the margin derivation reads as arithmetic 88const TP_BANDS: i64 = 2 // a cylinder is cut ONCE, which leaves TWO edges in the atlas -- so the 89 // seam pass sweeps exactly two bands. Deliberately NOT spelled TP_HALF: 90 // one constant serving two unrelated purposes can never be tuned for 91 // either, and these two happen to share a value only by coincidence. 92const TP_SMOOTH_A: i64 = 3 // the 3 in S(t) = 3t^2 - 2t^3 93const TP_SMOOTH_B: i64 = 2 // the 2 in S(t) = 3t^2 - 2t^3 94const TP_TEXEL_XY: i64 = 2 // a texel address is a pair 95const TP_TRI_V: i64 = 3 // a triangle names three vertices 96 97// ---- map-set arena, BYTE layout ---- 98// [0 .. TP_HDR*8) header, i64 words 99// [TP_HDR*8 ..) nmaps consecutive maps, each res*res*TP_CHAN bytes 100// The header is a whole number of i64 words so the u8 payload starts 8-byte aligned. 101const TP_H_RES: i64 = 0 102const TP_H_NMAPS: i64 = 1 103const TP_H_GRID: i64 = 2 104const TP_H_ERR: i64 = 3 105const TP_HDR: i64 = 4 106 107// stroke metric selector. Named rather than a bare 0/1 because the whole point of this file is that these 108// two metrics give different answers at a seam. 109const TP_M_PLANAR: i64 = 0 110const TP_M_WRAPONLY: i64 = 1 111 112// EVERY REFUSAL IS ITS OWN NAME. A guard that returns one code for nine causes sends every reader at the 113// wrong one; a compound assertion that will not name its failing conjunct is a false-alarm generator. 114const TP_OK: i64 = 0 115const TP_E_ARGS: i64 = 0 - 1 116const TP_E_RES: i64 = 0 - 2 117const TP_E_GRID: i64 = 0 - 3 118const TP_E_MAP: i64 = 0 - 4 119const TP_E_TEXEL: i64 = 0 - 5 120const TP_E_BARY: i64 = 0 - 6 121const TP_E_BARYSUM: i64 = 0 - 7 122const TP_E_TRI: i64 = 0 - 8 123const TP_E_VERT: i64 = 0 - 9 124const TP_E_RADIUS: i64 = 0 - 10 125const TP_E_RADIUS_CHART: i64 = 0 - 11 126const TP_E_STRENGTH: i64 = 0 - 12 127const TP_E_VALUE: i64 = 0 - 13 128const TP_E_CHMASK: i64 = 0 - 14 129const TP_E_CHART: i64 = 0 - 15 130const TP_E_GEOM: i64 = 0 - 16 131const TP_E_FULL: i64 = 0 - 17 132const TP_E_PUSH: i64 = 0 - 18 133const TP_E_MODE: i64 = 0 - 19 134 135func tp_err_name(e: i64) -> *u8 { 136 if e == TP_OK { return "PAINTED" as *u8 } 137 if e == TP_E_ARGS { return "REFUSED-BAD-ARGUMENTS" as *u8 } 138 if e == TP_E_RES { return "REFUSED-ATLAS-RESOLUTION-NOT-ADDRESSABLE-IN-Q16-UV" as *u8 } 139 if e == TP_E_GRID { return "REFUSED-CHART-GRID-NARROWER-THAN-ONE-TEXEL" as *u8 } 140 if e == TP_E_MAP { return "REFUSED-MAP-ID-OUTSIDE-THE-MAP-SET" as *u8 } 141 if e == TP_E_TEXEL { return "REFUSED-TEXEL-OUTSIDE-THE-ATLAS" as *u8 } 142 if e == TP_E_BARY { return "REFUSED-BARYCENTRIC-OUTSIDE-THE-TRIANGLE" as *u8 } 143 if e == TP_E_BARYSUM { return "REFUSED-BARYCENTRIC-DOES-NOT-SUM-TO-UNITY" as *u8 } 144 if e == TP_E_TRI { return "REFUSED-TRIANGLE-INDEX-OUT-OF-RANGE" as *u8 } 145 if e == TP_E_VERT { return "REFUSED-TRIANGLE-NAMES-A-VERTEX-OUT-OF-RANGE" as *u8 } 146 if e == TP_E_RADIUS { return "REFUSED-BRUSH-RADIUS-NOT-POSITIVE" as *u8 } 147 if e == TP_E_RADIUS_CHART { return "REFUSED-BRUSH-WIDER-THAN-THE-DERIVED-SEAM-MARGIN" as *u8 } 148 if e == TP_E_STRENGTH { return "REFUSED-STRENGTH-OUTSIDE-ITS-DECLARED-RANGE" as *u8 } 149 if e == TP_E_VALUE { return "REFUSED-VALUE-OUTSIDE-THE-CHANNEL-RANGE" as *u8 } 150 if e == TP_E_CHMASK { return "REFUSED-CHANNEL-MASK-SELECTS-NOTHING" as *u8 } 151 if e == TP_E_CHART { return "REFUSED-TEXEL-BELONGS-TO-NO-CHART" as *u8 } 152 if e == TP_E_GEOM { return "REFUSED-MAP-SETS-DISAGREE-ON-GEOMETRY" as *u8 } 153 if e == TP_E_FULL { return "REFUSED-STROKE-LOG-FULL" as *u8 } 154 if e == TP_E_PUSH { return "REFUSED-OPERATION-STACK-REJECTED-A-VALIDATED-STROKE" as *u8 } 155 if e == TP_E_MODE { return "REFUSED-UNKNOWN-STROKE-METRIC" as *u8 } 156 return "REFUSED-UNCLASSIFIED" as *u8 157} 158 159// ---- spans. ONE owner each, so no packing multiplier is ever typed twice. ---- 160func tp_value_span() -> i64 { return TP_VALUE_MAX + 1 } 161func tp_strength_span() -> i64 { return TP_STRENGTH_MAX + 1 } 162// TP_CH_SPAN recomputed from TP_CHAN by doubling. The gate asserts the two agree, which is what makes the 163// constant a GUARDED duplicate rather than a silent one. 164func tp_ch_span_derived() -> i64 { 165 var s: i64 = 1 166 var i: i64 = 0 167 while i < TP_CHAN { s = s + s; i = i + 1 } 168 return s 169} 170// channel c of the mask, without a bitwise operator: divide by 2^c and read the low digit. 171func tp_ch_on(chmask: i64, c: i64) -> i64 { 172 if c < 0 { return 0 } 173 if c >= TP_CHAN { return 0 } 174 var d: i64 = 1 175 var i: i64 = 0 176 while i < c { d = d + d; i = i + 1 } 177 return chmask / d % TP_HALF 178} 179 180// ================= THE DECLARED FALLOFF CURVE ================= 181// S(t) = 3t^2 - 2t^3 on t in [0,1]: the cubic Hermite smoothstep, the UNIQUE cubic with S(0)=0, S(1)=1 and 182// S'(0)=S'(1)=0. It is the curve GLSL names smoothstep and the one a painter recognises as a soft round 183// brush, and it is named here so the brush profile is a citable curve rather than whatever the author felt 184// like. The zero derivative at BOTH ends is the property that matters: the stroke reaches full strength 185// smoothly at the centre and fades to exactly nothing at the rim, so overlapping strokes do not band. 186// t is in permil and the result is in permil. Integer truncation makes S(t) + S(TP_ONE - t) equal TP_ONE to 187// within one permil rather than exactly; that imprecision is chosen and declared rather than hidden. 188func tp_smoothstep(t: i64) -> i64 { 189 if t <= 0 { return 0 } 190 if t >= TP_ONE { return TP_ONE } 191 return t * t * (TP_SMOOTH_A * TP_ONE - TP_SMOOTH_B * t) / (TP_ONE * TP_ONE) 192} 193// brush weight at distance d from the centre of a disc of radius r. Zero AT the rim and beyond, so the 194// written set is exactly the OPEN disc d < r and the bounded-write claim has a sharp boundary to assert. 195func tp_falloff(d: i64, r: i64) -> i64 { 196 if r <= 0 { return 0 } 197 if d < 0 { return 0 } 198 if d >= r { return 0 } 199 return tp_smoothstep((r - d) * TP_ONE / r) 200} 201 202// ================= ATLAS AND CHART GEOMETRY ================= 203func tp_hdr_bytes() -> i64 { return TP_HDR * TP_I64_BYTES } 204func tp_map_bytes(res: i64) -> i64 { return res * res * TP_CHAN } 205func tp_set_bytes(res: i64, nmaps: i64) -> i64 { return tp_hdr_bytes() + nmaps * tp_map_bytes(res) } 206 207// The geometry a map set may legally have, with the failing conjunct NAMED. 208// The resolution ceiling is DERIVED rather than picked: a TEXC u or v word is Q16, so above TP_UV_SPAN 209// texels the parameterization cannot address every texel and painting would alias by construction. The 210// memory ceiling is deliberately NOT a constant here -- the allocator reports it, because a guessed byte cap 211// is a defect generator in both directions. 212func tp_geom_ok(res: i64, nmaps: i64, grid: i64) -> i64 { 213 if res < 1 { return TP_E_RES } 214 if res > TP_UV_SPAN { return TP_E_RES } 215 if nmaps < 1 { return TP_E_ARGS } 216 if grid < 1 { return TP_E_GRID } 217 if grid > res { return TP_E_GRID } 218 return TP_OK 219} 220 221// tile width in UV units: EXACTLY nt_grid's tw = NT_Q16 / g from nx_nxa_texc_lib. Truncating, so the tiles 222// cover [0, g*tw) and the remainder of the UV square belongs to no chart -- reported, never guessed at. 223func tp_tile_uv(g: i64) -> i64 { return TP_UV_SPAN / g } 224// chart bounds along one axis, in texels, half-open [x0, x1). Uses the SAME floor conversion as 225// tp_uv_to_texel, so a chart boundary in UV space and the same boundary in texel space cannot disagree. 226func tp_chart_x0(res: i64, g: i64, t: i64) -> i64 { return t * tp_tile_uv(g) * res / TP_UV_SPAN } 227func tp_chart_x1(res: i64, g: i64, t: i64) -> i64 { return (t + 1) * tp_tile_uv(g) * res / TP_UV_SPAN } 228 229// which tile index owns texel coordinate x on one axis, or -1 for a texel no tile covers. 230// O(g) and called ONCE per stroke, never per texel; g is ceil(sqrt(joints)). 231func tp_chart_axis(res: i64, g: i64, x: i64) -> i64 { 232 if x < 0 { return 0 - 1 } 233 if x >= res { return 0 - 1 } 234 var t: i64 = 0 235 while t < g { 236 if x >= tp_chart_x0(res, g, t) { if x < tp_chart_x1(res, g, t) { return t } } 237 t = t + 1 238 } 239 return 0 - 1 240} 241func tp_chart_of_texel(res: i64, g: i64, x: i64, y: i64) -> i64 { 242 let tx: i64 = tp_chart_axis(res, g, x) 243 if tx < 0 { return 0 - 1 } 244 let ty: i64 = tp_chart_axis(res, g, y) 245 if ty < 0 { return 0 - 1 } 246 return ty * g + tx 247} 248 249// THE DERIVED SEAM MARGIN, for a chart of texel width w and a brush of radius r. See the header: r bounds the 250// spill, w/2 bounds the self-overlap, and the smaller of the two is the only window that is both sufficient 251// and non-overlapping. 252func tp_bleed_margin_w(w: i64, r: i64) -> i64 { 253 if w < 0 { return 0 } 254 if r < 0 { return 0 } 255 return vm_min(r, w / TP_HALF) 256} 257 258// ================= MAP SET ================= 259// mmap is zero-filled, so a new map set is a black atlas. Returns a null pointer on bad geometry or on an 260// allocation the host refuses; tp_geom_ok answers WHICH, so the null is never the only diagnosis available. 261func tp_maps_new(res: i64, nmaps: i64, grid: i64) -> *i64 { 262 if tp_geom_ok(res, nmaps, grid) != TP_OK { return 0 as *i64 } 263 let ms: *i64 = sys_mmap(tp_set_bytes(res, nmaps)) as *i64 264 if (ms as i64) == 0 { return 0 as *i64 } 265 ms[TP_H_RES] = res 266 ms[TP_H_NMAPS] = nmaps 267 ms[TP_H_GRID] = grid 268 ms[TP_H_ERR] = TP_OK 269 return ms 270} 271func tp_res(ms: *i64) -> i64 { return ms[TP_H_RES] } 272func tp_nmaps(ms: *i64) -> i64 { return ms[TP_H_NMAPS] } 273func tp_grid(ms: *i64) -> i64 { return ms[TP_H_GRID] } 274 275func tp_map_ptr(ms: *i64, m: i64) -> *u8 { 276 return ((ms as i64) + tp_hdr_bytes() + m * tp_map_bytes(ms[TP_H_RES])) as *u8 277} 278// byte offset of channel c of texel (x,y) within one map. THE ROUNDING AND ORDER CONTRACT, in one line: 279// row-major, three interleaved channels, exactly ntb_bake's (y*res + x)*3 + c. 280func tp_texel_off(res: i64, x: i64, y: i64, c: i64) -> i64 { return (y * res + x) * TP_CHAN + c } 281 282// Reads return 0..255, or -1 for an address outside the set. The two ranges do not overlap, so a caller can 283// always tell a value from a refusal -- which is why this does not clamp. 284func tp_get(ms: *i64, m: i64, x: i64, y: i64, c: i64) -> i64 { 285 if m < 0 { return 0 - 1 } 286 if m >= ms[TP_H_NMAPS] { return 0 - 1 } 287 let res: i64 = ms[TP_H_RES] 288 if x < 0 { return 0 - 1 } 289 if x >= res { return 0 - 1 } 290 if y < 0 { return 0 - 1 } 291 if y >= res { return 0 - 1 } 292 if c < 0 { return 0 - 1 } 293 if c >= TP_CHAN { return 0 - 1 } 294 let p: *u8 = tp_map_ptr(ms, m) 295 return p[tp_texel_off(res, x, y, c)] as i64 296} 297func tp_set(ms: *i64, m: i64, x: i64, y: i64, c: i64, v: i64) -> i64 { 298 if tp_get(ms, m, x, y, c) < 0 { return TP_E_TEXEL } 299 if v < 0 { return TP_E_VALUE } 300 if v > TP_VALUE_MAX { return TP_E_VALUE } 301 let p: *u8 = tp_map_ptr(ms, m) 302 p[tp_texel_off(ms[TP_H_RES], x, y, c)] = v as u8 303 return TP_OK 304} 305 306func tp_geom_same(a: *i64, b: *i64) -> i64 { 307 if (a as i64) == 0 { return 0 } 308 if (b as i64) == 0 { return 0 } 309 if a[TP_H_RES] != b[TP_H_RES] { return 0 } 310 if a[TP_H_NMAPS] != b[TP_H_NMAPS] { return 0 } 311 if a[TP_H_GRID] != b[TP_H_GRID] { return 0 } 312 return 1 313} 314func tp_maps_copy(dst: *i64, src: *i64) -> i64 { 315 if tp_geom_same(dst, src) == 0 { return TP_E_GEOM } 316 let n: i64 = src[TP_H_NMAPS] * tp_map_bytes(src[TP_H_RES]) 317 let d: *u8 = tp_map_ptr(dst, 0) 318 let s: *u8 = tp_map_ptr(src, 0) 319 var i: i64 = 0 320 while i < n { d[i] = s[i]; i = i + 1 } 321 return TP_OK 322} 323// 1 only when every byte of every map agrees. This is the instrument the bit-exact replay claim rests on, 324// so it compares BYTES and not a digest -- a digest comparison would be a change detector standing in for an 325// identity proof. 326func tp_maps_equal(a: *i64, b: *i64) -> i64 { 327 if tp_geom_same(a, b) == 0 { return 0 } 328 let n: i64 = a[TP_H_NMAPS] * tp_map_bytes(a[TP_H_RES]) 329 let pa: *u8 = tp_map_ptr(a, 0) 330 let pb: *u8 = tp_map_ptr(b, 0) 331 var i: i64 = 0 332 while i < n { if pa[i] != pb[i] { return 0 } i = i + 1 } 333 return 1 334} 335// A CHANGE DETECTOR, NOT A CRYPTOGRAPHIC HASH, and it is labelled so no caller mistakes it for one. It 336// composes the operation stack's own published FNV-1a multiplier rather than introducing a second one, and 337// mixes the byte index so a permutation of the same bytes does not collide. 338func tp_maps_digest(ms: *i64) -> i64 { 339 let n: i64 = ms[TP_H_NMAPS] * tp_map_bytes(ms[TP_H_RES]) 340 let p: *u8 = tp_map_ptr(ms, 0) 341 var h: i64 = 0 342 var i: i64 = 0 343 while i < n { h = h * ES_HASH_MUL + (p[i] as i64) + i + 1; i = i + 1 } 344 return h 345} 346 347// ================= 1. SURFACE POINT -> TEXEL ADDRESS ================= 348// THE INPUT IS WHAT A VIEWPORT PICKER RETURNS: a triangle index and a barycentric triple. The dcc board's 349// picker row reads _ABSENT_:vp_pick, so DC4 does not exist yet and this file DECLARES the contract it must 350// meet rather than pretending to consume one: barycentric coordinates in PERMIL (TP_BARY_ONE), all three 351// non-negative and summing exactly to unity. 352// 353// uv is the TEXC per-vertex payload and uvstride is its stride, so a caller hands over the section it 354// already has instead of repacking it: the shipped TEXC stride is 3 ([u][v][owning joint]) and a bare uv 355// pair array is stride 2. Reading the stride from the caller is what keeps this file from owning a second 356// copy of the TEXC layout. 357 358// The two conjuncts of validity, named separately, because a triple that sums to 900 and a triple with a 359// negative member are different mistakes with different fixes. 360func tp_bary_ok(b0: i64, b1: i64, b2: i64) -> i64 { 361 if b0 < 0 { return TP_E_BARY } 362 if b1 < 0 { return TP_E_BARY } 363 if b2 < 0 { return TP_E_BARY } 364 if b0 + b1 + b2 != TP_BARY_ONE { return TP_E_BARYSUM } 365 return TP_OK 366} 367 368// Interpolate the UV of a surface point. out2 receives [u, v] in Q16. 369// ROUNDING: all three barycentric weights and all three UVs are non-negative, so the division by TP_BARY_ONE 370// truncates toward zero and is therefore a FLOOR. Stated rather than left to the reader. 371func tp_surface_to_uv(uv: *i64, uvstride: i64, tris: *i64, tri: i64, ntri: i64, nvert: i64, 372 b0: i64, b1: i64, b2: i64, out2: *i64) -> i64 { 373 if (uv as i64) == 0 { return TP_E_ARGS } 374 if (tris as i64) == 0 { return TP_E_ARGS } 375 if (out2 as i64) == 0 { return TP_E_ARGS } 376 if uvstride < TP_TEXEL_XY { return TP_E_ARGS } 377 if ntri < 1 { return TP_E_ARGS } 378 if nvert < 1 { return TP_E_ARGS } 379 if tri < 0 { return TP_E_TRI } 380 if tri >= ntri { return TP_E_TRI } 381 let brc: i64 = tp_bary_ok(b0, b1, b2) 382 if brc != TP_OK { return brc } 383 let i0: i64 = tris[tri * TP_TRI_V + 0] 384 let i1: i64 = tris[tri * TP_TRI_V + 1] 385 let i2: i64 = tris[tri * TP_TRI_V + 2] 386 if i0 < 0 { return TP_E_VERT } 387 if i1 < 0 { return TP_E_VERT } 388 if i2 < 0 { return TP_E_VERT } 389 if i0 >= nvert { return TP_E_VERT } 390 if i1 >= nvert { return TP_E_VERT } 391 if i2 >= nvert { return TP_E_VERT } 392 let u0: i64 = uv[i0 * uvstride + 0] 393 let v0: i64 = uv[i0 * uvstride + 1] 394 let u1: i64 = uv[i1 * uvstride + 0] 395 let v1: i64 = uv[i1 * uvstride + 1] 396 let u2: i64 = uv[i2 * uvstride + 0] 397 let v2: i64 = uv[i2 * uvstride + 1] 398 out2[0] = (b0 * u0 + b1 * u1 + b2 * u2) / TP_BARY_ONE 399 out2[1] = (b0 * v0 + b1 * v1 + b2 * v2) / TP_BARY_ONE 400 return TP_OK 401} 402 403// THE ROUNDING CONVENTION, DECLARED: texel = uv * res / TP_UV_SPAN, a truncating divide on a non-negative 404// operand and therefore FLOOR. It maps the half-open UV interval [x*65536/res, (x+1)*65536/res) onto texel 405// x, which is EXACTLY the convention ntb_bake already uses when it walks the atlas (ty = y*g/res), so a 406// texel painted here and the same texel baked there are the same texel by construction rather than by 407// agreement. A u of TP_UV_SPAN-1 lands on res-1 for every res in the addressable range, so the top of the 408// range needs no special case; a u AT TP_UV_SPAN is out of the Q16 codomain and is refused. 409func tp_uv_to_texel(u: i64, v: i64, res: i64, out2: *i64) -> i64 { 410 if (out2 as i64) == 0 { return TP_E_ARGS } 411 if res < 1 { return TP_E_RES } 412 if res > TP_UV_SPAN { return TP_E_RES } 413 if u < 0 { return TP_E_TEXEL } 414 if v < 0 { return TP_E_TEXEL } 415 if u >= TP_UV_SPAN { return TP_E_TEXEL } 416 if v >= TP_UV_SPAN { return TP_E_TEXEL } 417 out2[0] = u * res / TP_UV_SPAN 418 out2[1] = v * res / TP_UV_SPAN 419 return TP_OK 420} 421 422// The composition, which is the function a picker actually calls. 423func tp_surface_to_texel(uv: *i64, uvstride: i64, tris: *i64, tri: i64, ntri: i64, nvert: i64, 424 b0: i64, b1: i64, b2: i64, res: i64, out2: *i64) -> i64 { 425 let rc: i64 = tp_surface_to_uv(uv, uvstride, tris, tri, ntri, nvert, b0, b1, b2, out2) 426 if rc != TP_OK { return rc } 427 return tp_uv_to_texel(out2[0], out2[1], res, out2) 428} 429 430// ================= 2. THE BRUSH ================= 431// Every bound is checked BEFORE a byte is written, so a refused stroke leaves the map set exactly as it 432// found it and a gate can assert "refused AND byte-identical" instead of merely "refused". 433func tp_stroke_valid(ms: *i64, map_id: i64, chmask: i64, cx: i64, cy: i64, r: i64, 434 strength: i64, value: i64) -> i64 { 435 if (ms as i64) == 0 { return TP_E_ARGS } 436 let res: i64 = ms[TP_H_RES] 437 let g: i64 = ms[TP_H_GRID] 438 let grc: i64 = tp_geom_ok(res, ms[TP_H_NMAPS], g) 439 if grc != TP_OK { return grc } 440 if map_id < 0 { return TP_E_MAP } 441 if map_id >= ms[TP_H_NMAPS] { return TP_E_MAP } 442 if chmask < 1 { return TP_E_CHMASK } 443 if chmask >= TP_CH_SPAN { return TP_E_CHMASK } 444 if strength < 0 { return TP_E_STRENGTH } 445 if strength > TP_STRENGTH_MAX { return TP_E_STRENGTH } 446 if value < 0 { return TP_E_VALUE } 447 if value > TP_VALUE_MAX { return TP_E_VALUE } 448 if r < 1 { return TP_E_RADIUS } 449 if cx < 0 { return TP_E_TEXEL } 450 if cy < 0 { return TP_E_TEXEL } 451 if cx >= res { return TP_E_TEXEL } 452 if cy >= res { return TP_E_TEXEL } 453 let ch: i64 = tp_chart_of_texel(res, g, cx, cy) 454 if ch < 0 { return TP_E_CHART } 455 let tx: i64 = ch % g 456 let w: i64 = tp_chart_x1(res, g, tx) - tp_chart_x0(res, g, tx) 457 if r > tp_bleed_margin_w(w, r) { return TP_E_RADIUS_CHART } 458 return TP_OK 459} 460 461// The derived seam margin for a stroke, as the caller sees it. -1 when the centre resolves to no chart. 462func tp_bleed_margin(ms: *i64, cx: i64, cy: i64, r: i64) -> i64 { 463 if (ms as i64) == 0 { return 0 - 1 } 464 let res: i64 = ms[TP_H_RES] 465 let g: i64 = ms[TP_H_GRID] 466 let ch: i64 = tp_chart_of_texel(res, g, cx, cy) 467 if ch < 0 { return 0 - 1 } 468 let tx: i64 = ch % g 469 let w: i64 = tp_chart_x1(res, g, tx) - tp_chart_x0(res, g, tx) 470 return tp_bleed_margin_w(w, r) 471} 472 473// ONE TEXEL. dx is the distance along u under whichever metric the caller chose; dy is the distance along v, 474// which never wraps. Returns 1 when the texel took paint and 0 when it did not, so the caller's count is a 475// measurement of the disc rather than of the loop bounds. 476// THE BLEND: new = old + (target - old) * weight / TP_ONE. At full weight the target is reached EXACTLY; at 477// partial weight the integer division truncates toward zero, which is toward the ORIGINAL value, so a stroke 478// can never overshoot its target in either direction. That direction of truncation is a property worth 479// having and it is asserted rather than assumed. 480func tp_put(m: *u8, res: i64, chmask: i64, x: i64, y: i64, dx: i64, dy: i64, r: i64, 481 strength: i64, value: i64) -> i64 { 482 let d2: i64 = dx * dx + dy * dy 483 if d2 >= r * r { return 0 } 484 let f: i64 = tp_falloff(vm_isqrt(d2), r) 485 if f <= 0 { return 0 } 486 let wgt: i64 = strength * f / TP_ONE 487 if wgt <= 0 { return 0 } 488 let off: i64 = tp_texel_off(res, x, y, 0) 489 var c: i64 = 0 490 while c < TP_CHAN { 491 if tp_ch_on(chmask, c) == 1 { 492 let old: i64 = m[off + c] as i64 493 m[off + c] = vm_clamp(old + (value - old) * wgt / TP_ONE, 0, TP_VALUE_MAX) as u8 494 } 495 c = c + 1 496 } 497 return 1 498} 499 500// THE ONE WRITER. mode selects the metric; there is no second painting loop in this file, so the planar 501// painter and the seam painter cannot drift apart in their blending, their falloff or their clipping. 502// Returns the number of texels that took paint, or a negative named refusal. 503func tp_stroke_at(ms: *i64, map_id: i64, chmask: i64, cx: i64, cy: i64, r: i64, 504 strength: i64, value: i64, mode: i64) -> i64 { 505 let vrc: i64 = tp_stroke_valid(ms, map_id, chmask, cx, cy, r, strength, value) 506 if vrc != TP_OK { return vrc } 507 if mode != TP_M_PLANAR { if mode != TP_M_WRAPONLY { return TP_E_MODE } } 508 let res: i64 = ms[TP_H_RES] 509 let g: i64 = ms[TP_H_GRID] 510 let ch: i64 = tp_chart_of_texel(res, g, cx, cy) 511 let tx: i64 = ch % g 512 let ty: i64 = ch / g 513 let x0: i64 = tp_chart_x0(res, g, tx) 514 let x1: i64 = tp_chart_x1(res, g, tx) 515 let y0: i64 = tp_chart_x0(res, g, ty) 516 let y1: i64 = tp_chart_x1(res, g, ty) 517 let w: i64 = x1 - x0 518 let mg: i64 = tp_bleed_margin_w(w, r) 519 let lcx: i64 = cx - x0 520 let m: *u8 = tp_map_ptr(ms, map_id) 521 var painted: i64 = 0 522 // v CLAMPS to the chart in both metrics: the ends of a bone are not identified with each other. 523 var yy: i64 = vm_max(cy - r, y0) 524 let yhi: i64 = vm_min(cy + r, y1 - 1) 525 while yy <= yhi { 526 let dy: i64 = vm_abs(yy - cy) 527 if mode == TP_M_PLANAR { 528 var xx: i64 = vm_max(cx - r, x0) 529 let xhi: i64 = vm_min(cx + r, x1 - 1) 530 while xx <= xhi { 531 painted = painted + tp_put(m, res, chmask, xx, yy, vm_abs(xx - cx), dy, r, strength, value) 532 xx = xx + 1 533 } 534 } 535 if mode == TP_M_WRAPONLY { 536 // Only the mg columns adjacent to each cut can hold a wrapped texel: a texel reached ONLY by 537 // wrapping has cylindrical distance w - |lx - lcx| <= r, so it lies within r = mg of one edge. 538 // The two bands are disjoint because mg <= w/2, which tp_stroke_valid has already enforced. 539 var band: i64 = 0 540 while band < TP_BANDS { 541 var xa: i64 = x0 542 var xb: i64 = x0 + mg 543 if band == 1 { xa = x1 - mg; xb = x1 } 544 var xx2: i64 = xa 545 while xx2 < xb { 546 let dxp: i64 = vm_abs(xx2 - x0 - lcx) 547 let dxw: i64 = w - dxp 548 // dxw < dxp is exactly "the short way round is the wrapped way", i.e. this texel is one 549 // the planar pass could not have reached. Equality cannot happen for an integer w that 550 // is odd, and for even w it means the two routes are the same length, in which case the 551 // planar pass already owns the texel and this pass must not touch it again. 552 if dxw < dxp { 553 painted = painted + tp_put(m, res, chmask, xx2, yy, dxw, dy, r, strength, value) 554 } 555 xx2 = xx2 + 1 556 } 557 band = band + 1 558 } 559 } 560 yy = yy + 1 561 } 562 return painted 563} 564 565// THE NAIVE PAINTER, SHIPPED ON PURPOSE. It clips the disc to the chart rectangle -- which any painter MUST 566// do, or paint leaks from one bone onto another -- and therefore drops the far side of a stroke that crosses 567// the cylinder cut. This is the tear. It is a public, callable function so the anti-tear tooth in the gate 568// is a permanent negative control that can be re-run, rather than a claim that someone once deleted the fix 569// and watched a test go red. 570func tp_stroke_core(ms: *i64, map_id: i64, chmask: i64, cx: i64, cy: i64, r: i64, 571 strength: i64, value: i64) -> i64 { 572 return tp_stroke_at(ms, map_id, chmask, cx, cy, r, strength, value, TP_M_PLANAR) 573} 574 575// ================= 3. SEAM BLEED ================= 576// Writes exactly the texels the planar pass could not reach: cylindrical distance inside the brush, planar 577// distance outside it. Disjoint from tp_stroke_core's set by that definition, so the union is the full disc 578// and no texel is ever written twice. Returns the count, which is 0 for an interior stroke -- this is a 579// targeted repair of the cut, NOT a blanket dilation of everything that was painted. 580func tp_seam_bleed(ms: *i64, map_id: i64, chmask: i64, cx: i64, cy: i64, r: i64, 581 strength: i64, value: i64) -> i64 { 582 return tp_stroke_at(ms, map_id, chmask, cx, cy, r, strength, value, TP_M_WRAPONLY) 583} 584 585// THE STROKE. Core plus bleed, and the sum of their counts is the whole disc. 586func tp_stroke(ms: *i64, map_id: i64, chmask: i64, cx: i64, cy: i64, r: i64, 587 strength: i64, value: i64) -> i64 { 588 let a: i64 = tp_stroke_core(ms, map_id, chmask, cx, cy, r, strength, value) 589 if a < 0 { return a } 590 let b: i64 = tp_seam_bleed(ms, map_id, chmask, cx, cy, r, strength, value) 591 if b < 0 { return b } 592 return a + b 593} 594 595// ================= 4. THE PAINT SESSION, ON THE ESTATE'S OPERATION STACK ================= 596// There is no history mechanism in this file. A session IS an nx_editstack, one operation per stroke, and 597// undo, redo and re-evaluation are that library's, unwrapped: a wrapper that only forwards is a second name 598// for one thing. What this file adds is the PACKING -- seven stroke numbers into the op record's three free 599// argument words -- and the RENDERER that turns a log into pixels. 600// 601// PACKING. Every multiplier is a span derived from a declared domain, so a decode is an exact div/mod and 602// there is no chosen shift anywhere: 603// what = (map_id * TP_CH_SPAN + chmask) * tp_value_span() + value 604// centre = cy * res + cx (the atlas is res wide, so this is exact and reversible) 605// brush = r * tp_strength_span() + strength 606func tp_pack_what(map_id: i64, chmask: i64, value: i64) -> i64 { 607 return (map_id * TP_CH_SPAN + chmask) * tp_value_span() + value 608} 609func tp_what_map(p: i64) -> i64 { return p / tp_value_span() / TP_CH_SPAN } 610func tp_what_chmask(p: i64) -> i64 { return p / tp_value_span() % TP_CH_SPAN } 611func tp_what_value(p: i64) -> i64 { return p % tp_value_span() } 612func tp_pack_centre(res: i64, cx: i64, cy: i64) -> i64 { return cy * res + cx } 613func tp_centre_x(res: i64, p: i64) -> i64 { return p % res } 614func tp_centre_y(res: i64, p: i64) -> i64 { return p / res } 615func tp_pack_brush(r: i64, strength: i64) -> i64 { return r * tp_strength_span() + strength } 616func tp_brush_r(p: i64) -> i64 { return p / tp_strength_span() } 617func tp_brush_strength(p: i64) -> i64 { return p % tp_strength_span() } 618 619// cap_ops strokes, and one cell per stroke so es_apply's SET has somewhere to write the what-word. 620func tp_session_new(cap_strokes: i64) -> *i64 { return es_new(cap_strokes, cap_strokes) } 621func tp_strokes(st: *i64) -> i64 { return es_head(st) } 622 623// Record a stroke. VALIDATED AGAINST THE MAP SET FIRST, so a stroke that could never be painted never enters 624// the log -- otherwise a replay would refuse halfway and leave a half-rendered atlas that looks like a paint 625// bug rather than a bad record. 626// The two ways this can fail after validation are named apart: a FULL log is an ordinary, expected state, 627// while an operation stack that refuses an already-validated stroke is an invariant violation and says so. 628func tp_record(st: *i64, ms: *i64, map_id: i64, chmask: i64, cx: i64, cy: i64, r: i64, 629 strength: i64, value: i64) -> i64 { 630 if (st as i64) == 0 { return TP_E_ARGS } 631 let vrc: i64 = tp_stroke_valid(ms, map_id, chmask, cx, cy, r, strength, value) 632 if vrc != TP_OK { return vrc } 633 if es_head(st) >= es_cap(st) { return TP_E_FULL } 634 let slot: i64 = es_head(st) 635 let rc: i64 = es_push(st, ES_OP_SET, slot, 636 tp_pack_what(map_id, chmask, value), 637 tp_pack_centre(ms[TP_H_RES], cx, cy), 638 tp_pack_brush(r, strength)) 639 if rc != ES_OK { return TP_E_PUSH } 640 return TP_OK 641} 642 643// Change the parameters of stroke idx and re-evaluate. The SLOT is read back from the existing record rather 644// than recomputed, so a re-evaluation cannot silently move a stroke to a different cell. 645func tp_reeval(st: *i64, ms: *i64, idx: i64, map_id: i64, chmask: i64, cx: i64, cy: i64, r: i64, 646 strength: i64, value: i64) -> i64 { 647 if (st as i64) == 0 { return TP_E_ARGS } 648 if idx < 0 { return TP_E_ARGS } 649 if idx >= es_count(st) { return TP_E_ARGS } 650 let vrc: i64 = tp_stroke_valid(ms, map_id, chmask, cx, cy, r, strength, value) 651 if vrc != TP_OK { return vrc } 652 let slot: i64 = es_op_field(st, idx, ES_F_A0) 653 let rc: i64 = es_reeval(st, idx, slot, 654 tp_pack_what(map_id, chmask, value), 655 tp_pack_centre(ms[TP_H_RES], cx, cy), 656 tp_pack_brush(r, strength)) 657 if rc != ES_OK { return TP_E_PUSH } 658 return TP_OK 659} 660 661// THE RENDERER: base, then every operation up to the head, in order. This is why undo is bit-exact -- the 662// result is never approached by applying an inverse, it is rebuilt. Iterates to es_head and NOT to es_count, 663// so the redo tail that the stack is still holding is correctly not drawn. 664// Returns the number of strokes applied, or a negative named refusal. 665func tp_render(st: *i64, base: *i64, out: *i64) -> i64 { 666 if (st as i64) == 0 { return TP_E_ARGS } 667 if tp_geom_same(base, out) == 0 { return TP_E_GEOM } 668 let crc: i64 = tp_maps_copy(out, base) 669 if crc != TP_OK { return crc } 670 let res: i64 = out[TP_H_RES] 671 let n: i64 = es_head(st) 672 var k: i64 = 0 673 while k < n { 674 let what: i64 = es_op_field(st, k, ES_F_A1) 675 let ctr: i64 = es_op_field(st, k, ES_F_A2) 676 let brs: i64 = es_op_field(st, k, ES_F_A3) 677 let rc: i64 = tp_stroke(out, tp_what_map(what), tp_what_chmask(what), 678 tp_centre_x(res, ctr), tp_centre_y(res, ctr), 679 tp_brush_r(brs), tp_brush_strength(brs), tp_what_value(what)) 680 if rc < 0 { return rc } 681 k = k + 1 682 } 683 return n 684}