diff --git a/cli.ts b/cli.ts index ea9130b..5293514 100644 --- a/cli.ts +++ b/cli.ts @@ -492,6 +492,30 @@ function meshDepthNote(m: CompileResult['meshes'][number]): string { `depth "${m.depth.image}" ${m.depth.digest} near=${m.depth.near} zScale=${m.depth.zScale} ` + `z=[${m.depth.range[0]}, ${m.depth.range[1]}]`, ); + // Directly under the sheet's own line, because it is the other half of + // "what did this mesh read": `z=[…]` says how much of the map's range + // reached the vertices, and this says how many of them read a texel that + // draws nothing (issue #449). Reported only when there is something to + // report, so a mesh whose every vertex sits on drawn art gains no line. + // + // ⭐ A `contour` gets the OTHER half of the sentence, because rigc built + // that outline and knows what it is: `buildContourMesh` returns + // `hullVertices: points.length` over `offsetPolygon(simplified, margin)`, + // so every vertex of a contour mesh is the traced silhouette pushed out by + // the margin — there are no interior vertices for the count to be about. + // Nothing is derived, inferred or thresholded to say so; it is what the + // generator returns, and `generatedHullAndEdges` already cross-checks that + // hull against the triangulation's own outline. Without it the line reads + // as a fault on every correct contour rig, which is a diagnostic authors + // learn to ignore. + if (m.depth.undrawn > 0) { + parts.push( + `${m.depth.undrawn} of ${m.vertices} vertices sample a texel the part image does not draw — ` + + (m.kind === 'contour' + ? 'a contour\'s vertices are all traced outline, pushed out by the margin, so this is the topology and not the sheet' + : 'their z is the sheet\'s reading of somewhere the part is not'), + ); + } const c = m.depth.ceiling; parts.push(`turn ceiling yaw ${ceilingPair(c.yaw)} pitch ${ceilingPair(c.pitch)}`); const worst = tightestFold(c); @@ -503,7 +527,13 @@ function meshDepthNote(m: CompileResult['meshes'][number]): string { ? ` nothing in this sheet folds: ${c.measured} triangle(s) measured, none with a depth gradient across it` : ` first to fold: ${worst.kind} ${worst.sign} at ${worst.limit.degrees.toFixed(2)}°, ` + `triangle ${worst.limit.triangle} [${worst.limit.ids.join(',')}], the sheet steps ` + - `${depthStepLevels(worst.limit.depthStep, m.depth.zScale).toFixed(2)} level(s) across it` + + `${depthStepLevels(worst.limit.depthStep, m.depth.zScale).toFixed(2)} level(s) across it, ` + + // The same step over the range the mesh sampled (issue #448). A + // suffix and not a line of its own: it is an apposition on the step + // beside it, and the reading that matters is the two together — a + // discontinuity says "plenty of levels" and "nearly all of them" at + // once, and they have to be read in one breath to say the opposite. + `which is ${worst.limit.stepShare.toFixed(3)} of the range this mesh sampled` + `${c.degenerate ? `; ${c.degenerate} triangle(s) too flat in setup to measure` : ''}`, ); } diff --git a/docs/AUTHORING.md b/docs/AUTHORING.md index b64b30b..3060d17 100644 --- a/docs/AUTHORING.md +++ b/docs/AUTHORING.md @@ -1004,7 +1004,7 @@ number that says whether the map covers the part or a corner of it. depth "face_depth.png" f552a2f50d21 near=white zScale=60 z=[0, 60] turn ceiling yaw +31.41° / -32.01° pitch +32.01° / -31.41° 1st pct yaw +31.55° x1.004 of 1004 / -32.10° x1.003 of 1044 pitch +32.10° x1.003 of 1044 / -31.55° x1.004 of 1004 - first to fold: yaw + at 31.41°, triangle 960 [113,112,593], the sheet steps 12.52 level(s) across it + first to fold: yaw + at 31.41°, triangle 960 [113,112,593], the sheet steps 12.52 level(s) across it, which is 0.049 of the range this mesh sampled ``` Past that angle a triangle turns inside out and `A39` refuses the build by name. @@ -1012,9 +1012,10 @@ The loop this replaces is *pick an angle, build, read the refusal, guess again*. #### Is the ceiling describing the form, or the sheet's grain? -The second and third lines answer that, and they are **reports only** — nothing +The lines under the ceiling answer that, and they are **reports only** — nothing in them refuses a build or moves a ceiling -([#412](https://github.com/firejune/rigc/issues/412)). +([#412](https://github.com/firejune/rigc/issues/412), +[#448](https://github.com/firejune/rigc/issues/448)). The ceiling is the **minimum** of the per-triangle fold angles, and a minimum cannot say whether it is the floor of a band or one bad pixel. Measured: a clean @@ -1025,9 +1026,10 @@ triangle does not, and nothing on the first line says so. | the figure | how to read it | | --- | --- | -| `x1.003` — the **1st percentile over the ceiling** | near 1 means a *band* of the mesh reaches the limit together, which is what a smooth form looks like: its steepest region has area. Near 10 means **one triangle** does, which is what a bad texel looks like. The clean and stray sheets above read `x1.003` and `x10.652` | +| `x1.003` — the **1st percentile over the ceiling** | near 1 means a *band* of the mesh reaches the limit together, and near 10 means **one triangle** does, which is what a bad texel looks like. The clean and stray sheets above read `x1.003` and `x10.652`. ⚠️ A band is **not** sufficient evidence of a form, which is the third case: **an outline is a band**. A depth sheet estimated over cut-out art has a cliff along the whole silhouette, so its ceiling is a band too and this figure reads 1.02–2.17 — healthy — on a mesh whose angle means nothing. The row below is what tells those two apart | | `of 1004` — the **population** that percentile came out of | it is the nearest rank, so below **51** folding triangles there is no percentile to take and the line says `unranked of 36` instead of printing the minimum twice. Just over 51 it is the *second*-smallest angle, and a limit two triangles share is not yet a band | | `12.52 level(s)` — the **depth step across the triangle that folds first** | how much of the sheet's 0–255 range that triangle actually read. **Below about 3 the ceiling is quantisation rather than form**, and at exactly 1 it is `atan(255·h / zScale)` for cell size `h` — arithmetic about the encoding, with no form left in it at any density | +| `which is 0.049 of the range this mesh sampled` — the **same step, over the range this mesh sampled** | how much of everything the sheet said across the whole part it said across that one triangle. A form's slope is bounded, so this **halves every time you double the lattice** while the angle settles. Near 1 it is a **cliff**: a step with no slope in it, whose angle halves with the lattice instead and describes nothing at any density. Measured: `gallery/look` reads 0.112 and 0.468, a synthetic raised cosine 0.394 falling to 0.027 under refinement, the same cosine with one planted cliff a flat 0.50, and estimated depth sheets over cut-out art **0.92–0.99** | | `+none` | on the ceiling line, nothing folds on that side at all, at any angle. On the percentile line it is the same statement — there is no population, because there is nothing to take a percentile of | A real one rather than the illustration above — `bun cli.ts build --rig @@ -1038,25 +1040,48 @@ three spellings: ``` MESH head grid 189 vertices / 320 triangles (budget 320) bones=[head] attachments=[head] depth "face_depth.png" bf156ea0cfc970a3 near=white zScale=194 z=[0, 194] + 80 of 189 vertices sample a texel the part image does not draw — their z is the sheet's reading of somewhere the part is not turn ceiling yaw +19.32° / -19.32° pitch +22.92° / -26.94° 1st pct yaw +19.32° x1.000 of 80 / -19.32° x1.000 of 80 pitch +22.92° x1.000 of 102 / -26.94° x1.000 of 130 - first to fold: yaw + at 19.32°, triangle 174 [119,138,139], the sheet steps 28.50 level(s) across it + first to fold: yaw + at 19.32°, triangle 174 [119,138,139], the sheet steps 28.50 level(s) across it, which is 0.112 of the range this mesh sampled MESH hair_lock_l grid 39 vertices / 48 triangles (budget 320) bones=[lock_l] attachments=[hair_lock_l] depth "lock_l_depth.png" 0c4eaeb36b7c5cac near=white zScale=64 z=[22.086275, 63.874511] + 32 of 39 vertices sample a texel the part image does not draw — their z is the sheet's reading of somewhere the part is not turn ceiling yaw +17.04° / -45.80° pitch +none / -none 1st pct yaw +unranked of 12 / -unranked of 36 pitch +none / -none - first to fold: yaw + at 17.04°, triangle 2 [1,28,29], the sheet steps 78.00 level(s) across it + first to fold: yaw + at 17.04°, triangle 2 [1,28,29], the sheet steps 78.00 level(s) across it, which is 0.468 of the range this mesh sampled ``` ⭐ Both of those sheets are **form**, and the figures say so from opposite ends: the head's 320 triangles put the percentile exactly on the ceiling, and the lock's 48 are too few to rank at all — but at 28.50 and 78.00 levels across the -folding triangle, neither ceiling is anywhere near the encoding. - -⇒ **A small ceiling with a ratio near 1 and a step well above 3 is a steep -surface: flatten the map.** A small ceiling with a large ratio, or with a step -near 1, is a *sheet* problem: the grain, the 8-bit rounding, or a stray pixel. -[`docs/FACE.md` §2.2](FACE.md) has the amplitudes and what each one costs. +folding triangle, neither ceiling is anywhere near the encoding, and at 0.112 +and 0.468 of their own range neither step is a cliff. + +🔸 The line under each `depth "…"` is the other question, and it is not about +the ceiling. A `grid` spans the whole part window and a head is not a rectangle, so +some of the lattice lands where `head.png` draws nothing and takes its depth +from the sheet out there. That is a **count and not a complaint** — read it +against the mesh: 80 of 189 is the border of a lattice over a cut-out and the +ceiling is still a form, while a count approaching the whole mesh with a step +share near 1 beside it is a mesh reading background. + +⇒ **A small ceiling with a ratio near 1, a step well above 3 and a step share +well under 1 is a steep surface: flatten the map.** A small ceiling with a large +ratio, or with a step near 1, is a *sheet* problem: the grain, the 8-bit +rounding, or a stray pixel. A small ceiling with a step share **near 1** is +neither — it is a **discontinuity**, and no angle is the right one to quote for +it. [`docs/FACE.md` §2.2](FACE.md) has the amplitudes, what each one costs, and +why flattening does not apply to the third case. + +🚨 **How to tell a discontinuity from a steep surface in one move: refine the +lattice and build again.** A form's ceiling settles and its step share halves; a +cliff's ceiling **halves** and its step share does not move. Measured on a +synthetic raised cosine against the same cosine with one column of cliff planted +in it, over three doublings: the form goes 0.358 → 0.195 → 0.100 while its +ceiling moves 41.99° → 39.59° → 38.84°, and the cliff goes 0.460 → 0.489 → 0.497 +while its ceiling goes 37.07° → 18.59° → 9.24°. The second one is not converging +on anything. ⛔ **rigc will not filter the sheet for you, and you should not want it to.** A smoothed measurement would describe a surface the deform key is not built from: @@ -1123,9 +1148,50 @@ refuses it instead: | `gamma` or `contrast` at or below 0 | `collapses the range onto the midpoint … so the map would describe a flat part` | | a `near` that is neither | `it is "white" or "black"` | -A sheet with **no alpha channel** covers its whole grid by construction and the -coverage check has nothing to test; what its background level means is then your -statement, and the reported range is where it shows up. +A sheet that is **opaque everywhere** — a full-frame render with the background +in it, which is what monocular depth estimation produces — covers its whole grid +by construction, so the coverage refusal has nothing to hold it to and skips. +That is right: a full-frame sheet is a legitimate statement and rigc has no +authority to guess an input away. But the defect the refusal exists to catch is +still reachable in that encoding, so the report counts it instead +([#449](https://github.com/firejune/rigc/issues/449)): + +``` + 80 of 189 vertices sample a texel the part image does not draw — their z is the sheet's reading of somewhere the part is not +``` + +⚠️ **The reported range is NOT where this shows up**, and this guide said it was +until it was measured. A background level is a legitimate depth value, so a map +half of which is background reports exactly as full a range as one that is all +subject: on the measured build, `z=[0, 223.97]` of a stated 224 — healthy — with +54 % of the mesh reading background. "Covers its whole grid" is true and about +the wrong grid; the question was never coverage of the *sheet*, it was whether +the mesh is sampling **art**. + +| The input | What you get | +| --- | --- | +| a sheet cut to the art's alpha, over a mesh that reaches past it | **refused** — `does not cover N of the mesh's V vertices`, with the fix for your topology named | +| the same field stored opaque everywhere, over the same mesh | **compiled**, with `N of V vertices sample a texel the part image does not draw` in the report. The same N | +| a mesh every one of whose vertices sits on drawn art | nothing — no line, and no refusal | + +🔸 It is a raw count with **no reach subtracted from it**, so a `contour` mesh +reports most or all of its vertices: its outline is pushed `margin` pixels +outside the silhouette by design, and out there the part draws nothing. +Discounting the margin would mean borrowing a number authored for the trace to +mean "close enough" for the sheet, and rigc does not invent tolerances. + +⭐ **So the line says why instead**, because rigc built that outline and knows +what it is: + +``` + 15 of 15 vertices sample a texel the part image does not draw — a contour's vertices are all traced outline, pushed out by the margin, so this is the topology and not the sheet +``` + +The attribution is on a `contour` and **nowhere else**. A `grid`'s border is +where your `us`/`vs` put it, not where a margin did, so the same sentence there +would be a lie and the count stands alone. What that leaves true either way: the +rim's depth really did come from off the art, which is harmless on a sheet +dilated past the margin and is the whole failure on a full-frame estimate. ##### `soft` — which part is soft, and which bone carries it diff --git a/docs/FACE.md b/docs/FACE.md index aee221f..670a2c5 100644 --- a/docs/FACE.md +++ b/docs/FACE.md @@ -364,12 +364,54 @@ geometry ([AUTHORING §3.4](AUTHORING.md)): ``` turn ceiling yaw +31.41° / -32.01° pitch +32.01° / -31.41° 1st pct yaw +31.55° x1.004 of 1004 / -32.10° x1.003 of 1044 pitch +32.10° x1.003 of 1044 / -31.55° x1.004 of 1004 - first to fold: yaw + at 31.41°, triangle 960 [113,112,593], the sheet steps 12.52 level(s) across it + first to fold: yaw + at 31.41°, triangle 960 [113,112,593], the sheet steps 12.52 level(s) across it, which is 0.049 of the range this mesh sampled ``` Read it as a fact about **the sheet**. If the number is too small, the fix is in the map — flatten it where the part curves away — and not in the lattice. +#### ⚠️ That rule presumes the map is continuous + +"Flatten it where the part curves away" is an edit to a **surface**, and it +assumes there is one under the whole mesh. A depth sheet estimated from a +picture of cut-out art is not a surface: it is piecewise, with a **cliff at every +occlusion boundary** — figure against background at the silhouette, and one part +of the figure over another wherever they overlap. There is nothing to flatten +across a cliff, because the two sides are not two ends of a slope. They are two +different things at two different depths, and a 2.5D turn does not model +occlusion at all. + +The ceiling reads the cliff, correctly, and the number it reports is real: +walked one degree at a time through the survey `A39` refuses from, a reported +1.936° admits +1° and reverses 8 triangles at +2°. **The rig genuinely folds at +two degrees.** What is wrong is not the instrument and not the mesh — it is that +the question "how far can this turn" has no answer for an input with a +discontinuity in it, and the ceiling proves it by halving with every doubling of +the lattice: measured tangent ratios 2.02 / 1.95 / 2.02, `tan t ∝ h` exactly, +with no limit to converge to. + +🚨 **And the two figures beside the ceiling both call it healthy.** The 1st +percentile reads 1.02–2.17, which is a band reaching the limit together — and it +*is* a band, because an outline is long. The depth step reads 148–252 levels of +255, far above the quantisation floor — and the sheet really did say that much, +in one step. Divided by the range, the same number says the opposite: the step +share pins at **0.92–0.99** where rigc's own gallery reads 0.112 and 0.468. + +⇒ Two ways out, and **neither of them is flattening**: + +- **Mesh only what is continuous.** One face, one lock, one sleeve — a region + the sheet describes without a jump in it — rather than a lattice over a whole + figure. Whether a mask that tight gives a usable angle is not yet measured; + the mask has to be painted rather than thresholded, for the reason `soft` + is painted (§3.4's `soft` block). +- **State a sheet that was authored rather than estimated.** §2.2's raised + cosine holds 63–64° from 289 vertices to 32,761 because somebody drew its + slope. That is the input this whole section is about. + +⛔ rigc will not decide that your sheet is the wrong kind of thing. It has every +authority to say what it measured, and the step share is that +([#448](https://github.com/firejune/rigc/issues/448)). + 📐 Method, harness and the full ladders live in the repository rather than in this package, as [`bench/studies/2026-09-05-density`](https://github.com/firejune/rigc/tree/main/bench/studies/2026-09-05-density) — @@ -394,17 +436,22 @@ the range describes the same surface and reports 5.8° less of it. Method and ladders: [`bench/studies/2026-09-05-noise`](https://github.com/firejune/rigc/tree/main/bench/studies/2026-09-05-noise). -⭐ **And you do not have to guess which of the two you are looking at.** The two -lines under the ceiling say it ([#412](https://github.com/firejune/rigc/issues/412)): -the **1st percentile over the ceiling** is near 1 when a band of the mesh reaches -the limit together, which is a form, and near 10 when one triangle does, which is -a texel — 1.003 against 10.652 for the two sheets above. The **depth step across -the triangle that folds first**, in levels, is the other half: below about 3 the -ceiling is quantisation, and at 1 it is `atan(255·h / zScale)` and carries nothing -about the form at all. Both are reports and neither moves the ceiling — ⛔ rigc -will not filter a depth map, because a smoothed measurement would describe a -surface the deform key is not built from and `A39` would go on refusing at the -raw angle. [AUTHORING §3.4](AUTHORING.md) has the reading table. +⭐ **And you do not have to guess which of the three you are looking at.** The +lines under the ceiling say it +([#412](https://github.com/firejune/rigc/issues/412), +[#448](https://github.com/firejune/rigc/issues/448)): the **1st percentile over +the ceiling** is near 1 when a band of the mesh reaches the limit together and +near 10 when one triangle does, which is a texel — 1.003 against 10.652 for the +two sheets above. The **depth step across the triangle that folds first**, in +levels, is the second: below about 3 the ceiling is quantisation, and at 1 it is +`atan(255·h / zScale)` and carries nothing about the form at all. The **same step +over the range the mesh sampled** is the third, and it is the one that separates +a steep surface from a cliff — a form's halves with every doubling of the lattice +while its angle settles, a discontinuity's does not move while its angle halves. +All three are reports and none of them moves the ceiling — ⛔ rigc will not filter +a depth map, because a smoothed measurement would describe a surface the deform +key is not built from and `A39` would go on refusing at the raw angle. +[AUTHORING §3.4](AUTHORING.md) has the reading table. --- diff --git a/gallery/look/README.md b/gallery/look/README.md index 322cdf2..27caf98 100644 --- a/gallery/look/README.md +++ b/gallery/look/README.md @@ -117,9 +117,10 @@ inside out**, from `tan t = A₀/A_yaw` on the mesh's own triangles ``` MESH head grid 189 vertices / 320 triangles (budget 320) bones=[head] attachments=[head] depth "face_depth.png" bf156ea0cfc970a3 near=white zScale=194 z=[0, 194] + 80 of 189 vertices sample a texel the part image does not draw — their z is the sheet's reading of somewhere the part is not turn ceiling yaw +19.32° / -19.32° pitch +22.92° / -26.94° 1st pct yaw +19.32° x1.000 of 80 / -19.32° x1.000 of 80 pitch +22.92° x1.000 of 102 / -26.94° x1.000 of 130 - first to fold: yaw + at 19.32°, triangle 174 [119,138,139], the sheet steps 28.50 level(s) across it + first to fold: yaw + at 19.32°, triangle 174 [119,138,139], the sheet steps 28.50 level(s) across it, which is 0.112 of the range this mesh sampled ``` ⇒ **The range is the largest whole degree strictly inside that ceiling: 19.** @@ -137,6 +138,18 @@ Everything else falls out: | the map | `time = 0 + (degrees + 19) × 0.05` | | key times | 0, 0.3, 0.65, 0.95, 1.25, 1.6, 1.9 — the seven angles −19, −13, −6, 0, 6, 13, 19 | +🔸 **The other two figures in that block are about the sheet rather than the +angle**, and both say this one is a form. `which is 0.112 of the range this mesh +sampled` is the step across the folding triangle divided by everything the map +said across the whole mesh: a bounded slope halves that every time you double +the lattice, while a **discontinuity** — the cliff an estimated depth map puts at +every occlusion edge — pins it near 1 and halves the *angle* instead +([AUTHORING §3.4](../../docs/AUTHORING.md)). And `80 of 189 vertices sample a +texel the part image does not draw` is the lattice's own border: a `grid` spans +the whole part window and a head is not a rectangle, so 80 of these vertices take +their `z` from `face_depth.png` out past the silhouette. That is a count and not +a complaint — read it next to the 0.112. + 📐 **How close 19° is to the wall is worth reading**, because it is the number that says the derivation is not decorative. `rigc explain`'s `DEFORM` block at the extreme key: @@ -268,9 +281,10 @@ the inside — and the ceiling comes back asymmetric: ``` MESH hair_lock_l grid 39 vertices / 48 triangles (budget 320) bones=[lock_l] attachments=[hair_lock_l] depth "lock_l_depth.png" 0c4eaeb36b7c5cac near=white zScale=64 z=[22.086275, 63.874511] + 32 of 39 vertices sample a texel the part image does not draw — their z is the sheet's reading of somewhere the part is not turn ceiling yaw +17.04° / -45.80° pitch +none / -none 1st pct yaw +unranked of 12 / -unranked of 36 pitch +none / -none - first to fold: yaw + at 17.04°, triangle 2 [1,28,29], the sheet steps 78.00 level(s) across it + first to fold: yaw + at 17.04°, triangle 2 [1,28,29], the sheet steps 78.00 level(s) across it, which is 0.468 of the range this mesh sampled ``` ⭐ **And the side it folds on is the side the head has turned it away from.** A diff --git a/selftest.ts b/selftest.ts index 6dec80c..3b8c743 100644 --- a/selftest.ts +++ b/selftest.ts @@ -8465,6 +8465,279 @@ function runContourMeshSuite(): number { 'every one of these compiles to a plausible number with correct arithmetic behind it — the coverage case is the loudest, since a sheet cut to the art gives the whole rim the background depth and folds the silhouette away from the turn', ); + // --- a sheet that is opaque everywhere, and the count that replaced the + // silence (issue #449) --------------------------------------------- + // + // 🚨 The hole these two cases close. The coverage refusal above is gated on + // the sheet having a transparent texel somewhere, so a sheet that is opaque + // EVERYWHERE — which is what monocular depth estimation produces, a + // full-frame render with the background in it — skipped it entirely. One + // depth field stored two ways was a named refusal in one and a green build + // in the other, and `range` did not show it: a map that is half background + // has exactly as full a range as one that is all subject. + // + // The fixture is chosen so that neither the answer nor the oracle is a + // measured number: + // + // - the art is a stated RECTANGLE rather than the blob, so "does the part + // draw here" is a predicate this file can evaluate itself; + // - every lattice vertex sits on a texel centre whose whole 2x2 bilinear + // footprint is on one side of that rectangle, so the footprint the + // compiler walks and the predicate at the vertex cannot disagree — + // which is what makes the count checkable at all; + // - the two lattices are the whole window and the interior, so the "some + // outside" and "none outside" answers are 56 and 0 and can never print + // the same figure. + { + /** The art: a rectangle whose edges avoid every lattice vertex's footprint. */ + const RECT = { x0: 20, x1: 78, y0: 12, y1: 54 }; + const rectArt = (x: number, y: number): boolean => x >= RECT.x0 && x < RECT.x1 && y >= RECT.y0 && y < RECT.y1; + // One texel wider and taller than the blob's window, so both lattices below + // divide the span evenly and land on texel centres: 96 = 8·12 and 64 = 8·8. + const UNDRAWN_W = 97; + const UNDRAWN_H = 65; + /** + * `writeDepthSheet`'s own `ramp`, written out here so the OPAQUE sheet and + * the `tight` one carry byte-identical levels and differ only in alpha. + * That the two really are one depth field is asserted below by digest, not + * assumed from this line: `depthDigest` is taken over the levels alone. + */ + const ramp = (x: number): number => Math.round((x / (UNDRAWN_W - 1)) * 255); + const centresOn = (n: number, first: number, step: number, span: number): number[] => + Array.from({ length: n }, (_, i) => (first + step * i) / span); + // Texel columns 0, 12 … 96 and rows 0, 8 … 64. Inside the rectangle: the + // five columns 24 … 72 and the five rows 16 … 48. + const wholeUs = centresOn(9, 0.5, 12, UNDRAWN_W); + const wholeVs = centresOn(9, 0.5, 8, UNDRAWN_H); + const innerUs = wholeUs.slice(2, 7); + const innerVs = wholeVs.slice(2, 7); + const undrawnRig = (us: number[], vs: number[], sheet: 'opaque' | 'tight') => + buildContourRig( + { + type: 'mesh', + image: 'blob.png', + generator: { kind: 'grid', us, vs, depth: { image: 'blob_depth.png', near: 'white', zScale: DEPTH_Z_SCALE } }, + }, + { + art: rectArt, + width: UNDRAWN_W, + height: UNDRAWN_H, + invariants: { meshSlots: 1, meshTriangles: 200 }, + ...(sheet === 'opaque' ? { depthLevels: (x: number) => ramp(x) } : { depth: { kind: 'tight' as const } }), + }, + ); + /** How many of a build's OWN emitted vertices sit on a texel the rectangle does not draw. */ + const offArt = (build: ContourBuild): number => + contourPointsOf(loadedContourMesh(build).mesh).filter(([x, y]) => !rectArt(Math.floor(x), Math.floor(y))).length; + + const whole = undrawnRig(wholeUs, wholeVs, 'opaque'); + const inner = undrawnRig(innerUs, innerVs, 'opaque'); + const innerTight = undrawnRig(innerUs, innerVs, 'tight'); + let cutRefusal: string | null = null; + try { + undrawnRig(wholeUs, wholeVs, 'tight'); + } catch (err) { + cutRefusal = err instanceof CompileError ? err.message : `NOT a CompileError: ${(err as Error).message}`; + } + + { + const wholeDepth = whole.result.meshes[0].depth; + const vertices = whole.result.meshes[0].vertices; + // The two independent readings of the same fact, neither of them a + // literal: the lattice's own arithmetic, and the predicate applied to the + // vertices the ARTIFACT carries. + const byLattice = wholeUs.length * wholeVs.length - innerUs.length * innerVs.length; + const byArtifact = offArt(whole); + // ⭐ The refusal counts the same vertices from the other input, so the + // number it names has to be the number the report now prints. That is the + // whole of #449 in one comparison: one defect, two encodings, and until + // this case the second one printed nothing at all. + const named = cutRefusal === null ? null : /does not cover (\d+) of the mesh's (\d+)/.exec(cutRefusal); + const agreed = + wholeDepth !== undefined && + wholeDepth.undrawn === byLattice && + wholeDepth.undrawn === byArtifact && + named !== null && + Number(named[1]) === wholeDepth.undrawn && + Number(named[2]) === vertices && + wholeDepth.undrawn > 0 && + // Not vacuous: the sheet is a real one that reports a real ceiling. + wholeDepth.ceiling.yaw.positive !== null && + // And `range` is exactly as full as it would be on a mesh entirely on + // the art, which is the claim the module header used to make backwards. + wholeDepth.range[1] - wholeDepth.range[0] > 0; + say( + 'DP05_A_SHEET_OPAQUE_EVERYWHERE_COUNTS_THE_VERTICES_THAT_SAMPLE_UNDRAWN_ART', + agreed, + `a ${wholeUs.length}x${wholeVs.length} lattice over a ${UNDRAWN_W}x${UNDRAWN_H} window whose art is the ` + + `rectangle [${RECT.x0}, ${RECT.x1}) x [${RECT.y0}, ${RECT.y1}): the report says ` + + `${wholeDepth?.undrawn} of ${vertices} vertices sample a texel the part does not draw, the lattice's own ` + + `arithmetic says ${byLattice}, and the predicate over the ${vertices} vertices read back through ` + + `spine-core says ${byArtifact}. The same field with its alpha cut to the same rectangle is REFUSED at ` + + `${named === null ? 'NO COUNT — the coverage refusal did not fire' : `${named[1]} of ${named[2]}`}, and ` + + `the sheet reports range [${wholeDepth?.range[0]}, ${wholeDepth?.range[1]}] with a ` + + `${wholeDepth?.ceiling.yaw.positive?.degrees.toFixed(2)}° yaw ceiling — a full range and a plausible angle, ` + + 'which is what a build in this shape printed and all it printed', + 'a depth sheet produced by estimation is opaque everywhere, so the coverage refusal has nothing to hold it ' + + 'to and skips — and the defect it exists to catch is still there. `range` was claimed to be where that ' + + 'shows up and it is not: a background level is a legitimate depth, so half a mesh reading background ' + + 'reports exactly as healthy a range as none of it does', + ); + } + + { + // 🔒 The negative control, and it is the load-bearing half. A count that + // fired on every rig would separate nothing, and this one is easy to get + // wrong in the generous direction — the bilinear footprint reaches a + // texel past the vertex, so a lattice on the rectangle's own edge would + // report its whole border. + const innerDepth = inner.result.meshes[0].depth; + const explained = runCli([ + 'explain', + '--rig', + inner.opts.rigPath, + '--motion', + inner.opts.motionPath, + '--out', + inner.opts.outDir, + '--images', + inner.dir, + ]); + const wholeExplained = runCli([ + 'explain', + '--rig', + whole.opts.rigPath, + '--motion', + whole.opts.motionPath, + '--out', + whole.opts.outDir, + '--images', + whole.dir, + ]); + const LINE = 'sample a texel the part image does not draw'; + const quiet = explained.status === 0 && !explained.stdout.includes(LINE); + const loud = wholeExplained.status === 0 && wholeExplained.stdout.includes(LINE); + const sameField = innerDepth?.digest === whole.result.meshes[0].depth?.digest; + const sameFieldCut = innerTight.result.meshes[0].depth?.digest === innerDepth?.digest; + const ok = + innerDepth !== undefined && + innerDepth.undrawn === 0 && + offArt(inner) === 0 && + // Not vacuous: this mesh sampled a real slope and reports a real ceiling. + innerDepth.ceiling.yaw.positive !== null && + innerDepth.range[1] > innerDepth.range[0] && + quiet && + loud && + sameField && + sameFieldCut && + // The interior lattice is inside the ART, so the alpha-cut encoding of + // the same field covers it and is NOT refused — the refusal is about + // coverage and the count is about the drawing, and this is where the + // two part company. + innerTight.result.meshes[0].depth?.undrawn === 0; + say( + 'DP06_A_MESH_ENTIRELY_ON_THE_ART_REPORTS_ZERO_AND_GAINS_NO_LINE', + ok, + `the interior ${innerUs.length}x${innerVs.length} lattice of the same window: ${innerDepth?.undrawn} ` + + `undrawn by the report and ${offArt(inner)} by the predicate over the emitted vertices, on a mesh that ` + + `still measures a ${innerDepth?.ceiling.yaw.positive?.degrees.toFixed(2)}° ceiling over range ` + + `[${innerDepth?.range[0]}, ${innerDepth?.range[1]}]; \`explain\` prints no such line for it ` + + `${quiet ? '' : '— IT DID '}and prints one for the whole-window lattice ${loud ? '' : '— IT DID NOT '}` + + `beside it. The three sheets are one depth field, by digest: ${innerDepth?.digest} opaque, ` + + `${innerTight.result.meshes[0].depth?.digest} cut to the art ` + + `${sameFieldCut ? '(identical, so only the alpha differs)' : '(DIFFERENT — these are two fields)'}, and ` + + `the cut encoding of THIS lattice is not refused at all, because the art covers it`, + 'a count that fires on every rig is not a count, and this one has two easy ways to fire on everything: the ' + + 'footprint reaches one texel past the vertex, and a `contour` mesh is pushed outside the silhouette by ' + + 'design. Zero has to be reachable and it has to be silent, or the line stops meaning anything the first ' + + 'time an author sees it on a rig that is correct', + ); + } + + { + // 🔸 The count on a `contour`, and the clause that stops it reading as a + // fault. Its outline is pushed `margin` pixels OUTSIDE the silhouette, so + // every vertex of one takes its depth from where the part draws nothing — + // the count is the whole mesh on a correctly authored rig, whatever the + // sheet's alpha is, and a diagnostic that says "15 of 15" on every + // contour rig is one authors learn to ignore. + // + // ⭐ The line says WHY instead, and rigc is entitled to: it built that + // outline. `buildContourMesh` returns `hullVertices: points.length` over + // `offsetPolygon(simplified, margin)`, so "every vertex is traced + // outline" is what the generator returns rather than something read back + // off the geometry. This case holds it to the ARTIFACT anyway — the + // emitted `hull` against the emitted vertex count — because a claim the + // report makes about the mesh's shape has to be true of the mesh. + // + // ⛔ What this is NOT is a margin discount. Subtracting the margin would + // mean borrowing a number authored for the TRACE to mean "close enough" + // for the SHEET, which is the guess this compiler refuses to make: on a + // dilated sheet the rim's depth is the art's and the count is noise, on a + // full-frame estimate the rim's depth is the background and the count is + // the failure, and nothing in the part's alpha tells those apart. + const tracedBuild = buildContourRig( + depthAttachment({ image: 'blob_depth.png', near: 'white', zScale: DEPTH_Z_SCALE }), + { depth: { kind: 'ramp' } }, + ); + const traced = tracedBuild.result.meshes[0]; + interface HulledSkin { + skins: Array<{ attachments: { blob: { blob: { uvs: number[]; hull: number } } } }>; + } + const emittedMesh = (JSON.parse(tracedBuild.result.skeletonText) as HulledSkin).skins[0].attachments.blob.blob; + const emittedVertices = emittedMesh.uvs.length / 2; + const tracedLine = runCli([ + 'explain', + '--rig', + tracedBuild.opts.rigPath, + '--motion', + tracedBuild.opts.motionPath, + '--out', + tracedBuild.opts.outDir, + '--images', + tracedBuild.dir, + ]); + const gridLine = runCli([ + 'explain', + '--rig', + whole.opts.rigPath, + '--motion', + whole.opts.motionPath, + '--out', + whole.opts.outDir, + '--images', + whole.dir, + ]); + const TOPOLOGY = 'a contour\'s vertices are all traced outline, pushed out by the margin'; + const SHEET = 'their z is the sheet\'s reading of somewhere the part is not'; + // Two-sided on both halves: each kind has to carry its own clause AND not + // carry the other's. A report that appended both would satisfy either + // half alone and attribute a grid's lattice border to a margin it has not + // got. + const contourSays = tracedLine.status === 0 && tracedLine.stdout.includes(TOPOLOGY) && !tracedLine.stdout.includes(SHEET); + const gridSays = gridLine.status === 0 && gridLine.stdout.includes(SHEET) && !gridLine.stdout.includes(TOPOLOGY); + say( + 'DP07_A_CONTOUR_ATTRIBUTES_ITS_COUNT_TO_THE_OUTLINE_IT_TRACED_AND_A_GRID_DOES_NOT', + traced.vertices > 0 && + traced.depth?.undrawn === traced.vertices && + emittedVertices === traced.vertices && + emittedMesh.hull === emittedVertices && + contourSays && + gridSays, + `a contour at margin ${CONTOUR_MARGIN} over an opaque sheet: ${traced.depth?.undrawn} of ` + + `${traced.vertices} vertices sample a texel the part does not draw, and the emitted mesh declares hull ` + + `${emittedMesh.hull} of ${emittedVertices} vertices — every one of them outline, which is what the ` + + `report attributes the count to${contourSays ? '' : ' — BUT IT DOES NOT SAY SO'}. The whole-window grid ` + + `beside it says the sheet clause and not the topology one${gridSays ? '' : ' — IT DOES NOT'}, and the ` + + 'interior lattice says neither, because it has nothing to report', + 'a line that reads "15 of 15" on every correct contour rig is one an author stops reading, and the fix is ' + + 'not to subtract the margin — it is that rigc BUILT that outline and can say so. On a grid the same ' + + 'sentence would be a lie: a lattice border is where the author\'s `us`/`vs` put it, not where a margin ' + + 'did, so the attribution is absent there and the count stands alone', + ); + } + } + // --- the soft region, and why it is painted (issue #382) ----------------- // // A physics constraint answers an impact over exactly the vertices a mask @@ -9095,6 +9368,180 @@ function runContourMeshSuite(): number { 'real answer is anything at all, which the author cannot see unless the report says how many levels it read', ); } + + // A form and a cliff, told apart by the step over the range (issue #448). + // + // 🚨 The reading neither figure above can give. On an estimated depth sheet + // over cut-out art the ceiling is set by the OCCLUSION boundary rather than + // by the drawing's form, and both existing diagnostics call that healthy: + // `p1/degrees` reads as a band, because an outline is a band, and + // `depthStep` reads as plenty said, because the sheet said the whole + // distance from figure to background in one step. Divided by the range they + // say the opposite. + // + // ⚠️ And the ceiling is not lying when they do. A discontinuity simply has + // no slope to converge to, so the angle halves with every doubling of the + // lattice and describes nothing at any density — which is the property + // these two fixtures are built to show, on the same lattice ladder: + // + // the form — a raised cosine along x. Its slope is bounded, so the step + // halves with the cell and the ceiling settles. + // the cliff — half that cosine plus a jump of the other half at one + // texel column. The jump is there at every density, so the + // step stops shrinking and the ceiling keeps halving. + // + // The window is one texel wider and taller than the blob's so that 96 and + // 64 divide by 8, 16 and 32: every vertex of all three lattices lands on a + // texel centre, and the planted column sits strictly between two of them at + // every rung. + { + const REFINE_W = 97; + const REFINE_H = 65; + const REFINE_ZSCALE = 50; + /** + * Levels the cosine spans, and the base it sits on. + * + * ⚠️ Even, and every level below is rounded to a whole number before it + * is written. `depthLevels` hands its result straight to a `Uint8Array`, + * which TRUNCATES — so a fixture stating halves would plant a cliff of 95 + * levels while this file believed it had planted 95.5. + */ + const AMP = 190; + const FLOOR_LEVEL = 64; + /** + * A base above zero on purpose. With the sheet reaching level 0 the range + * and its upper end are the same number, so a `stepShare` that divided by + * `max z` instead of by `max − min` would read identically and this whole + * ladder would pass on it. + */ + const raised = (x: number): number => 0.5 * (1 - Math.cos((2 * Math.PI * (x + 0.5)) / REFINE_W)); + const formSheet = (x: number): number => Math.round(FLOOR_LEVEL + AMP * raised(x)); + /** Not a multiple of 3, 6 or 12, so no lattice vertex ever lands on it. */ + const CLIFF_COLUMN = 50; + const cliffSheet = (x: number): number => + Math.round(FLOOR_LEVEL + (AMP / 2) * raised(x)) + (x >= CLIFF_COLUMN ? AMP / 2 : 0); + /** + * What the planted cliff is worth as a share, by construction rather than + * by measurement: it rises half of the levels the sheet spans. + */ + const PLANTED_SHARE = AMP / 2 / AMP; + const refineRig = (cells: number, level: (x: number, y: number) => number): ContourBuild => + buildContourRig( + { + type: 'mesh', + image: 'blob.png', + generator: { + kind: 'grid', + us: centres(cells + 1, 0.5, 96 / cells, REFINE_W), + vs: centres(cells + 1, 0.5, 64 / cells, REFINE_H), + depth: { image: 'blob_depth.png', near: 'white', zScale: REFINE_ZSCALE }, + }, + }, + { + art: () => true, + width: REFINE_W, + height: REFINE_H, + depthLevels: level, + invariants: { meshSlots: 1, meshTriangles: 4000 }, + }, + ); + const tightest = (build: ContourBuild): FoldLimit | null => { + const c = build.result.meshes[0].depth?.ceiling; + if (c === undefined) return null; + const all = [c.yaw.positive, c.yaw.negative, c.pitch.positive, c.pitch.negative].filter( + (f): f is FoldLimit => f !== null, + ); + return all.length === 0 ? null : all.reduce((a, b) => (b.degrees < a.degrees ? b : a)); + }; + const RUNGS = [8, 16, 32]; + const ladder = (level: (x: number, y: number) => number): Array<{ build: ContourBuild; limit: FoldLimit | null }> => + RUNGS.map((cells) => { + const build = refineRig(cells, level); + return { build, limit: tightest(build) }; + }); + const form = ladder(formSheet); + const cliff = ladder(cliffSheet); + const tan = (deg: number): number => Math.tan((deg * Math.PI) / 180); + const ratios = (rungs: typeof form, of: (l: FoldLimit) => number): number[] => + rungs.slice(0, -1).map((r, i) => { + const a = r.limit; + const b = rungs[i + 1].limit; + return a === null || b === null ? NaN : of(a) / of(b); + }); + const shares = (rungs: typeof form): number[] => rungs.map((r) => r.limit?.stepShare ?? NaN); + const formShares = shares(form); + const cliffShares = shares(cliff); + const formShareRatio = ratios(form, (l) => l.stepShare); + const cliffShareRatio = ratios(cliff, (l) => l.stepShare); + const formTanRatio = ratios(form, (l) => tan(l.degrees)); + const cliffTanRatio = ratios(cliff, (l) => tan(l.degrees)); + // Every band is stated here with room on both sides and nothing in `src/` + // reads any of them. Measured on this fixture: the form's share ratio is + // 1.86 and 2.00 with a tangent ratio of 1.07 and 1.00; the cliff's share + // ratio is 0.94 and 0.98 with a tangent ratio of 2.25 and 2.07. + const HALVES: [number, number] = [1.6, 2.4]; + const HOLDS: [number, number] = [0.85, 1.15]; + const SETTLES: [number, number] = [0.85, 1.25]; + const DOUBLES: [number, number] = [1.7, 2.5]; + /** How near the finest rung has to land on the share that was planted. */ + const PLANTED_TOLERANCE = 0.05; + const within = (v: number, [lo, hi]: [number, number]): boolean => Number.isFinite(v) && v >= lo && v <= hi; + const every = (vs: number[], band: [number, number]): boolean => vs.length > 0 && vs.every((v) => within(v, band)); + // A share is a fraction of the mesh's own range and cannot exceed one. + // The clause that catches the arithmetic being skipped altogether, which + // every ratio above survives: an unnormalised step keeps its ratios. + const bounded = [...formShares, ...cliffShares].every((s) => Number.isFinite(s) && s > 0 && s <= 1); + const separated = Math.min(...cliffShares) > Math.max(...formShares); + const planted = Math.abs(cliffShares[cliffShares.length - 1] - PLANTED_SHARE) <= PLANTED_TOLERANCE; + // The block an author reads has to carry the figure the struct holds — + // a control on the struct alone passes a report that prints the wrong one. + const finest = form[form.length - 1].build; + const explained = runCli([ + 'explain', + '--rig', + finest.opts.rigPath, + '--motion', + finest.opts.motionPath, + '--out', + finest.opts.outDir, + '--images', + finest.dir, + ]); + const printed = `which is ${(form[form.length - 1].limit?.stepShare ?? NaN).toFixed(3)} of the range this mesh sampled`; + const inReport = explained.status === 0 && explained.stdout.includes(printed); + const ok = + bounded && + separated && + planted && + inReport && + every(formShareRatio, HALVES) && + every(cliffShareRatio, HOLDS) && + every(formTanRatio, SETTLES) && + every(cliffTanRatio, DOUBLES) && + form.every((r) => r.limit !== null) && + cliff.every((r) => r.limit !== null) && + form[0].build.result.meshes[0].depth?.digest !== cliff[0].build.result.meshes[0].depth?.digest; + const table = (name: string, rungs: typeof form, sh: number[]): string => + `${name} ${RUNGS.map((cells, i) => `grid-${cells + 1} ${rungs[i].limit?.degrees.toFixed(2) ?? 'none'}°/${sh[i].toFixed(4)}`).join(' ')}`; + say( + 'TC07_A_PLANTED_CLIFF_PINS_THE_STEP_SHARE_WHILE_A_FORMS_HALVES_UNDER_THE_SAME_REFINEMENT', + ok, + `${table('form', form, formShares)} (share x${formShareRatio.map((r) => r.toFixed(3)).join(', x')} per ` + + `doubling, tangent x${formTanRatio.map((r) => r.toFixed(3)).join(', x')}); ` + + `${table('cliff', cliff, cliffShares)} (share x${cliffShareRatio.map((r) => r.toFixed(3)).join(', x')}, ` + + `tangent x${cliffTanRatio.map((r) => r.toFixed(3)).join(', x')}). The cliff was planted at ` + + `${PLANTED_SHARE.toFixed(3)} of the sheet's own range and the finest lattice reads ` + + `${cliffShares[cliffShares.length - 1].toFixed(4)}${planted ? '' : ' — NOT what was planted'}; no cliff ` + + `reading falls to any form reading (${Math.min(...cliffShares).toFixed(4)} against ` + + `${Math.max(...formShares).toFixed(4)}${separated ? '' : ' — NOT SEPARATED'}), every reading is a ` + + `fraction${bounded ? '' : ' — ONE IS NOT'}, and \`explain\` prints "${printed}"` + + `${inReport ? '' : ' — IT DOES NOT'}`, + 'a discontinuity has no slope to converge to, so its ceiling halves with the lattice and means nothing at ' + + 'any density — and the two figures already in the report both read it as healthy, a band reaching the ' + + 'limit together with plenty of levels across it. The form is the half that must NOT fire: a diagnostic ' + + 'that pinned on both would separate nothing, and one that fell on both would miss the cliff', + ); + } } // --- the lattice, generated instead of hand-numbered (issue #382) -------- diff --git a/src/compile.ts b/src/compile.ts index 5c5fe55..01b7ce0 100644 --- a/src/compile.ts +++ b/src/compile.ts @@ -910,6 +910,21 @@ function partPlate(img: CompiledImage): Plate { return img.atlas === undefined ? page : extractRegion(page, img.atlas); } +/** + * A part's alpha channel on its own grid — where the drawing is, and where it + * is not. + * + * Two readers want exactly this and they used to be one inline loop and one + * absence: the contour generator traces it, and `sampleMeshDepth` counts the + * vertices whose depth came from outside it (issue #449). One function so the + * two cannot come to mean different things by "the part draws here". + */ +function plateAlpha(plate: Plate): Uint8Array { + const alpha = new Uint8Array(plate.width * plate.height); + for (let i = 0; i < alpha.length; i++) alpha[i] = plate.data[i * 4 + 3]; + return alpha; +} + export function compile(opts: CompileOptions): CompileResult { const rigPath = resolve(opts.rigPath); const motionPath = resolve(opts.motionPath); @@ -2711,9 +2726,40 @@ function buildGeneratedMesh( * sheet that carries an alpha channel is held to it, and the fix — dilate the * sheet past the mesh margin — is named in the message. * - * A sheet with no alpha channel covers its whole grid by construction and the - * third check has nothing to test; what the background level means is then the - * author's statement, and `range` in the report is where it shows up. + * ## The count beside the third refusal, and why it is a count (issue #449) + * + * A sheet with no transparent texel anywhere covers its whole grid by + * construction, so the third refusal has nothing to hold it to and skips. That + * is the encoding a monocular depth estimator produces — a full-frame opaque + * render with the background in it — and it is a legitimate statement, so + * skipping the refusal is right. What was wrong is that nothing took its place: + * measured on one cell and one depth field, the same 54 %-background mesh is a + * named refusal when the sheet's alpha is cut to the art and a green build when + * it is 255 everywhere. + * + * ⚠️ **`range` is not where that shows up**, and this header said it was until + * #449 measured it. On the build above `range` reads `[0, 223.97]` of a stated + * 224 — full, healthy — because the background is a legitimate depth value and + * a map that is half background has exactly as full a range as one that is all + * subject. "Covers its whole grid" is true and about the wrong grid: the + * question was never coverage of the *sheet*, it was whether the mesh is + * sampling **art**. + * + * ⇒ what shows it is `undrawn`: how many of the mesh's vertices take their + * depth from a texel **the part image does not draw**, over the same four + * bilinear taps the refusal walks. That is a measurement, not a guess, so it is + * reported and never refused — a full-frame sheet is a statement rigc has no + * authority to guess away. + * + * 🔸 It is a raw count with no reach subtracted from it, which is why a + * `contour` mesh reports most or all of its vertices: its outline is pushed + * `margin` pixels outside the silhouette by design, and out there the part + * draws nothing. That is the true reading of that geometry rather than a defect + * in the count — the rim's depth really did come from off the art, which is + * benign on a sheet dilated past the margin and is the whole failure on a + * full-frame estimate. Discounting the margin would be borrowing a number + * authored for the trace to mean "close enough" for the sheet, and rigc does + * not invent tolerances. */ function sampleMeshDepth( spec: NonNullable, { kind: 'contour' }>['depth']>, @@ -2734,6 +2780,14 @@ function sampleMeshDepth( * units `z` is already in before it is taken. */ toBind: (px: number, py: number) => readonly [number, number], + /** + * The PART's own alpha channel, in the same grid the sheet is sampled in — + * what the drawing actually covers, as against what the sheet covers. + * + * The two are different questions and the header says why. This one has no + * refusal behind it: it is counted, reported, and left to the author. + */ + partAlpha: Uint8Array, partWidth: number, partHeight: number, where: string, @@ -2771,19 +2825,32 @@ function sampleMeshDepth( // Every texel a bilinear tap touches has to be covered, not just the nearest // one: a vertex half a pixel outside the sheet blends real depth with the // background and lands somewhere neither states. + // + // 🔒 One walk, one footprint, two readings. `cover` is the SHEET's alpha and + // decides a refusal; `partAlpha` is the PART's and decides a count. They are + // deliberately the same four taps and the same clamp — the second is the + // first's instinct applied to the other input (issue #449), and writing it as + // a second loop is how the two would come to disagree about which texels a + // vertex reads. + const cx = (i: number): number => (i < 0 ? 0 : i > partWidth - 1 ? partWidth - 1 : i); + const cy = (j: number): number => (j < 0 ? 0 : j > partHeight - 1 ? partHeight - 1 : j); const uncovered: number[] = []; - if (!opaqueEverywhere) { - const cx = (i: number): number => (i < 0 ? 0 : i > partWidth - 1 ? partWidth - 1 : i); - const cy = (j: number): number => (j < 0 ? 0 : j > partHeight - 1 ? partHeight - 1 : j); - for (let v = 0; v < points.length; v++) { - const x0 = Math.floor(points[v][0] - 0.5); - const y0 = Math.floor(points[v][1] - 0.5); - let covered = true; - for (const [dx, dy] of [[0, 0], [1, 0], [0, 1], [1, 1]] as const) { - if (cover[cy(y0 + dy) * partWidth + cx(x0 + dx)] !== 255) covered = false; - } - if (!covered) uncovered.push(v); + let undrawn = 0; + for (let v = 0; v < points.length; v++) { + const x0 = Math.floor(points[v][0] - 0.5); + const y0 = Math.floor(points[v][1] - 0.5); + let covered = true; + let drawn = true; + for (const [dx, dy] of [[0, 0], [1, 0], [0, 1], [1, 1]] as const) { + const at = cy(y0 + dy) * partWidth + cx(x0 + dx); + if (cover[at] !== 255) covered = false; + // Zero, not a threshold: "the part image draws nothing here" is a fact + // about the file, and any other cut-off would be rigc deciding how faint + // a texel has to be before it stops counting as art. + if (partAlpha[at] === 0) drawn = false; } + if (!opaqueEverywhere && !covered) uncovered.push(v); + if (!drawn) undrawn++; } if (uncovered.length > 0) { const first = uncovered[0]; @@ -2825,6 +2892,7 @@ function sampleMeshDepth( zScale: spec.zScale, tone, range: [r6(lo), r6(hi)], + undrawn, ceiling: turnCeiling( points.map(([px, py]) => toBind(px, py)), z, @@ -3064,6 +3132,7 @@ function buildGridAttachment( geometry.points, geometry.triangles, (px, py) => toBoneLocal(anchor, anchor.worldX + px * toArt - w / 2, anchor.worldY + h / 2 - py * toArt), + plateAlpha(plate), plate.width, plate.height, where, @@ -3160,8 +3229,7 @@ function buildContourAttachment( // none — so "this part has no silhouette to trace" is a question about pixels, // and `buildContourMesh` refuses it by counting them. const plate = partPlate(img); - const alpha = new Uint8Array(plate.width * plate.height); - for (let i = 0; i < alpha.length; i++) alpha[i] = plate.data[i * 4 + 3]; + const alpha = plateAlpha(plate); const margin = generator.margin ?? CONTOUR_DEFAULTS.margin; const maxVertices = generator.maxVertices ?? CONTOUR_DEFAULTS.maxVertices; @@ -3219,6 +3287,7 @@ function buildContourAttachment( geometry.points, geometry.triangles, (px, py) => toBoneLocal(anchor, anchor.worldX + px * toArt - w / 2, anchor.worldY + h / 2 - py * toArt), + alpha, plate.width, plate.height, where, diff --git a/src/depth.ts b/src/depth.ts index b1890ef..fa99d34 100644 --- a/src/depth.ts +++ b/src/depth.ts @@ -374,14 +374,20 @@ const CEILING_AREA_FLOOR = 1e-6; * Where one triangle turns inside out, which triangle that is — and, beside it, * what the REST of this axis and side's triangles do. * - * The first four fields are about one triangle. `count` and `p1` are about the - * population it is the minimum of, and they are here because the minimum alone - * cannot answer the question an author actually has: `degrees` is the same - * number whether a whole band of the mesh reaches the limit together or one - * triangle does, and those are a form and a bad texel respectively + * Everything down to `stepShare` is about one triangle. `count` and `p1` are + * about the population it is the minimum of, and they are here because the + * minimum alone cannot answer the question an author actually has: `degrees` is + * the same number whether a whole band of the mesh reaches the limit together + * or one triangle does, and those are a form and a bad texel respectively * ([#412](https://github.com/firejune/rigc/issues/412), * `bench/studies/2026-09-05-noise` §6). * + * ⚠️ And a band is not sufficient evidence of a form either, which is what + * `stepShare` is here for: an OUTLINE is a band, so a mesh whose ceiling is set + * by the occlusion edge of a cut-out reads `p1/degrees` near 1 and a `depthStep` + * of most of the sheet's range — both of the older figures reading *healthy* on + * the same measurement ([#448](https://github.com/firejune/rigc/issues/448)). + * * ⛔ Neither figure changes `degrees`, and neither is a threshold. rigc does not * have the authority to guess its input away, so nothing here filters, * smooths or rejects a sample — the ceiling stays the raw sheet read through @@ -407,6 +413,42 @@ export interface FoldLimit { * with no form left in it (`bench/studies/2026-09-05-noise` §3). */ depthStep: number; + /** + * `depthStep` over the depth range this mesh actually sampled — what fraction + * of everything the sheet said across the whole part it said across the one + * triangle that folds first. + * + * ⭐ The figure that tells a form from a cliff, and it is the one reading the + * other two cannot give ([#448](https://github.com/firejune/rigc/issues/448)). + * A form has a slope, so refining the lattice halves the step and halves this + * with it while the angle converges. A **discontinuity has no slope**: the + * step stays the whole range however fine the lattice gets, this figure pins + * near 1, and the ceiling halves with every doubling instead of converging — + * `tan t ∝ h`, an angle that describes nothing at any density. + * + * ⚠️ **The ceiling is not wrong when this reads high; the input is not a + * surface.** Measured on estimated sheets: reported 1.936°, and the runtime + * admits +1° and reverses 8 triangles at +2°. The rig genuinely folds at two + * degrees. What a `depthStep` near the whole range means is an occlusion + * boundary — figure against background, or one part of a figure over + * another — and a 2.5D turn does not model occlusion at all, so **no angle is + * the right one to quote for it**. The fix is upstream of the ceiling: mesh + * only what is continuous, or state a sheet that was authored rather than + * estimated. + * + * 🔒 In (0, 1] by construction, never a division by zero. `depthStep` is a + * difference between two of this mesh's own `z` values, so the span over all + * of them is at least as large; and a fold only exists where the axis area + * with `z` substituted in is non-zero, which needs two vertices of the + * triangle at different depths — so a mesh with no span reports no fold and + * never reaches the divide. + * + * ⛔ A report and never a threshold. rigc does not decide that an author's + * sheet is the wrong kind of thing; nothing here filters, and nothing here + * moves a ceiling. What to read off the number is stated in + * `docs/AUTHORING.md` §3.4. + */ + stepShare: number; /** How many triangles fold on this axis and side — the population below. */ count: number; /** @@ -509,9 +551,26 @@ export interface TurnCeiling { * triangle, which is a texel. A `depthStep` of one level is `atan(255·h/zScale)` * and says nothing about the form at all. * - * ⛔ Both are reports. Nothing here filters the sheet, and nothing here moves a - * ceiling: a smoothed measurement would describe a surface the deform key is - * not built from, and would part company with the gate that reads the raw one. + * ## What a band cannot say either (issue #448) + * + * Both of those figures read *healthy* on an estimated depth sheet over cut-out + * art, and they do not merely stay silent — they affirm it. `p1/degrees` comes + * back at 1.02–2.17, which reads as a band; `depthStep` at 148–252 levels of + * 255, which reads as plenty said. It **is** a band, because an outline is long, + * and the sheet did say a great deal across that triangle — it said the whole + * distance from the figure to the background in one step. + * + * So each `FoldLimit` also carries `stepShare`, the same step divided by the + * range this mesh sampled. A form's halves per refinement while its angle + * converges; a discontinuity's pins near 1 while the angle halves. Measured: + * rigc's own gallery reads 0.112 and 0.468, a synthetic raised cosine 0.394 + * falling to 0.027 under refinement, the same cosine with one planted cliff a + * flat 0.50, and estimated sheets 0.92–0.99. + * + * ⛔ All three are reports. Nothing here filters the sheet, and nothing here + * moves a ceiling: a smoothed measurement would describe a surface the deform + * key is not built from, and would part company with the gate that reads the + * raw one. * * @param points Vertices in the BIND space the deform offsets are authored in. * Areas are translation-invariant, so the origin does not matter; the scale @@ -546,6 +605,18 @@ export function turnCeiling( } const floor = largest * CEILING_AREA_FLOOR; + // The denominator `stepShare` is taken against: the depth range this mesh + // sampled, which is `range` in the report read off the same array. Taken over + // the WHOLE mesh rather than per triangle, because the question the figure + // answers is how much of what the sheet said here one triangle said. + let zLo = Infinity; + let zHi = -Infinity; + for (const d of z) { + if (d < zLo) zLo = d; + if (d > zHi) zHi = d; + } + const zSpan = zHi - zLo; + // One list per axis and side, so the ceiling can say whether it is the floor // of a BAND or of a single triangle. Nothing here filters: every measurable // triangle goes in exactly once, in triangle order, and the sort below is @@ -591,7 +662,11 @@ export function turnCeiling( // `count` and `p1` are filled once the whole population is in; a minimum // cannot know its own percentile while it is still being found. if (held === null || degrees < held.degrees) { - out[axis][side] = { degrees, triangle: n, ids, depthStep, count: 0, p1: null }; + // `zSpan` cannot be zero here: `aAxis !== 0` needs two of this + // triangle's vertices at different depths, and the span over the whole + // mesh is at least that difference. A guard would be an unreachable + // branch, and an unreachable branch is not a control. + out[axis][side] = { degrees, triangle: n, ids, depthStep, stepShare: depthStep / zSpan, count: 0, p1: null }; } } } diff --git a/src/types.ts b/src/types.ts index c36b6cd..d417e90 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1057,15 +1057,6 @@ export interface CompileResult { * authored mesh was not traced. */ holePixels?: number; - /** - * What a depth map put on this mesh's vertices, when one was named. - * - * The digest is over the levels rather than the file, so a re-encode of the - * same sheet reports the same provenance; `range` is what was actually - * sampled, which is the number that says whether the map covers the part or - * a corner of it. Absent when no map was named — never zeroes, which would - * read as "sampled and found flat". - */ /** * The soft region a `soft` block carried to its own bone, when one was * named — the mask, its digest, and how many vertices it reached. @@ -1075,6 +1066,18 @@ export interface CompileResult { * and a nose does not wobble. */ soft?: { mask: string; digest: string; bone: string; carried: number; ramped: number }; + /** + * What a depth map put on this mesh's vertices, when one was named. + * + * The digest is over the levels rather than the file, so a re-encode of the + * same sheet reports the same provenance; `range` is what was actually + * sampled. Absent when no map was named — never zeroes, which would read as + * "sampled and found flat". + * + * ⚠️ This comment sat above `soft` rather than above the field it describes + * until issue #449 came to add to it, which is the same drift `CUR07` was + * built for one file over — nothing derives a doc comment's neighbour. + */ depth?: { /** The sheet, as written in the spec. */ image: string; @@ -1086,6 +1089,24 @@ export interface CompileResult { tone: { gamma: number; contrast: number; bias: number }; /** Least and greatest `z` over the mesh's vertices, in attachment units. */ range: [number, number]; + /** + * How many of the mesh's vertices took their depth from a texel **the + * part image does not draw** (issue #449). + * + * ⚠️ Not what `range` says, and this is the field that exists because + * `range` was claimed to say it. A map that is half background has + * exactly as full a range as one that is all subject, because a + * background level is a legitimate depth — so a full-frame sheet over a + * cut-out part reports a healthy `[0, 223.97]` of 224 with 54 % of the + * mesh reading background. + * + * A count and never a refusal: the same defect is already a named refusal + * when the sheet's alpha is cut to the art, and a sheet that is opaque + * everywhere is a statement rigc has no authority to guess away. Zero is + * a real answer here rather than an absence — every mesh that names a + * depth map also names an image, so the measurement is always taken. + */ + undrawn: number; /** * The turn this geometry takes on this sheet before a triangle reverses, * per axis and per direction — `src/depth.ts`'s `turnCeiling`.