Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Every documentation page is one Markdown file under `content/` (frontmatter + bo
| `/docs/parts/` … `/docs/adapters/` | `content/docs/<slug>.md` | One concept page per package: what it is, when to use it, what it solves, what you get, how it works, key concepts, and links into the repo for detail |
| `/examples/` | `content/examples.md` | Reproducible examples (`rig-c/examples`, `rig-c/trailer`) |
| `/demos/head-turn/` | `demos/head-turn/index.html` | Interactive preview of an upcoming head-turn feature (JavaScript/WebGL required) |
| `/demos/head-turn/curvature/` | `demos/head-turn/curvature/index.html` | Curvature check: the original painting displaced by its depth map in one fragment shader, no rig (WebGL required) |
| `/exam/` | `content/exam.md` | [`rig-c/exam`](https://github.com/rig-c/exam) — the open-book exam for AI agents, Basic and Advanced editions |

The first version's routes (`/parts/`, `/core/`, `/getting-started/`, …) are redirect stubs to their `/docs/` pages (`REDIRECTS` in `src/content.ts`); they are `noindex` and stay out of the sitemap.
Expand Down Expand Up @@ -113,4 +114,6 @@ After building, `bun run serve` serves `/demos/head-turn/` from the isolated out

`demos/head-turn/bundle/` preserves all ten approved producer files byte for byte: `index.html`, `app.js`, `aperture.js`, `runtime.js`, `look.png`, `depth.jpg`, `scene.json`, `scene.bin.gz`, `README.md`, `THIRD_PARTY_LICENSES.txt`. The adjacent `bundle-manifest.json` records their path-sorted lengths and SHA-256 values; the build checks its pinned digest before fetching registry data. Verification checks the whole copied `demos/` tree and the complete deployment manifest. The original depth logic, renderer, geometry and licences are unchanged.

`demos/head-turn/curvature/` is a separate static page linked from the preview's caption: one WebGL fragment shader samples `original.webp` at each pixel's position plus depth × camera offset (pointer on desktop, drag on touch), with depth gamma, contrast and bias and a Show depth view. It reads the depth map and is not the head-turn feature. `depth.webp` is the supplied 1359×2000 depth map resampled into the painting's 1368×2000 frame by `x_depth = 0.99908·x − 2.939` (pixel indices, y unchanged), fitted to the silhouette edges of both images; a uniform 1359/1368 stretch left a median edge residual of 2.77 px, this fit 0.17 px. `verifyCurvaturePage` (in `src/head-turn.ts`) checks its five files, caption, back link, that every reference is relative and resolves, and the preview's link to it.

Serve `scene.bin.gz` as raw gzip bytes with **no Content-Encoding header**; the application decompresses it itself. The local static server returns the file directly. The Examples page links it from a "Preview: depth-driven head turn" section; the landing page does not feature it. Its `assets/head-turn-yaw15.png` poster is a real canvas render at yaw +15 degrees, captured with a synthetic pointer event at the right canvas edge through the original handler, with default controls; separate motion evidence uses real mouse movement. It is not an image-model asset. Browser captures and measured viewport/renderer details are separate acceptance evidence; source tests alone do not establish visual or physical-device correctness.
33 changes: 33 additions & 0 deletions demos/head-turn/curvature/curvature.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/* Curvature check: one canvas, one small panel. Same palette as the head-turn preview. */
* { box-sizing: border-box; }
html, body { margin: 0; width: 100%; height: 100%; overflow: hidden; background: #20232a; color: #f0f3f8; font: 14px system-ui, sans-serif; }
canvas { position: fixed; inset: 0; display: block; width: 100vw; height: 100vh; touch-action: none; cursor: crosshair; }
header { position: fixed; z-index: 2; top: 20px; left: 20px; right: 20px; display: flex; justify-content: space-between; gap: 24px; pointer-events: none; }
.intro { max-width: 350px; }
h1 { margin: 0 0 10px; font-size: 24px; letter-spacing: -.5px; }
.caption { margin: 0 0 10px; color: #bcc8d6; line-height: 1.6; font-size: 13px; }
a { color: #a8cfdf; font-size: 12px; pointer-events: auto; }
.controls { pointer-events: auto; width: 300px; padding: 16px 18px; border: 1px solid #ffffff20; border-radius: 14px; background: #111923e8; box-shadow: 0 12px 36px #0003; }
.hint { margin: 0 0 12px; color: #c5d1df; font-size: 12px; }
.row { display: grid; grid-template-columns: 1fr 45px; gap: 7px; margin: 12px 0; }
.row label { font-size: 12px; }
.row output { text-align: right; font-size: 12px; font-variant-numeric: tabular-nums; }
.row input { grid-column: 1 / -1; width: 100%; margin: 0; accent-color: #61bddd; }
.toggle { display: flex; align-items: center; gap: 8px; font-size: 12px; margin: 14px 0 0; }
.toggle input { margin: 0; accent-color: #61bddd; }
.actions { display: flex; align-items: center; justify-content: space-between; margin-top: 14px; }
button { padding: 7px 12px; border-radius: 7px; border: 1px solid #ffffff30; color: #eef6fd; background: #2c4354; cursor: pointer; font: inherit; font-size: 13px; }
#controls-toggle { display: none; pointer-events: auto; }
#status { position: fixed; z-index: 3; top: 48%; left: 20px; right: 20px; text-align: center; }
#status:empty { display: none; }
@media (max-width: 799px) {
header { display: block; top: 12px; left: 14px; right: 14px; }
.intro { max-width: 100%; }
.intro h1 { padding-right: 80px; font-size: 19px; margin-bottom: 5px; }
.caption { font-size: 11px; max-width: 100%; margin-bottom: 4px; }
#controls-toggle { display: block; position: absolute; top: 0; right: 0; padding: 7px 10px; font-size: 12px; }
.controls { position: fixed; bottom: 14px; right: 14px; width: 276px; padding: 12px 15px; max-height: calc(100dvh - 130px); overflow-y: auto; }
.controls:not([data-open]) { display: none; }
.row { margin: 9px 0; }
.hint { margin-bottom: 6px; }
}
219 changes: 219 additions & 0 deletions demos/head-turn/curvature/curvature.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
// Curvature check: one fragment shader over the original painting. Each pixel samples the
// painting at its own position plus depth × camera offset, so near (bright) pixels move more
// than far ones. No mesh, no rig, no rotation of the whole image.
// depth.webp is the supplied depth map resampled into the painting's 1368×2000 frame
// (x_depth = 0.99908·x − 2.939, y unchanged; see the site README).
'use strict';

const MAX_SHIFT = 0.03; // fraction of the image width at strength 1 and the pointer at the edge
const SETTLE = 8; // 1/s; exponential damping rate toward the pointer target
const BACKGROUND = [0x20 / 255, 0x23 / 255, 0x2a / 255];

const VERT = `attribute vec2 aPos; void main() { gl_Position = vec4(aPos, 0.0, 1.0); }`;
const FRAG = `
precision mediump float;
uniform sampler2D uImage;
uniform sampler2D uDepth;
uniform vec4 uRect; // image rectangle in drawing-buffer pixels: x, y (bottom-left), w, h
uniform vec2 uOffset; // camera offset in image UV units
uniform float uGamma;
uniform float uContrast;
uniform float uBias;
uniform float uShowDepth;
uniform vec3 uBackground;
float depthAt(vec2 uv) {
float raw = texture2D(uDepth, uv).r;
return clamp((pow(raw, uGamma) - 0.5) * uContrast + 0.5 + uBias, 0.0, 1.0);
}
void main() {
vec2 p = (gl_FragCoord.xy - uRect.xy) / uRect.zw;
p.y = 1.0 - p.y;
if (p.x < 0.0 || p.x > 1.0 || p.y < 0.0 || p.y > 1.0) { gl_FragColor = vec4(uBackground, 1.0); return; }
float depth = depthAt(p);
if (uShowDepth > 0.5) { gl_FragColor = vec4(vec3(depth), 1.0); return; }
gl_FragColor = texture2D(uImage, clamp(p + depth * uOffset, 0.0, 1.0));
}`;

const $ = (id) => document.getElementById(id);
const main = $('curvature');
const canvas = $('view');
const status = $('status');

function fail(message) {
status.textContent = message;
main.setAttribute('aria-busy', 'false');
main.dataset.state = 'error';
}

function loadImage(src) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error(`Could not load ${src}`));
img.src = src;
});
}

function compile(gl, type, source) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(shader) || 'shader compile failed');
return shader;
}

function texture(gl, unit, image) {
const tex = gl.createTexture();
gl.activeTexture(gl.TEXTURE0 + unit);
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
return tex;
}

// Controls panel: open on desktop, collapsed below 800 CSS pixels (as on the head-turn page).
const toggle = $('controls-toggle');
const panel = $('curvature-controls');
const phone = matchMedia('(max-width: 799px)');
function setOpen(open) {
const expanded = !phone.matches || open;
panel.toggleAttribute('data-open', expanded);
panel.inert = !expanded;
toggle.setAttribute('aria-expanded', String(expanded));
}
setOpen(false);
phone.addEventListener('change', () => setOpen(false));
toggle.addEventListener('click', () => setOpen(toggle.getAttribute('aria-expanded') !== 'true'));
addEventListener('keydown', (event) => {
if (phone.matches && event.key === 'Escape' && toggle.getAttribute('aria-expanded') === 'true') {
setOpen(false);
toggle.focus();
}
});

const params = { strength: 1, gamma: 1, contrast: 1, bias: 0, showDepth: false };
const target = { x: 0, y: 0 };
const camera = { x: 0, y: 0 };
let requestDraw = () => {};

function bindSlider(name, format) {
const input = $(name);
const output = $(`${name}Value`);
const update = () => {
params[name] = Number(input.value);
output.textContent = format(params[name]);
requestDraw();
};
input.addEventListener('input', update);
update();
}
bindSlider('strength', (v) => `${v.toFixed(2)}×`);
bindSlider('gamma', (v) => v.toFixed(2));
bindSlider('contrast', (v) => v.toFixed(2));
bindSlider('bias', (v) => v.toFixed(2));
$('showDepth').addEventListener('change', (event) => { params.showDepth = event.target.checked; requestDraw(); });
$('centre').addEventListener('click', () => { target.x = 0; target.y = 0; requestDraw(); });

const clamp1 = (v) => Math.max(-1, Math.min(1, v));
// Mouse and pen: the pointer's position relative to the viewport centre is the camera target.
// Touch: dragging moves the target by the drag distance, so a finger never has to cover the face.
let drag = null;
canvas.addEventListener('pointerdown', (event) => {
if (event.pointerType !== 'touch') return;
drag = { id: event.pointerId, x: event.clientX, y: event.clientY };
canvas.setPointerCapture(event.pointerId);
});
canvas.addEventListener('pointerup', (event) => { if (drag && drag.id === event.pointerId) drag = null; });
canvas.addEventListener('pointercancel', (event) => { if (drag && drag.id === event.pointerId) drag = null; });
addEventListener('pointermove', (event) => {
const halfW = innerWidth / 2;
const halfH = innerHeight / 2;
if (event.pointerType === 'touch') {
if (!drag || drag.id !== event.pointerId) return;
target.x = clamp1(target.x + (event.clientX - drag.x) / halfW);
target.y = clamp1(target.y + (event.clientY - drag.y) / halfH);
drag.x = event.clientX;
drag.y = event.clientY;
} else {
target.x = clamp1((event.clientX - halfW) / halfW);
target.y = clamp1((event.clientY - halfH) / halfH);
}
requestDraw();
});

async function start() {
const gl = canvas.getContext('webgl', { antialias: false, alpha: false, premultipliedAlpha: false });
if (!gl) return fail('This check needs WebGL, which this browser did not provide.');
const [image, depth] = await Promise.all([loadImage('./original.webp'), loadImage('./depth.webp')]);
const program = gl.createProgram();
gl.attachShader(program, compile(gl, gl.VERTEX_SHADER, VERT));
gl.attachShader(program, compile(gl, gl.FRAGMENT_SHADER, FRAG));
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(program) || 'program link failed');
gl.useProgram(program);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
const aPos = gl.getAttribLocation(program, 'aPos');
gl.enableVertexAttribArray(aPos);
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
texture(gl, 0, image);
texture(gl, 1, depth);
const u = (name) => gl.getUniformLocation(program, name);
const uni = {
image: u('uImage'), depth: u('uDepth'), rect: u('uRect'), offset: u('uOffset'), gamma: u('uGamma'),
contrast: u('uContrast'), bias: u('uBias'), showDepth: u('uShowDepth'), background: u('uBackground'),
};
gl.uniform1i(uni.image, 0);
gl.uniform1i(uni.depth, 1);
gl.uniform3fv(uni.background, BACKGROUND);
const aspect = image.naturalWidth / image.naturalHeight;

function resize() {
const dpr = Math.min(devicePixelRatio || 1, 2);
const w = Math.round(canvas.clientWidth * dpr);
const h = Math.round(canvas.clientHeight * dpr);
if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; }
// Contain-fit with a margin of 4% of the shorter side, leaving room for the camera shift.
const margin = Math.min(w, h) * 0.04;
let rw = w - 2 * margin;
let rh = rw / aspect;
if (rh > h - 2 * margin) { rh = h - 2 * margin; rw = rh * aspect; }
gl.viewport(0, 0, w, h);
gl.uniform4f(uni.rect, (w - rw) / 2, (h - rh) / 2, rw, rh);
}

let last = 0;
let pending = false;
function frame(now) {
pending = false;
const dt = last ? Math.min((now - last) / 1000, 0.1) : 1 / 60;
last = now;
const k = 1 - Math.exp(-SETTLE * dt);
camera.x += (target.x - camera.x) * k;
camera.y += (target.y - camera.y) * k;
resize();
const shift = MAX_SHIFT * params.strength;
// Same pixel distance on both axes: the y component is scaled by the image's aspect.
gl.uniform2f(uni.offset, camera.x * shift, camera.y * shift * aspect);
gl.uniform1f(uni.gamma, params.gamma);
gl.uniform1f(uni.contrast, params.contrast);
gl.uniform1f(uni.bias, params.bias);
gl.uniform1f(uni.showDepth, params.showDepth ? 1 : 0);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
const moving = Math.abs(target.x - camera.x) > 1e-4 || Math.abs(target.y - camera.y) > 1e-4;
main.dataset.settled = String(!moving);
if (moving) requestDraw(); else last = 0;
}
requestDraw = () => { if (!pending) { pending = true; requestAnimationFrame(frame); } };
addEventListener('resize', requestDraw);
canvas.addEventListener('webglcontextlost', (event) => { event.preventDefault(); fail('The WebGL context was lost; reload the page.'); });
requestDraw();
status.textContent = '';
main.setAttribute('aria-busy', 'false');
main.dataset.state = 'ready';
}

start().catch((error) => fail(`The curvature check could not start: ${error.message}`));
Binary file added demos/head-turn/curvature/depth.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
36 changes: 36 additions & 0 deletions demos/head-turn/curvature/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="icon" href="data:,">
<title>Curvature check — rig-c</title>
<meta name="robots" content="noindex">
<link rel="canonical" href="https://rig-c.org/demos/head-turn/curvature/">
<link rel="stylesheet" href="./curvature.css">
<script src="./curvature.js" defer></script>
</head>
<body>
<main id="curvature" aria-busy="true">
<canvas id="view" aria-label="The original painting, displaced by its depth map"></canvas>
<header>
<div class="intro">
<h1>Curvature check</h1>
<p class="caption">Curvature check: the original painting displaced by its depth map, with no rig. A tool for reading the depth, not the head-turn feature.</p>
<a class="back" href="../">← Back to the head-turn preview</a>
</div>
<button id="controls-toggle" type="button" aria-controls="curvature-controls" aria-expanded="false">Controls</button>
<section id="curvature-controls" class="controls" aria-label="Curvature check controls">
<p class="hint">Move the pointer to move the camera; drag on a phone.</p>
<div class="row"><label for="strength">Strength</label><output id="strengthValue" for="strength">1.00×</output><input id="strength" type="range" min="0" max="3" step="0.05" value="1"></div>
<div class="row"><label for="gamma">Depth gamma</label><output id="gammaValue" for="gamma">1.00</output><input id="gamma" type="range" min="0.2" max="3" step="0.05" value="1"></div>
<div class="row"><label for="contrast">Depth contrast</label><output id="contrastValue" for="contrast">1.00</output><input id="contrast" type="range" min="0" max="3" step="0.05" value="1"></div>
<div class="row"><label for="bias">Depth bias</label><output id="biasValue" for="bias">0.00</output><input id="bias" type="range" min="-0.5" max="0.5" step="0.01" value="0"></div>
<label class="toggle"><input id="showDepth" type="checkbox"> Show depth</label>
<div class="actions"><button id="centre" type="button">Centre</button><a href="./depth.webp" target="_blank" rel="noopener">Depth map</a></div>
</section>
</header>
<div id="status" role="status">Loading the painting and its depth map…</div>
</main>
</body>
</html>
Binary file added demos/head-turn/curvature/original.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading