From fc152d81175f2a738ac30c678ff9b5a3baffce9a Mon Sep 17 00:00:00 2001 From: firejune Date: Mon, 7 Sep 2026 14:25:19 +0900 Subject: [PATCH] feat(depth): what the sheet is not, and whether the ceiling read a form sampleMeshDepth refused a sheet that does not cover the mesh, but the check was gated behind a sheet having any transparent texel at all, so a full-frame opaque render skipped it entirely. A sheet produced by monocular depth estimation is exactly that. The same defect was a named refusal in one encoding and a green build in the other. The coverage instinct now reaches the part's alpha and reports rather than refuses, in the same loop and the same four taps as the refusal. A full-frame sheet is a legitimate statement and rigc has no authority to guess an input away; how many of the mesh's vertices sample a texel the part image does not draw is a measurement, not a guess. The module header justified the old gate with a sentence that is measurably false: range does not show a background-sampling mesh. On the tested build it reads full and healthy over a mesh half of which samples background. It is replaced by what actually shows one. stepShare is depthStep over the range the mesh sampled, one divide of two numbers turnCeiling already held. A form's share halves under refinement while its ceiling converges; a discontinuity pins its share and halves its ceiling instead, because there is no slope to converge to. Both existing diagnostics read healthy on the second case and this one separates them. No threshold: the number is reported so an author can see which kind of thing the ceiling read. A contour's vertices are all traced outline pushed out by the margin, so the count is its whole rim on every rig. Discounting it by the margin would borrow a number authored for the trace to mean close enough for the sheet. The report names the topology instead, and only on a contour, where the generator already knows the vertices are the outline it traced. Closes #448, closes #449. Co-Authored-By: Claude Opus 5 --- cli.ts | 32 ++- docs/AUTHORING.md | 96 +++++++-- docs/FACE.md | 71 +++++-- gallery/look/README.md | 18 +- selftest.ts | 447 +++++++++++++++++++++++++++++++++++++++++ src/compile.ts | 101 ++++++++-- src/depth.ts | 93 ++++++++- src/types.ts | 39 +++- 8 files changed, 833 insertions(+), 64 deletions(-) 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`.