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}