index.jsx
21.0 kB · jsx · 382 lines
1import { useMemo, useRef, useState } from 'react';2import * as THREE from 'three';3import { ready, ink } from '../../lib/mrly.js';4import { faces } from '../../lib/stage.js';5import { board, bars, line, axis, rules, tag } from '../../lib/chart.js';6import { mount, Page, Row, Pick, Slider, Check, Stats, Stat, Note, Group } from '../../lib/app.jsx';7import { Grid, Markup, Sketch } from '../../lib/draw.jsx';8import { Stage } from '../../lib/stage.jsx';9import { useSeeds, seeded, Picker } from '../../lib/select.jsx';10import { useQuery } from '../../lib/query.js';1112const m = await ready();13const RDEN = 120;14const RMAX = 108;15const CELLS = 600000;16const FOLDS = 64;17const PAD = 14;18const SIGNS = [];19for (let bits = 0; bits < 8; bits++) SIGNS.push([1 - 2 * (bits & 1), 1 - 2 * ((bits >> 1) & 1), 1 - 2 * ((bits >> 2) & 1)]);20const WALLS = {21 box: [[1, 0, 0], [-1, 0, 0], [0, 1, 0], [0, -1, 0], [0, 0, 1], [0, 0, -1]],22 diamond: SIGNS,23 octahedron: SIGNS,24 tetrahedron: [[1, 1, 1], [-1, -1, 1], [-1, 1, -1], [1, -1, -1]],25 pyramid: [[1, 0, 0], [-1, -2, 0], [-1, 2, 0], [-1, 0, -2], [-1, 0, 2]],26};27const POLICIES = { 2: ['inside', 'touching', 'refined1', 'refined2'], 3: ['inside', 'touching'] };28const ART = { background: 'var(--fg)', borderRadius: '8px', padding: '8px', lineHeight: 0 };29const DIM = +(new URLSearchParams(location.search).get('dim') ?? 2);3031function planes(shape, r) {32 return WALLS[shape].map((n) => {33 const size = Math.hypot(...n);34 return new THREE.Plane(new THREE.Vector3(-n[0], -n[1], -n[2]).divideScalar(size), (2 * r) / size);35 });36}3738function Num({ label, value, min, max, onChange }) {39 return <label>{label} <input type="number" value={value} min={min} max={max} onChange={(e) => onChange(+e.target.value)} /></label>;40}4142function Toggle({ label, checked, disabled, hidden, onChange }) {43 return <label hidden={hidden}><input type="checkbox" checked={checked} disabled={disabled} onChange={(e) => onChange(e.target.checked)} /> {label}</label>;44}4546function App() {47 const s = useSeeds();48 const [q, set] = useQuery({49 dim: DIM, code: seeded(s, DIM, 2, DIM === 2 ? '7' : '23'), base: 2, number: 3, level: 3,50 shape: 'ball', radius: 60, mode: 'crop', policy: 'touching',51 centre: 'corner', count: DIM === 2 ? 6 : 4, r: 27, low: -2, high: -1,52 });53 const [crisp, setCrisp] = useState(false);54 const [spin, setSpin] = useState(false);55 const live = useRef(null);56 const shapes = useMemo(() => JSON.parse(m.crop_shapes(q.dim)), [q.dim]);57 const top = m.level_cap(q.number, 1, q.dim === 2 ? 243 : 81);58 const level = Math.min(q.level, top);59 const countTop = m.level_cap(q.number, q.dim, CELLS);60 const count = Math.min(q.count, countTop);6162 const circle = useMemo(() => {63 const out = {};64 try {65 const code = q.code.trim();66 const flat = m.crop_circle(code, q.number, count, q.base, q.dim, q.centre);67 const width = flat.length / 3;68 out.seen = flat.subarray(0, width);69 out.inside = flat.subarray(width, 2 * width);70 out.cut = flat.subarray(2 * width, 3 * width);71 out.top = width - 1;72 out.mass = Number(m.fills(code, q.number, q.dim, 1, q.base));73 out.d = m.dimension(code, q.number, q.dim, q.base);74 out.side = m.grid_total(q.number, 1, count);75 out.marks = [];76 for (let r = q.number; r <= out.top; r *= q.number) out.marks.push(r);77 } catch (error) {78 out.error = error;79 }80 return out;81 }, [q.code, q.number, q.base, q.dim, q.centre, count]);8283 const at = Math.max(1, Math.min(q.r, circle.top ?? q.r));84 const defect = circle.seen && q.number * at <= circle.top ? circle.seen[q.number * at] - circle.mass * circle.seen[at] : null;8586 const fold = useMemo(() => {87 try {88 return JSON.parse(m.crop_collapse(q.code.trim(), q.number, count, q.base, q.dim, q.centre, FOLDS));89 } catch (error) {90 return { error, scales: [], pairs: [] };91 }92 }, [q.code, q.number, q.base, q.dim, q.centre, count]);9394 const scales = fold.scales ?? [];95 const held = (want) => (scales.length ? Math.max(0, Math.min(scales.length - 1, want < 0 ? scales.length + want : want)) : 0);96 const [low, high] = [held(q.low), held(q.high)];97 const gap = fold.pairs?.find((row) => row.low === Math.min(low, high) && row.high === Math.max(low, high));9899 const sweep = useMemo(() => {100 const out = {};101 try {102 const steps = q.dim === 2 ? 36 : 24;103 out.rows = JSON.parse(m.crop_series(q.code.trim(), q.number, level, q.base, q.dim, q.shape, q.radius, RDEN, q.mode === 'anti', 'radius', steps));104 } catch (error) {105 out.error = error;106 }107 return out;108 }, [q.code, q.base, q.number, level, q.dim, q.shape, q.mode]);109110 const data = useMemo(() => {111 const out = {};112 try {113 const code = q.code.trim(), d = q.dim, anti = q.mode === 'anti';114 out.name = m.name_of(code, d, q.base);115 out.side = m.grid_total(q.number, 1, level);116 const census = JSON.parse(m.crop_census(code, q.number, level, q.base, d, q.shape, q.radius, RDEN, anti));117 out.census = census;118 out.solid = d === 3;119 out.cut = !!(out.solid && crisp && !anti && WALLS[q.shape]);120 if (!out.solid) {121 if (crisp) {122 const side = Number(m.grid_total(q.number, 1, level));123 const art = m.crop_svg(code, q.number, level, q.base, q.shape, q.radius, RDEN, anti, Math.max(2, Math.round(512 / side)));124 if (art.length > 4000000) throw new Error('that drawing is larger than this page serves; lower the level.');125 out.art = art;126 out.note = 'touching cells under the exact outline';127 } else {128 out.grid = m.crop_grid(code, q.number, level, q.base, q.shape, q.radius, RDEN, anti, q.policy);129 out.note = q.policy;130 }131 } else {132 const load = Number(out.cut ? census.exposed_before : census.exposed_after);133 if (load > 400000) throw new Error(`${load} faces is more than this page draws; lower the level.`);134 out.mesh = out.cut135 ? m.three_faces(code, q.number, level, q.base)136 : m.crop_faces(code, q.number, level, q.base, q.shape, q.radius, RDEN, anti, q.policy);137 out.note = out.cut ? 'the exact walls clip the full mesh' : q.policy;138 }139 if (sweep.error) throw sweep.error;140 const total = Number(census.filled_in) + Number(census.filled_cut) + Number(census.filled_out);141 out.rows = [{ x: 0, filled_in: anti ? total : 0, filled_cut: 0 }, ...sweep.rows];142 out.frac = q.radius / RDEN;143 out.levels = JSON.parse(m.crop_series(code, q.number, level, q.base, d, q.shape, q.radius, RDEN, anti, 'level', top));144 out.level = level;145 } catch (error) {146 out.error = error;147 out.art = null;148 out.grid = null;149 out.mesh = null;150 }151 return out;152 }, [q.code, q.base, q.number, level, q.dim, q.shape, q.radius, q.mode, q.policy, crisp, sweep]);153154 const census = data.census ?? {};155156 const turn = (on) => {157 setSpin(on);158 if (live.current) live.current.spin = on ? 0.004 : 0;159 };160161 const shift = (v) => {162 const d = +v;163 const list = JSON.parse(m.crop_shapes(d));164 s.drop();165 set({166 dim: d, code: d === 2 ? '7' : '23',167 shape: list.includes(q.shape) ? q.shape : 'ball',168 policy: POLICIES[d].includes(q.policy) ? q.policy : 'touching',169 });170 };171172 const alongRadius = (canvas) => {173 if (!data.rows) return;174 const b = board(canvas, 170, { pad: PAD, top: 16, bottom: 20 });175 const peak = Math.max(...data.rows.map((r) => Math.max(r.filled_in, r.filled_cut)), 1);176 line(b, data.rows.map((r) => [r.x, r.filled_in / peak]), ink.yellow);177 line(b, data.rows.map((r) => [r.x, r.filled_cut / peak]), ink.blue);178 axis(b, [[0, '0'], [1, 'radius 1']]);179 rules(b, [data.frac], { color: ink.pink });180 const edge = tag(b, 'in', ink.yellow);181 tag(b, 'cut', ink.blue, 'left', edge + 12);182 };183184 const alongLevel = (canvas) => {185 if (!data.levels) return;186 const b = board(canvas, 170, { pad: PAD, top: 16, bottom: 20 });187 const logs = data.levels.map((r) => Math.log10(1 + r.filled_in));188 bars(b, logs, { color: (i) => (i === data.level ? ink.pink : ink.yellow), inset: 2 });189 axis(b, [[0, 'level 0'], [1, String(data.levels.length - 1)]]);190 };191192 const alongCircle = (canvas) => {193 const { seen, cut, top, d, mass, marks } = circle;194 if (!seen || !seen[top] || top < 2) return;195 const b = board(canvas, 230, { pad: PAD, top: 20, bottom: 22 });196 const span = Math.log(top);197 let ceiling = Math.max(seen[top], 2);198 for (let r = 1; r <= top; r++) {199 ceiling = Math.max(ceiling, cut[r]);200 if (q.number * r <= top) ceiling = Math.max(ceiling, Math.abs(seen[q.number * r] - mass * seen[r]));201 }202 const roof = Math.log(ceiling);203 const lx = (r) => Math.log(r) / span;204 const ly = (v) => Math.log(v) / roof;205 const trail = (pick) => {206 const points = [];207 for (let r = 1; r <= top; r++) {208 const v = pick(r);209 if (v >= 1) points.push([lx(r), ly(v)]);210 }211 return points;212 };213 const ramp = (slope, v0) => {214 if (!(slope > 0) || !(v0 > 1)) return;215 const start = Math.max(1, top * Math.pow(v0, -1 / slope));216 line(b, [[lx(start), ly(v0 * Math.pow(start / top, slope))], [1, ly(v0)]], ink.dim, { width: 1, dash: [4, 4] });217 };218 rules(b, marks.map(lx), { dash: [2, 4] });219 rules(b, [lx(at)], { color: ink.pink });220 ramp(d, seen[top]);221 ramp(d - 1, cut[top]);222 line(b, trail((r) => seen[r]), ink.yellow);223 line(b, trail((r) => cut[r]), ink.blue);224 line(b, trail((r) => (q.number * r <= top ? Math.abs(seen[q.number * r] - mass * seen[r]) : 0)), ink.orange, { width: 1 });225 for (const r of marks) if (seen[r] >= 1) line(b, [[lx(r), ly(seen[r])]], ink.yellow, { dots: 3 });226 axis(b, [[0, 'r 1'], [1, `${top}`]], { wall: true });227 let edge = tag(b, `N slope ${d.toFixed(4)}`, ink.yellow);228 edge = tag(b, `C slope ${(d - 1).toFixed(4)}`, ink.blue, 'left', edge + 12);229 tag(b, 'defect', ink.orange, 'left', edge + 12);230 };231232 const collapse = (canvas) => {233 if (scales.length < 1) return;234 const b = board(canvas, 230, { pad: PAD, top: 20, bottom: 22 });235 const [a, z] = [scales[low], scales[high]];236 const seen = a.main.concat(z.main);237 const floor = Math.min(...seen), roof = Math.max(...seen);238 const room = (roof - floor) * 0.1 || 0.05;239 const fx = (j) => j / FOLDS;240 const fy = (v) => (v - floor + room) / (roof - floor + 2 * room);241 const trail = (scale, color) => line(b, scale.main.map((v, j) => [fx(j), fy(v)]), color, { width: 1.6 });242 const step = Math.log(q.number);243 const turn = Math.log(at) / step;244 rules(b, [turn - Math.floor(turn)], { color: ink.pink });245 trail(a, ink.yellow);246 if (high !== low) trail(z, ink.green);247 axis(b, [[0, '0'], [0.5, `log_${q.number} r mod 1`], [1, '1']], { wall: true });248 let edge = tag(b, `R ${a.start} to ${a.stop}`, ink.yellow);249 if (high !== low) edge = tag(b, `R ${z.start} to ${z.stop}`, ink.green, 'left', edge + 12);250 tag(b, 'N / r^d', ink.dim, 'left', edge + 12);251 if (gap) tag(b, `gap ${gap.sup.toFixed(4)}`, ink.dim, 'right');252 };253254 const ridge = (canvas) => {255 if (scales.length < 1) return;256 const b = board(canvas, 230, { pad: PAD, top: 20, bottom: 22 });257 const [a, z] = [scales[low], scales[high]];258 const seen = a.drift.concat(high === low ? [] : z.drift);259 if (!seen.length) {260 tag(b, 'the fold runs past the counted radii', ink.dim);261 axis(b, [[0, '0'], [0.5, `log_${q.number} r mod 1`], [1, '1']], { wall: true });262 return;263 }264 const floor = Math.min(...seen), roof = Math.max(...seen);265 const room = (roof - floor) * 0.1 || 0.05;266 const fx = (j) => j / FOLDS;267 const fy = (v) => (v - floor + room) / (roof - floor + 2 * room);268 const trail = (scale, color) => scale.drift.length && line(b, scale.drift.map((v, j) => [fx(j), fy(v)]), color, { width: 1.6 });269 const turn = Math.log(at) / Math.log(q.number);270 rules(b, [turn - Math.floor(turn)], { color: ink.pink });271 trail(a, ink.orange);272 if (high !== low) trail(z, ink.green);273 axis(b, [[0, '0'], [0.5, `log_${q.number} r mod 1`], [1, '1']], { wall: true });274 let edge = tag(b, `R ${a.start} to ${a.stop}`, a.drift.length ? ink.orange : ink.dim);275 if (high !== low) edge = tag(b, `R ${z.start} to ${z.stop}`, z.drift.length ? ink.green : ink.dim, 'left', edge + 12);276 tag(b, 'delta / r^(d - 1)', ink.dim, 'left', edge + 12);277 if (gap?.rsup != null) tag(b, `gap ${gap.rsup.toFixed(4)}`, ink.dim, 'right');278 };279280 const folds = scales.map((scale, k) => [k, `R ${scale.start}`]);281282 const controls = (283 <>284 <Group name="Design">285 <Picker dimension={q.dim} bases={[2, 3]} code={q.code} base={q.base} seeds={s} onChange={set} />286 <Num label="number" value={q.number} min={2} max={5} onChange={(v) => set({ number: v })} />287 <Slider label="level" value={level} min={1} max={top} onChange={(v) => set({ level: v })} />288 </Group>289 <Group name="Shape">290 <Pick label="dimension" value={q.dim} options={[2, 3]} onChange={shift} />291 <Pick label="shape" value={q.shape} options={shapes} onChange={(v) => set({ shape: v })} />292 <Slider label="radius" value={q.radius} min={0} max={RMAX} show={`${q.radius}/${RDEN}`} onChange={(v) => set({ radius: v })} />293 <Pick label="mode" value={q.mode} options={['crop', 'anti']} onChange={(v) => set({ mode: v })} />294 <Pick label="policy" value={q.policy} options={POLICIES[q.dim]} onChange={(v) => set({ policy: v })} />295 </Group>296 <Group name="Circle">297 <Pick label="centre" value={q.centre} options={['corner', 'centre']} onChange={(v) => set({ centre: v })} />298 <Slider label="count level" value={count} min={1} max={countTop} onChange={(v) => set({ count: v })} />299 <Slider label="radius r" value={at} min={1} max={Math.max(1, circle.top ?? 1)} show={`${at}/${circle.top ?? 1}`} onChange={(v) => set({ r: v })} />300 <Pick label="scale A" value={low} options={folds} onChange={(v) => set({ low: +v })} />301 <Pick label="scale B" value={high} options={folds} onChange={(v) => set({ high: +v })} />302 </Group>303 <Group name="View">304 <Check label="exact edge" checked={crisp} onChange={setCrisp} />305 <Toggle label="spin" checked={spin} disabled={data.cut} hidden={q.dim === 2} onChange={turn} />306 </Group>307 </>308 );309310 return (311 <Page crumb="crop" title="A shape keeps only the cells of a design it reaches" controls={controls}312 sub="A named shape of rational radius sits on the unit square or cube and keeps only the cells of a design it reaches: strictly inside, touching, or rebuilt on a finer lattice at the rim. The census splits every cell into in, cut and out before anything is drawn, and the sweeps show how the kept mass grows with the radius and the level. Drag the radius chart to move the shape. Below it the radius stops being a fraction of the box and runs in whole cells: the count of filled cells the ball holds, the count its sphere crosses, and what the count leaves over when the radius is multiplied by the design's own side. The last panel takes two of the windows between consecutive powers of that side, divides the count by the radius to the dimension and reads both at the same offsets of the log radius, so a stranger can pick any two scales and watch them land on one curve. The panel beside it folds the left-over of that multiplication the same way, one power of the radius lower, so the defect has a ridge of its own to read."313 foot={<>The shape is exact rational geometry in Rust: a ball tested on squared fractions or a polytope of half-plane walls, never a float. A cell is in, cut or out by where its corners land, the census tallies the three regions and the perimeter or surface before and after the touching crop, and the sweeps re-run that census at every radius and level. The exact edge in the plane is the same touching crop clipped by the true circle or polygon in SVG; in the cube it clips the uncropped mesh with the shape's own walls as camera-space planes, so the ball and the anti crop stay on the raster mesh, which is always the source of truth for every count. The circle count is the same geometry in whole cells and one pass over the grid: a cell is seen when its own centre lands in the ball, inside when its far corner does and cut when the sphere separates its near corner from its far one, all on doubled integer coordinates with squared distances compared as integers, and the three columns are prefix sums by radius. Its main term is not a constant times r to the d: it is r to the d times a periodic multiplier of log r, which is why two windows between consecutive powers of the side, read at the same offsets of the log radius, land on one curve; the gap beside them is the largest distance between the two profiles and the share is that gap over the higher of their two mean levels, and both fall as the scales deepen, which is what makes the collapse a law rather than one lucky window. The defect is an exact integer, it rides one power below the count, and the last panel folds it at the same offsets over the radius to that lower power, so the two windows carry two ridges with a gap of their own; that ridge gap need not fall with the scale the way the count's does, and the panel shows it rather than hides it. A window whose multiplied radii run past the counted grid has no ridge and says so. At the grid centre the middle block is empty, so the count stays at zero out to the block's inradius. The exact classification, the census of the three regions, the circle theorem and the one open lane the cut column points at are in <a href="/research/crop/">the crop note</a>.</>}>314 <div className="arena" style={{ gridTemplateColumns: '3fr 2fr' }}>315 <div className="panel">316 <h2>The crop <span>{data.note}</span></h2>317 {data.grid && <Grid grid={data.grid} on={ink.yellow} role="img" aria-label="The crop" />}318 <Markup style={ART} hidden={!data.art} svg={data.art ?? ''} role="img" aria-label="The crop" />319 <Stage hidden={!data.solid} role="img" aria-label="The crop" deps={[data]} onStage={(st) => {320 live.current = st;321 st.renderer.localClippingEnabled = true;322 st.spin = data.cut || !spin ? 0 : 0.004;323 if (data.cut) setSpin(false);324 if (!data.mesh) {325 st.clear();326 return;327 }328 const mesh = faces(data.mesh, ink.blue);329 if (data.cut) mesh.material.clippingPlanes = planes(q.shape, q.radius / RDEN);330 st.show(mesh);331 }} />332 </div>333 <div className="panel">334 <h2>Along the radius <span>filled cells kept and cut</span></h2>335 <Sketch className="bars" role="img" aria-label="Along the radius" draw={alongRadius} deps={[data]} onSeek={(frac) => set({ radius: Math.min(RMAX, Math.max(0, Math.round(frac * RDEN))) })} />336 <h2>Along the level <span>kept fills, log scale</span></h2>337 <Sketch className="bars" role="img" aria-label="Along the level" draw={alongLevel} deps={[data]} />338 </div>339 </div>340 <Stats>341 <Stat label="name">{data.name}</Stat>342 <Stat label="side">{data.side}</Stat>343 <Stat label="filled in">{census.filled_in}</Stat>344 <Stat label="filled cut">{census.filled_cut}</Stat>345 <Stat label="filled out">{census.filled_out}</Stat>346 <Stat label="exposed before">{census.exposed_before}</Stat>347 <Stat label="exposed after">{census.exposed_after}</Stat>348 </Stats>349 <div className="arena">350 <div className="panel">351 <h2>The circle count <span>N, C and the defect on log axes</span></h2>352 <Sketch className="bars" role="img" aria-label="The circle count" draw={alongCircle} deps={[circle, at]} onSeek={(frac) => set({ r: Math.round(Math.exp(Math.max(0, Math.min(1, frac)) * Math.log(circle.top ?? 1))) })} />353 </div>354 <div className="panel">355 <h2>The collapse <span>two scales laid on each other</span></h2>356 <Sketch className="bars" role="img" aria-label="The collapse" draw={collapse} deps={[fold, low, high, at]} />357 </div>358 <div className="panel">359 <h2>The defect ridge <span>the same two scales, one power down</span></h2>360 <Sketch className="bars" role="img" aria-label="The defect ridge" draw={ridge} deps={[fold, low, high, at]} />361 </div>362 </div>363 <Stats>364 <Stat label="count side">{circle.side}</Stat>365 <Stat label="r">{at}</Stat>366 <Stat label="N(r)">{circle.seen?.[at]}</Stat>367 <Stat label="inside(r)">{circle.inside?.[at]}</Stat>368 <Stat label="C(r)">{circle.cut?.[at]}</Stat>369 <Stat label="defect">{defect === null ? 'past the grid' : defect}</Stat>370 <Stat label="d">{circle.d?.toFixed(7)}</Stat>371 <Stat label="fill of one tile">{circle.mass}</Stat>372 <Stat label="gap">{gap ? gap.sup.toFixed(5) : 'one scale'}</Stat>373 <Stat label="gap / level">{gap ? gap.share.toFixed(5) : 'one scale'}</Stat>374 <Stat label="ridge gap">{gap?.rsup != null ? gap.rsup.toFixed(5) : 'past the grid'}</Stat>375 <Stat label="ridge gap / ridge">{gap?.rshare != null ? gap.rshare.toFixed(5) : 'past the grid'}</Stat>376 </Stats>377 <Note error={data.error ?? circle.error ?? fold.error} />378 </Page>379 );380}381382mount(<App />);