index.jsx
8.9 kB · jsx · 184 lines
1import { useMemo, useRef, useState } from 'react';2import { ready, ink } from '../../lib/mrly.js';3import { faces, plane } from '../../lib/stage.js';4import { mount, Page, Row, Pick, Slider, Check, Btn, Stats, Stat, Note, Group } from '../../lib/app.jsx';5import { Stage } from '../../lib/stage.jsx';6import { Pixels } from '../../lib/draw.jsx';7import { useSeeds, seeded, roll, Picker, Ramp, Cropper, cropOf } from '../../lib/select.jsx';8import { useQuery } from '../../lib/query.js';910const m = await ready();11const NORMALS = { x: [1, 0, 0], y: [0, 1, 0], z: [0, 0, 1], d: [1, 1, 1] };12const CUTS = [['x', 'x plane'], ['y', 'y plane'], ['z', 'z plane'], ['d', 'diagonal']];13const COMBINE = ['sum', 'xor', 'and'];1415function drawCombine(seed) {16 const [k] = roll(seed, [[0, COMBINE.length - 1]]);17 return COMBINE[k] === 'and' ? COMBINE[(k + 1) % COMBINE.length] : COMBINE[k];18}1920function Cut({ label, on, at, onToggle, onSlide }) {21 return (22 <label><input type="checkbox" checked={on} onChange={(e) => onToggle(e.target.checked)} /> {label} <input type="range" min={0} max={1} step={0.01} value={at} onChange={(e) => onSlide(+e.target.value)} /></label>23 );24}2526function App() {27 const s = useSeeds();28 const [q, set] = useQuery({29 code: seeded(s, 3, 2, '23'), base: 2, limit: 7, combine: s.get() ? drawCombine(s.get()) : 'sum', level: 1, size: 64,30 camera: 'eye', opacity: 1, crop: '', 'crop-r': 16, 'crop-anti': false,31 });32 const [shell, setShell] = useState(true);33 const [threshold, setThreshold] = useState(null);34 const [cuts, setCuts] = useState({ x: false, y: false, z: false, d: true });35 const [spot, setSpot] = useState({ x: 0.5, y: 0.5, z: 0.5, d: 0.5 });36 const [look, setLook] = useState({ ramp: 'fire', levels: 16, invert: false });37 const [spin, setSpin] = useState(false);38 const live = useRef(null);39 const first = useRef(true);4041 const solid = useMemo(() => {42 const out = {};43 try {44 const code = q.code.trim();45 const data = m.volume(code, q.base, q.limit, q.combine, q.level, q.size);46 const stats = JSON.parse(m.volume_stats(data, q.size));47 const shape = JSON.parse(m.volume_shape(q.limit, q.size));48 out.name = m.name_of(code, 3, q.base);49 out.data = data;50 out.stats = stats;51 out.layers = shape.layers;52 out.voxels = shape.voxels;53 } catch (error) {54 out.error = error;55 }56 return out;57 }, [q.code, q.base, q.limit, q.combine, q.level, q.size]);5859 const cap = solid.stats ? solid.stats.max : 4;60 const mark = threshold === null || threshold > cap ? cap : threshold;6162 const view = useMemo(() => {63 const out = { planes: [] };64 if (!solid.data) return out;65 try {66 const c = cropOf(q);67 const shown = c.active ? m.field_crop(solid.data, q.size, 3, c.shape, c.rnum, c.rden, c.anti) : solid.data;68 out.count = m.volume_count(shown, q.size, mark);69 if (shell) {70 const quads = m.volume_surface(shown, q.size, mark);71 if (quads > 400000) throw new Error(`${quads} faces is more than this page draws; raise the threshold or lower the size.`);72 out.faces = quads;73 out.mesh = m.volume_faces(shown, q.size, mark);74 }75 for (const [key] of CUTS) {76 if (!cuts[key]) continue;77 const normal = NORMALS[key], offset = spot[key];78 const frame = JSON.parse(m.plane_frame(normal, offset));79 const wide = key === 'd' ? 384 : 256;80 const pixels = m.paint_span(m.plane_field(shown, q.size, normal, offset, wide), wide, solid.stats.min, solid.stats.max, look.ramp, look.levels, look.invert);81 out.planes.push({ pixels, frame });82 if (key === 'd') {83 out.cut = pixels;84 out.cutNote = `diagonal at ${offset}`;85 }86 }87 out.note = `${q.combine}, ${q.camera === 'iso' ? 'isometric' : 'perspective'}`;88 } catch (error) {89 out.error = error;90 out.mesh = null;91 out.cut = null;92 out.planes = [];93 }94 return out;95 }, [solid, mark, shell, cuts, spot, look, q.size, q.combine, q.camera, q.crop, q['crop-r'], q['crop-anti']]);9697 const turn = (on) => {98 setSpin(on);99 if (live.current) live.current.spin = on ? 0.004 : 0;100 };101102 const randomize = () => {103 const seed = s.next();104 set({ code: m.random_code(3, q.base, seed), combine: drawCombine(seed) });105 };106107 const controls = (108 <>109 <Group name="Field">110 <Picker dimension={3} bases={[2, 3]} code={q.code} base={q.base} seeds={s} button={false} onChange={set} />111 <Btn onClick={randomize}>Randomize</Btn>112 <Slider label="scales up to" value={q.limit} min={1} max={21} step={2} onChange={(v) => set({ limit: v })} />113 <Pick label="combine" value={q.combine} options={COMBINE} onChange={(v) => set({ combine: v })} />114 <Slider label="level" value={q.level} min={1} max={2} onChange={(v) => set({ level: v })} />115 <Pick label="size" value={q.size} options={[[48, 48], [64, 64], [80, 80]]} onChange={(v) => set({ size: +v })} />116 </Group>117 <Group name="Shell">118 <Check label="shell at" checked={shell} onChange={setShell} />119 <Slider label="threshold" value={mark} min={0} max={cap} step={1} onChange={(v) => setThreshold(v >= cap ? null : v)} />120 <Slider label="opacity" value={q.opacity} min={0.05} max={1} step={0.05} onChange={(v) => set({ opacity: v })} />121 </Group>122 <Group name="Planes">123 {CUTS.map(([key, text]) => (124 <Cut key={key} label={text} on={cuts[key]} at={spot[key]}125 onToggle={(v) => setCuts({ ...cuts, [key]: v })} onSlide={(v) => setSpot({ ...spot, [key]: v })} />126 ))}127 </Group>128 <Group name="Camera">129 <Pick label="camera" value={q.camera} options={[['eye', 'perspective'], ['iso', 'isometric']]} onChange={(v) => set({ camera: v })} />130 <span className="tabs" role="group" aria-label="Look from">131 <Btn onClick={() => live.current.view(1, 1, 1)}>corner</Btn>132 <Btn onClick={() => live.current.view(1, 1, 0)}>edge</Btn>133 <Btn onClick={() => live.current.view(0, 0, 1)}>face</Btn>134 </span>135 <Check label="spin" checked={spin} onChange={turn} />136 </Group>137 <Group name="Colour">138 <Ramp value={look} onChange={(patch) => setLook({ ...look, ...patch })} />139 </Group>140 <Group name="Crop">141 <Cropper dimension={3} value={q} onChange={set} />142 </Group>143 </>144 );145146 return (147 <Page crumb="volume" title="A stack of scales becomes a solid you can cut" controls={controls}148 sub="One cube design sampled at scale 1, 3, 5, and so on, the layers stacked into one solid field. A level set of the field is drawn as a shell, and any plane through it is painted as the moire that plane sees. Look down the diagonal in isometric projection and the cut through the centre is the hexagon the slices page counts. Drag to orbit, scroll to zoom."149 foot={<>Every voxel reads its design residue at each odd scale, and the layers are summed, folded to parity, or met. The shell is the crate's list of outward faces of the voxels at or above the threshold; a plane is sampled voxel by voxel in Rust and arrives as pixels with the outside of the cube transparent, so the diagonal cut shows its hexagon. Isometric projection is the orthographic camera; the corner button puts it on the <code>(1, 1, 1)</code> axis, where the cube's silhouette and its central cut are the same regular hexagon. What that diagonal cut is at every height, and why the middle one falls into six gaskets, is in <a href="/research/cuts/">the cuts note</a>.</>}>150 <div className="arena" style={{ gridTemplateColumns: '2fr 1fr' }}>151 <div className="panel">152 <h2>The solid <span>{view.note}</span></h2>153 <Stage role="img" aria-label="The solid" deps={[view, q.opacity, q.camera]} onStage={(st) => {154 live.current = st;155 st.spin = spin ? 0.004 : 0;156 st.project(q.camera);157 if (first.current) {158 first.current = false;159 if (q.camera === 'iso') st.view(1, 1, 1);160 }161 st.clear();162 if (view.mesh) st.add(faces(view.mesh, ink.blue, q.opacity));163 for (const { pixels, frame } of view.planes) st.add(plane(pixels, frame));164 }} />165 </div>166 <div className="panel">167 <h2>The cut <span>{view.cutNote}</span></h2>168 {view.cut ? <Pixels data={view.cut} role="img" aria-label="The cut" /> : <canvas className="sheet" role="img" aria-label="The cut, none taken" />}169 </div>170 </div>171 <Stats>172 <Stat label="name">{solid.name}</Stat>173 <Stat label="layers">{solid.layers}</Stat>174 <Stat label="voxels">{solid.voxels}</Stat>175 <Stat label="at or above">{view.count}</Stat>176 <Stat label="faces">{view.faces}</Stat>177 <Stat label="field">{solid.stats && `${solid.stats.min} to ${solid.stats.max}`}</Stat>178 </Stats>179 <Note error={solid.error ?? view.error} />180 </Page>181 );182}183184mount(<App />);