diff --git a/packages/render-2d/render-svg/src/render-side.ts b/packages/render-2d/render-svg/src/render-side.ts index e042295f..34931ade 100644 --- a/packages/render-2d/render-svg/src/render-side.ts +++ b/packages/render-2d/render-svg/src/render-side.ts @@ -12,8 +12,8 @@ // the pattern with every crease the program makes, and the parts are numbered // by their order on the paper along the cut. Two programs that leave the same // paper under the cut number it alike, whatever order they score their -// creases in. Hinges carry no names; the numbers of the strips they join -// identify them. +// creases in. A folded hinge on a crease the program names carries that +// name at its turn; other hinges carry none. import type { FoldScene, Vec2 } from "@beloch/scene"; import { pickStep, SceneError } from "@beloch/scene"; import { createDoc, el, SvgDoc } from "./svgdoc"; @@ -44,6 +44,12 @@ const APART = 4; // the width of one digit of a strip's number, and the room left between two // numbers on one level const DIGIT = 8, NUMBER_GAP = 4; +// the least pitch of the layers, to which they move closer where half +// circles would cross, and how many times its width the section may grow to +// beyond that +const MIN_PITCH = 22, MAX_WIDEN = 3; +// the width of one character of a crease's name beside a turn, and its height +const CHAR = 7, NAME_H = 14; // The stretch of a piece that lies in one face of the crease pattern. `name` // is its number in the order of the paper along the cut, counted from 1. @@ -53,7 +59,9 @@ export interface Piece { face: number; t0: number; t1: number; paper: [Vec2, Vec // Pieces that continue each other flat: one horizontal run in the drawing. export interface Strip { pieces: Piece[]; t0: number; t1: number; level: number } // A folded hinge on the line: two strips turn into each other at `t`. -export interface Turn { t: number; strips: [number, number]; out: 1 | -1 } +// `crease` is the name the program gives the crease of the hinge, without +// `--`, or null where it gives none. +export interface Turn { t: number; strips: [number, number]; out: 1 | -1; crease: string | null } // A named point of the state drawn that lies on the line, at `t` along it. // Every layer the line crosses there holds one paper point at that place; @@ -158,14 +166,16 @@ export function sideSection(scene: FoldScene, along: string, stepLabel?: string, const endsAt = (p: Piece, t: number) => Math.abs(p.t0 - t) < EPS || Math.abs(p.t1 - t) < EPS; const away = (p: Piece, t: number): 1 | -1 => (Math.abs(p.t0 - t) < EPS ? 1 : -1); const edges = pieces.map((p) => edgesOf(p.face)); - const hinges: { p: number; q: number; t: number; folded: boolean }[] = []; + const hinges: { p: number; q: number; t: number; folded: boolean; edge: string }[] = []; pieces.forEach((p, i) => pieces.forEach((q, j) => { if (j <= i) return; - const t = [...edges[i]!].filter(([k]) => edges[j]!.has(k)) - .map(([, e]) => crossing(e)) - .find((s) => s !== undefined && endsAt(p, s) && endsAt(q, s)); - if (t !== undefined) hinges.push({ p: i, q: j, t, folded: away(p, t) === away(q, t) }); + const hit = [...edges[i]!].filter(([k]) => edges[j]!.has(k)) + .map(([k, e]) => ({ k, t: crossing(e) })) + .find(({ t: s }) => s !== undefined && endsAt(p, s) && endsAt(q, s)); + if (hit) hinges.push({ p: i, q: j, t: hit.t!, folded: away(p, hit.t!) === away(q, hit.t!), edge: hit.k }); })); + // the name of the crease each edge of the frame lies on, where it has one + const creaseOf = new Map(frame.edgesVertices.map(([i, j], e) => [edgeKey(i, j), frame.edgesProvenance[e]?.name ?? null])); // strips: the pieces joined by flat hinges const parent = pieces.map((_, i) => i); @@ -179,7 +189,10 @@ export function sideSection(scene: FoldScene, along: string, stepLabel?: string, pieces.forEach((p, i) => { p.strip = roots.indexOf(find(i)); }); const turns: Turn[] = hinges.filter((h) => h.folded).map((h) => { const p = pieces[h.p]!; - return { t: h.t, strips: [p.strip, pieces[h.q]!.strip], out: away(p, h.t) === 1 ? -1 : 1 }; + return { + t: h.t, strips: [p.strip, pieces[h.q]!.strip], out: away(p, h.t) === 1 ? -1 : 1, + crease: creaseOf.get(h.edge) ?? null, + }; }); // The parts numbered in the order of the paper along the cut. Parts that @@ -274,54 +287,124 @@ export function renderSide(scene: FoldScene, opts: SideOptions): SvgDoc { const theme: Theme = { ...DEFAULT_THEME, ...opts.theme }; const color = (name: number) => theme.highlightPalette[(name - 1) % theme.highlightPalette.length]!.stroke; const halo = { stroke: theme.background, "stroke-width": 4, "paint-order": "stroke" }; - const y = (level: number) => PAD + level * GAP; - const radius = (u: Turn) => Math.abs(strips[u.strips[0]]!.level - strips[u.strips[1]]!.level) * GAP / 2; - - // where turns meet from both sides at one place, the drawing opens a gap - // there wide enough for both, and a strip running through it stretches - const tMin = Math.min(...strips.map((s) => s.t0)), tMax = Math.max(...strips.map((s) => s.t1)); - const widen = new Map(); + // A turn is a half circle on the place of its hinge along the line, from + // the end of one strip to the end of the other, bulging out past them; its + // radius is half the height between the two. Turns at one place that open to + // one side nest inside each other. + const span = (u: Turn) => { + const [l0, l1] = [strips[u.strips[0]]!.level, strips[u.strips[1]]!.level]; + return [Math.min(l0, l1), Math.max(l0, l1)] as const; + }; + // the crease names of the turns at one place on one side, one per crease, + // and the room they take + const namesAt = (us: Turn[]) => [...new Set(us.map((v) => v.crease).filter((c): c is string => c !== null))]; + const nameW = (c: string) => (c.length + 2) * CHAR; + const named = (us: Turn[]) => Math.max(0, ...namesAt(us).map((c) => nameW(c) + 4)); + type Group = { t: number; o: 1 | -1; us: Turn[] }; + const groups: Group[] = []; for (const u of turns) { - const both = turns.filter((v) => Math.abs(v.t - u.t) < EPS); - if (both.some((v) => v.out === 1) && both.some((v) => v.out === -1)) { - const reach = (o: 1 | -1) => Math.max(0, ...both.filter((v) => v.out === o).map(radius)); - widen.set(u.t, reach(1) + reach(-1) + SPLIT); - } + const g = groups.find((h) => Math.abs(h.t - u.t) < EPS && h.o === u.out); + if (g) g.us.push(u); + else groups.push({ t: u.t, o: u.out, us: [u] }); } - const gaps = [...widen.entries()].sort(([s], [t]) => s - t); - const outer = (o: 1 | -1) => Math.max(0, ...turns.filter((u) => u.out === o && !widen.has(u.t)).map(radius)); - const left = PAD + outer(-1), right = PAD + outer(1); - const room = SIDE_W - left - right - gaps.reduce((w, [, g]) => w + g, 0); - const scale = room / (tMax - tMin || 1); - // x of a place along the line; at a gap, the side a strip's material lies on - const x = (t: number, side: 1 | -1 = 1) => - left + (t - tMin) * scale + gaps.reduce((w, [g, width]) => - w + (g < t - EPS || (Math.abs(g - t) < EPS && side === 1) ? width : 0), 0); + const groupAt = (t: number, o: 1 | -1) => groups.find((g) => Math.abs(g.t - t) < EPS && g.o === o)!; + const tMin = Math.min(...strips.map((s) => s.t0)), tMax = Math.max(...strips.map((s) => s.t1)); + + // The layout for one pitch of the layers. Along the line the section keeps + // the proportions of the paper: one scale for the whole line, and the + // section grows wider for the turns that bulge out past its ends, so places that lie above one + // another in the folded state lie above one another in the section. Where + // turns open to both sides of one place, the half circles of one side would + // cover the strips that end there from the other side at every pitch and + // width; there alone the drawing opens a gap wide enough for both, and the + // strips that run on through the place stretch across it. + const twoSided = [...new Set(groups.filter((g) => g.o === 1 && groupAt(g.t, -1)).map((g) => g.t))].sort((m, n) => m - n); + const layout = (pitch: number, widen = 1) => { + const radius = (u: Turn) => (span(u)[1] - span(u)[0]) * pitch / 2; + const reach = (g: Group | undefined) => g ? Math.max(...g.us.map(radius)) + named(g.us) : 0; + const gaps = twoSided.map((t) => [t, reach(groupAt(t, 1)) + reach(groupAt(t, -1)) + SPLIT] as const); + const open = (g: Group) => !twoSided.some((t) => Math.abs(t - g.t) < EPS); + // the line itself takes the default width less the margins, stretched by + // `widen`; the margins, the gaps and the turns past the ends come on top + const scale = widen * (SIDE_W - 2 * PAD) / (tMax - tMin || 1); + const left = PAD + Math.max(0, ...groups.filter((g) => g.o === -1 && open(g)).map((g) => reach(g) - (g.t - tMin) * scale)); + const right = PAD + Math.max(0, ...groups.filter((g) => g.o === 1 && open(g)).map((g) => reach(g) - (tMax - g.t) * scale)); + const width = left + right + (tMax - tMin) * scale + gaps.reduce((w, [, d]) => w + d, 0); + // x of a place along the line; at a gap, on the side a strip's material + // lies on + const X = (t: number, side: 1 | -1 = 1) => left + (t - tMin) * scale + gaps.reduce((w, [g, d]) => + w + (g < t - EPS || (Math.abs(g - t) < EPS && side === 1) ? d : 0), 0); + // the place the turns of a group stand on: the end of the strips they join + const foot = (g: Group) => X(g.t, (-g.o) as 1 | -1); + return { pitch, width, radius, X, foot, y: (level: number) => PAD + level * pitch }; + }; + // Whether a turn of this layout crosses a strip or the turn of another + // place: a strip at a level strictly between the ends of a turn must stay + // out of its half circle, and two half circles must not cut each other. + const crosses = ({ radius, X, foot, y }: ReturnType) => { + const circles = turns.map((u) => { + const [lo, hi] = span(u); + return { u, o: u.out, cx: foot(groupAt(u.t, u.out)), cy: (y(lo) + y(hi)) / 2, r: radius(u), lo, hi }; + }); + for (const c of circles) { + for (const s of strips) { + if (s.level <= c.lo || s.level >= c.hi) continue; + const d = y(s.level) - c.cy; + const ext = Math.sqrt(Math.max(0, c.r * c.r - d * d)); + const [a0, a1] = c.o === 1 ? [c.cx, c.cx + ext] : [c.cx - ext, c.cx]; + if (Math.min(X(s.t1, -1), a1) - Math.max(X(s.t0, 1), a0) > 1) return true; + } + for (const e of circles) { + if (e === c || Math.abs(e.u.t - c.u.t) < EPS) continue; + const dd = Math.hypot(e.cx - c.cx, e.cy - c.cy); + if (dd >= c.r + e.r || dd <= Math.abs(c.r - e.r) || dd < 1e-9) continue; + // the two points where the circles meet, each on both half circles? + const k = (c.r * c.r - e.r * e.r + dd * dd) / (2 * dd); + const h = Math.sqrt(Math.max(0, c.r * c.r - k * k)); + const [ux, uy] = [(e.cx - c.cx) / dd, (e.cy - c.cy) / dd]; + for (const sgn of [1, -1]) { + const [px, py] = [c.cx + k * ux - sgn * h * uy, c.cy + k * uy + sgn * h * ux]; + if (c.o * (px - c.cx) > 1 && e.o * (px - e.cx) > 1) return true; + } + } + } + return false; + }; + // Where the half circles at the default pitch would cross, the layers move + // closer together, which makes every half circle smaller, down to a pitch of + // MIN_PITCH, the least that still holds a strip's number between two layers. + // Where they cross even there, the whole line grows longer at one scale, + // by a tenth at a time up to MAX_WIDEN times its length, which moves places + // apart and leaves the half circles as they are. Past that the half circles + // are drawn crossing. + let fit = layout(GAP); + for (let p = GAP - 2; p >= MIN_PITCH && crosses(fit); p -= 2) fit = layout(p); + for (let w = 1.1; w <= MAX_WIDEN + 1e-9 && crosses(fit); w += 0.1) fit = layout(MIN_PITCH, w); + const { radius, X, foot, y, pitch, width: sideW } = fit; const levels = Math.max(...strips.map((s) => s.level)) + 1; - const sideH = 2 * PAD + (levels - 1) * GAP + 28; + const sideH = 2 * PAD + (levels - 1) * pitch + 28; const nodes: SvgNode[] = []; - const at = (t: number, s: Strip) => (Math.abs(t - s.t1) < EPS ? -1 : 1); // two strips of one level that meet share no hinge there, only raw edges: // each stops short of the place, so a gap stands between them const meets = (s: Strip, t: number) => strips.some((o) => o !== s && o.level === s.level && (Math.abs(o.t0 - t) < EPS || Math.abs(o.t1 - t) < EPS)); - const numbers: { name: number; level: number; x: number }[] = []; + const numbers: { name: number; level: number; x: number; end: number }[] = []; for (const s of strips) { const parts = s.pieces.flatMap((p) => p.parts).sort((p, q) => p.t0 - q.t0); for (const r of parts) { // a part that ends inside the strip runs on to where the next one starts, // across a gap the strip stretches through const [first, last] = [Math.abs(r.t0 - s.t0) < EPS, Math.abs(r.t1 - s.t1) < EPS]; - const x0 = x(r.t0, 1) + (first && meets(s, s.t0) ? APART : 0); - const x1 = x(r.t1, last ? -1 : 1) - (last && meets(s, s.t1) ? APART : 0); + const x0 = X(r.t0, 1) + (first && meets(s, s.t0) ? APART : 0); + const x1 = X(r.t1, last ? -1 : 1) - (last && meets(s, s.t1) ? APART : 0); nodes.push(el("line", { "data-kind": "layer", "data-name": r.name, "data-level": s.level, x1: x0, y1: y(s.level), x2: x1, y2: y(s.level), stroke: color(r.name), "stroke-width": 3, "stroke-linecap": "round", })); // at the start of the part, clear of the points and names over its middle - numbers.push({ name: r.name, level: s.level, x: x0 + (first ? 14 : 8) }); + numbers.push({ name: r.name, level: s.level, x: x0 + 6, end: x1 }); if (Math.abs(r.t0 - s.t0) > EPS) { nodes.push(el("line", { "data-kind": "seam", x1: x0, y1: y(s.level) - 5, x2: x0, y2: y(s.level) + 5, @@ -331,12 +414,12 @@ export function renderSide(scene: FoldScene, opts: SideOptions): SvgDoc { } } // The numbers of one level stand in the order of their parts, each where its - // part starts, unless the number before it on the level would overlap it: - // then it moves right until it stands clear of that number. A part too short - // for its number has it standing past its end, still in order and in its - // color. + // part starts and short of its end, unless the number before it on the + // level would overlap it: then it moves right until it stands clear of that + // number. Numbers move; strips do not. numbers.sort((m, n) => m.level - n.level || m.x - n.x); numbers.forEach((m, i) => { + m.x = Math.max(m.x - 6, Math.min(m.x, m.end - String(m.name).length * DIGIT)); const before = numbers[i - 1]; if (before && before.level === m.level) { m.x = Math.max(m.x, before.x + String(before.name).length * DIGIT + NUMBER_GAP); @@ -346,21 +429,47 @@ export function renderSide(scene: FoldScene, opts: SideOptions): SvgDoc { "font-size": 13, "font-weight": 600, fill: color(m.name), }, [], String(m.name))); }); - for (const u of turns) { - const [p, q] = [strips[u.strips[0]]!, strips[u.strips[1]]!]; - const xh = x(u.t, at(u.t, p)); - const [y0, y1] = [y(p.level), y(q.level)]; - const r = radius(u); - nodes.push(el("path", { - "data-kind": "turn", "data-out": u.out, - d: `M ${xh} ${y0} A ${r} ${r} 0 0 ${(u.out === 1) === (y0 < y1) ? 1 : 0} ${xh} ${y1}`, - fill: "none", stroke: theme.ink, "stroke-width": 2, - })); + for (const g of groups) { + const xf = foot(g), sweep = g.o === 1 ? 1 : 0; + for (const u of g.us) { + const [lo, hi] = span(u); + const [ya, yb, r] = [y(lo), y(hi), radius(u)]; + nodes.push(el("path", { + "data-kind": "turn", "data-out": g.o, "data-x": xf, "data-r": r, "data-y0": ya, "data-y1": yb, + d: `M ${xf} ${ya} A ${r} ${r} 0 0 ${sweep} ${xf} ${yb}`, + fill: "none", stroke: theme.ink, "stroke-width": 2, + })); + } + } + // A crease's name stands once for each place and side, outside the + // outermost turn there, level with the middle of the outermost turn on that + // crease. Where it would overlap a name placed before it, it moves down by + // the height of a name until it stands clear of all of them. + const creaseNames: { name: string; x: number; y: number; w: number }[] = []; + for (const g of groups) { + const out = Math.max(...g.us.map(radius)); + for (const c of namesAt(g.us)) { + const top = g.us.filter((v) => v.crease === c).sort((u, v) => radius(v) - radius(u))[0]!; + const [lo, hi] = span(top); + const w = nameW(c); + creaseNames.push({ name: `--${c}`, x: g.o === 1 ? foot(g) + out + 4 : foot(g) - out - 4 - w, y: (y(lo) + y(hi)) / 2 + 4, w }); + } + } + const placed: { x: number; y: number; w: number }[] = []; + const clash = (m: { x: number; y: number; w: number }) => placed.some((o) => + Math.min(o.x + o.w, m.x + m.w) - Math.max(o.x, m.x) > 0 && Math.abs(o.y - m.y) < NAME_H); + for (const m of creaseNames.sort((a, b) => a.y - b.y || a.x - b.x)) { + while (clash(m)) m.y += NAME_H; + placed.push(m); + nodes.push(el("text", { + "data-kind": "crease-name", "data-name": m.name, x: m.x, y: m.y, "text-anchor": "start", + "font-size": 12, fill: theme.ink, ...halo, + }, [], m.name)); } // a named point on the line: where it stands across the whole stack, as Ida // marks the place of a fold line [ida2007modeling, Fig. 7] for (const p of points) { - const xp = (x(p.t, -1) + x(p.t, 1)) / 2; + const xp = (X(p.t, -1) + X(p.t, 1)) / 2; nodes.push(el("line", { "data-kind": "point", "data-name": p.name, x1: xp, y1: y(0) - 18, x2: xp, y2: y(levels - 1) + 10, @@ -374,14 +483,14 @@ export function renderSide(scene: FoldScene, opts: SideOptions): SvgDoc { for (const s of p.spots.filter((s) => s.own)) { const u = turns.find((v) => Math.abs(v.t - p.t) < EPS && v.strips.includes(s.strip)); const [cx, cy] = u - ? [x(u.t, at(u.t, strips[u.strips[0]]!)) + u.out * radius(u), + ? [foot(groupAt(u.t, u.out)) + u.out * radius(u), (y(strips[u.strips[0]]!.level) + y(strips[u.strips[1]]!.level)) / 2] : [xp, y(strips[s.strip]!.level)]; nodes.push(el("circle", { "data-kind": "point-own", "data-name": p.name, cx, cy, r: 4.5, fill: theme.ink })); } } nodes.push(el("text", { - x: SIDE_W / 2, y: sideH - 12, "text-anchor": "middle", "font-size": 15, fill: theme.ink, + x: sideW / 2, y: sideH - 12, "text-anchor": "middle", "font-size": 15, fill: theme.ink, }, [], opts.title ?? `along --${opts.along}, top layer first`)); // the crease pattern with the pieces of the paper the line crosses @@ -464,9 +573,9 @@ export function renderSide(scene: FoldScene, opts: SideOptions): SvgDoc { } const H = Math.max(lay.H, sideH); - const doc = createDoc(2 * lay.W + SIDE_W, H); + const doc = createDoc(2 * lay.W + sideW, H); doc.root.children.push( - el("rect", { width: 2 * lay.W + SIDE_W, height: H, fill: theme.background }), + el("rect", { width: 2 * lay.W + sideW, height: H, fill: theme.background }), el("svg", { ...folded.attrs, x: 0, y: 0 }, [...folded.children, ...cut]), el("svg", { ...cp.attrs, x: lay.W, y: 0 }, [...cp.children, ...marks]), el("g", { transform: `translate(${2 * lay.W} ${(H - sideH) / 2})` }, nodes), diff --git a/packages/render-2d/render-svg/test/fixtures/side-named.bel b/packages/render-2d/render-svg/test/fixtures/side-named.bel new file mode 100644 index 00000000..e244f7f5 --- /dev/null +++ b/packages/render-2d/render-svg/test/fixtures/side-named.bel @@ -0,0 +1,7 @@ +@author "Claude (Anthropic)" +paper square +fold (map .b onto .a) as --d +.m = --d * --ab +fold (map .m onto .a) +.e = free on --da from .a at 1/2 +--k = (perp --da through .e) diff --git a/packages/render-2d/render-svg/test/fixtures/side-named.fold b/packages/render-2d/render-svg/test/fixtures/side-named.fold new file mode 100644 index 00000000..af40bd7c --- /dev/null +++ b/packages/render-2d/render-svg/test/fixtures/side-named.fold @@ -0,0 +1,549 @@ +{ + "file_spec": 1.1, + "file_creator": "beloch 0.4.0", + "file_author": "Claude (Anthropic)", + "frame_classes": [ "creasePattern" ], + "frame_unit": "unit", + "vertices_coords": [ + [ 0.0, 0.0 ], + [ 0.25, 0.0 ], + [ 0.25, 1.0 ], + [ 0.0, 1.0 ], + [ 0.75, 0.0 ], + [ 1.0, 0.0 ], + [ 1.0, 1.0 ], + [ 0.75, 1.0 ], + [ 0.5, 0.0 ], + [ 0.5, 1.0 ] + ], + "edges_vertices": [ + [ 0, 1 ], + [ 1, 2 ], + [ 2, 3 ], + [ 3, 0 ], + [ 4, 5 ], + [ 5, 6 ], + [ 6, 7 ], + [ 7, 4 ], + [ 8, 4 ], + [ 7, 9 ], + [ 9, 8 ], + [ 1, 8 ], + [ 9, 2 ] + ], + "edges_assignment": [ + "B", "V", "B", "B", "B", "B", "B", "M", "B", "B", "V", "B", "B" + ], + "edges_foldAngle": [ + 0.0, 180.0, 0.0, 0.0, 0.0, 0.0, 0.0, -180.0, 0.0, 0.0, 180.0, 0.0, 0.0 + ], + "edges_faces": [ + [ 0 ], + [ 0, 3 ], + [ 0 ], + [ 0 ], + [ 1 ], + [ 1 ], + [ 1 ], + [ 1, 2 ], + [ 2 ], + [ 2 ], + [ 2, 3 ], + [ 3 ], + [ 3 ] + ], + "faces_vertices": [ + [ 0, 1, 2, 3 ], [ 4, 5, 6, 7 ], [ 8, 4, 7, 9 ], [ 1, 8, 9, 2 ] + ], + "beloch:edges": [ + null, + { + "axiom": "axiom2", + "sources": [ ".m", ".a" ], + "span": "side-named.bel:5:1-22", + "statement": 2, + "name": null, + "crease_id": 1 + }, + null, + null, + null, + null, + null, + { + "axiom": "axiom2", + "sources": [ ".m", ".a" ], + "span": "side-named.bel:5:1-22", + "statement": 2, + "name": null, + "crease_id": 1 + }, + null, + null, + { + "axiom": "axiom2", + "sources": [ ".b", ".a" ], + "span": "side-named.bel:3:1-29", + "statement": 0, + "name": "d", + "crease_id": 0 + }, + null, + null + ], + "beloch:inspect": { + "creases": { + "1": { + "name": null, + "axiom": "axiom2", + "sources": [ ".m", ".a" ], + "span": "side-named.bel:5:1-22", + "segments": [ + { + "faces": [ 1, 2 ], + "paper": [ [ 0.75, 1.0 ], [ 0.75, 0.0 ] ], + "table": [ [ 0.25, 1.0 ], [ 0.25, 0.0 ] ], + "assignment": "M" + }, + { + "faces": [ 0, 3 ], + "paper": [ [ 0.25, 1.0 ], [ 0.25, 0.0 ] ], + "table": [ [ 0.25, 1.0 ], [ 0.25, 0.0 ] ], + "assignment": "V" + } + ] + }, + "0": { + "name": "d", + "axiom": "axiom2", + "sources": [ ".b", ".a" ], + "span": "side-named.bel:3:1-29", + "segments": [ + { + "faces": [ 3, 2 ], + "paper": [ [ 0.5, 1.0 ], [ 0.5, 0.0 ] ], + "table": [ [ 0.0, 1.0 ], [ 0.0, 0.0 ] ], + "assignment": "V" + } + ] + } + }, + "faces": { + "0": { + "vertices": [ + [ 0.0, 0.0 ], [ 0.25, 0.0 ], [ 0.25, 1.0 ], [ 0.0, 1.0 ] + ], + "flap": 0, + "rank": 0 + }, + "1": { + "vertices": [ + [ 0.75, 0.0 ], [ 1.0, 0.0 ], [ 1.0, 1.0 ], [ 0.75, 1.0 ] + ], + "flap": 1, + "rank": 1 + }, + "2": { + "vertices": [ + [ 0.5, 0.0 ], [ 0.75, 0.0 ], [ 0.75, 1.0 ], [ 0.5, 1.0 ] + ], + "flap": 2, + "rank": 2 + }, + "3": { + "vertices": [ + [ 0.25, 0.0 ], [ 0.5, 0.0 ], [ 0.5, 1.0 ], [ 0.25, 1.0 ] + ], + "flap": 3, + "rank": 3 + } + }, + "points": { + "a": { "face": 0, "flap": 0 }, + "b": { "face": 1, "flap": 1 }, + "c": { "face": 1, "flap": 1 }, + "d": { "face": 0, "flap": 0 }, + "e": { "face": 0, "flap": 0 }, + "m": { "face": null, "flap": null } + }, + "edges": { + "ab": { + "name": "ab", + "assignment": "B", + "segments": [ + { + "faces": [ 0 ], + "paper": [ [ 0.0, 0.0 ], [ 0.25, 0.0 ] ], + "table": [ [ 0.0, 0.0 ], [ 0.25, 0.0 ] ], + "assignment": "B" + }, + { + "faces": [ 1 ], + "paper": [ [ 0.75, 0.0 ], [ 1.0, 0.0 ] ], + "table": [ [ 0.25, 0.0 ], [ 0.0, 0.0 ] ], + "assignment": "B" + }, + { + "faces": [ 2 ], + "paper": [ [ 0.5, 0.0 ], [ 0.75, 0.0 ] ], + "table": [ [ 0.0, 0.0 ], [ 0.25, 0.0 ] ], + "assignment": "B" + }, + { + "faces": [ 3 ], + "paper": [ [ 0.25, 0.0 ], [ 0.5, 0.0 ] ], + "table": [ [ 0.25, 0.0 ], [ 0.0, 0.0 ] ], + "assignment": "B" + } + ] + }, + "da": { + "name": "da", + "assignment": "B", + "segments": [ + { + "faces": [ 0 ], + "paper": [ [ 0.0, 1.0 ], [ 0.0, 0.0 ] ], + "table": [ [ 0.0, 1.0 ], [ 0.0, 0.0 ] ], + "assignment": "B" + } + ] + }, + "cd": { + "name": "cd", + "assignment": "B", + "segments": [ + { + "faces": [ 0 ], + "paper": [ [ 0.25, 1.0 ], [ 0.0, 1.0 ] ], + "table": [ [ 0.25, 1.0 ], [ 0.0, 1.0 ] ], + "assignment": "B" + }, + { + "faces": [ 1 ], + "paper": [ [ 1.0, 1.0 ], [ 0.75, 1.0 ] ], + "table": [ [ 0.0, 1.0 ], [ 0.25, 1.0 ] ], + "assignment": "B" + }, + { + "faces": [ 2 ], + "paper": [ [ 0.75, 1.0 ], [ 0.5, 1.0 ] ], + "table": [ [ 0.25, 1.0 ], [ 0.0, 1.0 ] ], + "assignment": "B" + }, + { + "faces": [ 3 ], + "paper": [ [ 0.5, 1.0 ], [ 0.25, 1.0 ] ], + "table": [ [ 0.0, 1.0 ], [ 0.25, 1.0 ] ], + "assignment": "B" + } + ] + }, + "bc": { + "name": "bc", + "assignment": "B", + "segments": [ + { + "faces": [ 1 ], + "paper": [ [ 1.0, 0.0 ], [ 1.0, 1.0 ] ], + "table": [ [ 0.0, 0.0 ], [ 0.0, 1.0 ] ], + "assignment": "B" + } + ] + } + } + }, + "beloch:vertices_names": [ + "a", null, null, "d", null, "b", "c", null, "m", null + ], + "beloch:named_points": { + "a": { + "paper": [ 0.0, 0.0 ], + "table": [ 0.0, 0.0 ], + "step": 0, + "statement": null + }, + "b": { + "paper": [ 1.0, 0.0 ], + "table": [ 0.0, 0.0 ], + "step": 0, + "statement": null + }, + "c": { + "paper": [ 1.0, 1.0 ], + "table": [ 0.0, 1.0 ], + "step": 0, + "statement": null + }, + "d": { + "paper": [ 0.0, 1.0 ], + "table": [ 0.0, 1.0 ], + "step": 0, + "statement": null + }, + "e": { + "paper": [ 0.0, 0.5 ], + "table": [ 0.0, 0.5 ], + "step": 2, + "statement": 3 + }, + "m": { + "paper": [ 0.5, 0.0 ], + "table": [ 0.0, 0.0 ], + "step": 1, + "statement": 1 + } + }, + "beloch:named_lines": { + "d": { "coeffs": [ -1.0, 0.0, 0.0 ], "step": 1, "statement": 0 }, + "k": { "coeffs": [ 0.0, 1.0, 0.5 ], "step": 2, "statement": 4 } + }, + "beloch:named_lines_frame": "creasePattern", + "beloch:marks": [], + "beloch:free": { + "e": { + "t": "1/2", + "endpoints": [ [ 0.0, 0.0 ], [ 0.0, 1.0 ] ], + "source_line": 6 + } + }, + "beloch:statements": [ + { + "mark": null, + "kind": "fold", + "parent": null, + "source_line": 3, + "span": "side-named.bel:3:1-29", + "frame_index": 1, + "kept_marks": [] + }, + { + "mark": null, + "kind": "bind", + "parent": null, + "source_line": 4, + "span": "side-named.bel:4:1-16", + "frame_index": 1, + "kept_marks": [] + }, + { + "mark": null, + "kind": "fold", + "parent": null, + "source_line": 5, + "span": "side-named.bel:5:1-22", + "frame_index": 2, + "kept_marks": [] + }, + { + "mark": null, + "kind": "bind", + "parent": null, + "source_line": 6, + "span": "side-named.bel:6:1-33", + "frame_index": 2, + "kept_marks": [] + }, + { + "mark": null, + "kind": "bind", + "parent": null, + "source_line": 7, + "span": "side-named.bel:7:1-29", + "frame_index": 2, + "kept_marks": [] + } + ], + "beloch:references": [ + { "span": "side-named.bel:4:6-9", "crease_id": 0 }, + { "span": "side-named.bel:4:12-16", "edge": "ab" }, + { "span": "side-named.bel:6:14-18", "edge": "da" }, + { "span": "side-named.bel:7:13-17", "edge": "da" } + ], + "beloch:annotations": [ + { + "key": "author", + "namespace": null, + "target": "program", + "frame_index": 0, + "source_line": 1, + "span": "side-named.bel:1:1-2:1", + "args": [ + { "text": "Claude (Anthropic)", "span": "side-named.bel:1:9-29" } + ] + } + ], + "file_frames": [ + { + "frame_classes": [ "foldedForm" ], + "frame_parent": 0, + "frame_inherit": false, + "vertices_coords": [ + [ 0.0, 0.0 ], [ 1.0, 0.0 ], [ 1.0, 1.0 ], [ 0.0, 1.0 ] + ], + "edges_vertices": [ [ 0, 1 ], [ 1, 2 ], [ 2, 3 ], [ 3, 0 ] ], + "edges_assignment": [ "B", "B", "B", "B" ], + "edges_foldAngle": [ 0.0, 0.0, 0.0, 0.0 ], + "edges_faces": [ [ 0 ], [ 0 ], [ 0 ], [ 0 ] ], + "faces_vertices": [ [ 0, 1, 2, 3 ] ], + "beloch:faces_matrix": [ [ 1.0, 0.0, 0.0, 1.0, 0.0, 0.0 ] ], + "faceOrders": [], + "beloch:source_line": null, + "beloch:edges": [ null, null, null, null ], + "beloch:vertices_names": [ "a", "b", "c", "d" ] + }, + { + "frame_classes": [ "foldedForm" ], + "frame_parent": 0, + "frame_inherit": false, + "vertices_coords": [ + [ 0.0, 0.0 ], + [ 0.5, 0.0 ], + [ 0.5, 1.0 ], + [ 0.0, 1.0 ], + [ 0.0, 0.0 ], + [ 0.0, 1.0 ] + ], + "edges_vertices": [ + [ 0, 1 ], [ 1, 2 ], [ 2, 3 ], [ 3, 0 ], [ 1, 4 ], [ 4, 5 ], [ 5, 2 ] + ], + "edges_assignment": [ "B", "V", "B", "B", "B", "B", "B" ], + "edges_foldAngle": [ 0.0, 180.0, 0.0, 0.0, 0.0, 0.0, 0.0 ], + "edges_faces": [ [ 0 ], [ 0, 1 ], [ 0 ], [ 0 ], [ 1 ], [ 1 ], [ 1 ] ], + "faces_vertices": [ [ 0, 1, 2, 3 ], [ 1, 4, 5, 2 ] ], + "beloch:faces_matrix": [ + [ 1.0, 0.0, 0.0, 1.0, 0.0, 0.0 ], [ -1.0, 0.0, 0.0, 1.0, 1.0, 0.0 ] + ], + "faceOrders": [ [ 0, 1, 1 ] ], + "beloch:source_line": 3, + "beloch:edges": [ + null, + { + "axiom": "axiom2", + "sources": [ ".b", ".a" ], + "span": "side-named.bel:3:1-29", + "statement": 0, + "name": "d", + "crease_id": 0 + }, + null, + null, + null, + null, + null + ], + "beloch:vertices_names": [ "a", "m", null, "d", "b", "c" ] + }, + { + "frame_classes": [ "foldedForm" ], + "frame_parent": 0, + "frame_inherit": false, + "vertices_coords": [ + [ 0.0, 0.0 ], + [ 0.25, 0.0 ], + [ 0.25, 1.0 ], + [ 0.0, 1.0 ], + [ 0.25, 0.0 ], + [ 0.0, 0.0 ], + [ 0.0, 1.0 ], + [ 0.25, 1.0 ], + [ 0.0, 0.0 ], + [ 0.0, 1.0 ] + ], + "edges_vertices": [ + [ 0, 1 ], + [ 1, 2 ], + [ 2, 3 ], + [ 3, 0 ], + [ 4, 5 ], + [ 5, 6 ], + [ 6, 7 ], + [ 7, 4 ], + [ 8, 4 ], + [ 7, 9 ], + [ 9, 8 ], + [ 1, 8 ], + [ 9, 2 ] + ], + "edges_assignment": [ + "B", "V", "B", "B", "B", "B", "B", "M", "B", "B", "V", "B", "B" + ], + "edges_foldAngle": [ + 0.0, 180.0, 0.0, 0.0, 0.0, 0.0, 0.0, -180.0, 0.0, 0.0, 180.0, 0.0, + 0.0 + ], + "edges_faces": [ + [ 0 ], + [ 0, 3 ], + [ 0 ], + [ 0 ], + [ 1 ], + [ 1 ], + [ 1 ], + [ 1, 2 ], + [ 2 ], + [ 2 ], + [ 2, 3 ], + [ 3 ], + [ 3 ] + ], + "faces_vertices": [ + [ 0, 1, 2, 3 ], [ 4, 5, 6, 7 ], [ 8, 4, 7, 9 ], [ 1, 8, 9, 2 ] + ], + "beloch:faces_matrix": [ + [ 1.0, 0.0, 0.0, 1.0, 0.0, 0.0 ], + [ -1.0, 0.0, 0.0, 1.0, 1.0, 0.0 ], + [ 1.0, 0.0, 0.0, 1.0, -0.5, 0.0 ], + [ -1.0, 0.0, 0.0, 1.0, 0.5, 0.0 ] + ], + "faceOrders": [ + [ 0, 1, 1 ], + [ 0, 2, -1 ], + [ 0, 3, 1 ], + [ 1, 2, -1 ], + [ 1, 3, 1 ], + [ 2, 3, 1 ] + ], + "beloch:source_line": 5, + "beloch:edges": [ + null, + { + "axiom": "axiom2", + "sources": [ ".m", ".a" ], + "span": "side-named.bel:5:1-22", + "statement": 2, + "name": null, + "crease_id": 1 + }, + null, + null, + null, + null, + null, + { + "axiom": "axiom2", + "sources": [ ".m", ".a" ], + "span": "side-named.bel:5:1-22", + "statement": 2, + "name": null, + "crease_id": 1 + }, + null, + null, + { + "axiom": "axiom2", + "sources": [ ".b", ".a" ], + "span": "side-named.bel:3:1-29", + "statement": 0, + "name": "d", + "crease_id": 0 + }, + null, + null + ], + "beloch:vertices_names": [ + "a", null, null, "d", null, "b", "c", null, "m", null + ] + } + ] +} diff --git a/packages/render-2d/render-svg/test/render-side.test.ts b/packages/render-2d/render-svg/test/render-side.test.ts index 74fb0d35..df0056ce 100644 --- a/packages/render-2d/render-svg/test/render-side.test.ts +++ b/packages/render-2d/render-svg/test/render-side.test.ts @@ -79,9 +79,9 @@ test("the parts are numbered 1 to n in the order of the paper along the cut, and test.each([1, 2, 3])("no two turns of state %i cross or touch, unless one wraps the other", async (n) => { const svg = renderSide(await fixture(n), { along: "s" }).toString(); // the box of each turn: from its hinge out by its radius, between its levels - const boxes = [...svg.matchAll(/data-kind="turn" data-out="(-?1)" d="M ([\d.]+) ([\d.]+) A ([\d.]+) [\d.]+ 0 0 \d [\d.]+ ([\d.]+)"/g)] + const boxes = [...svg.matchAll(/data-kind="turn" data-out="(-?1)" data-x="([\d.]+)" data-r="([\d.]+)" data-y0="([\d.]+)" data-y1="([\d.]+)"/g)] .map((m) => { - const [out, x, y0, r, y1] = [1, 2, 3, 4, 5].map((k) => Number(m[k])); + const [out, x, r, y0, y1] = [1, 2, 3, 4, 5].map((k) => Number(m[k])); return { x: out! > 0 ? [x!, x! + r!] : [x! - r!, x!], y: [Math.min(y0!, y1!), Math.max(y0!, y1!)] }; }); expect(boxes.length).toBe(6); @@ -139,7 +139,9 @@ test("the folded state beside the section carries the cut", async () => { // the folded state is the first panel, left of the crease pattern const W = sceneLayout(scene).W; expect(Math.max(Number(cut![1]), Number(cut![2]))).toBeLessThan(W); - expect(svg).toContain(`width="${2 * W + 572}"`); + // the section beside it is at least 572 px wide, wider where turns bulge + // out past the ends of the line + expect(Number(svg.match(/^]* width="([\d.]+)"/)![1])).toBeGreaterThanOrEqual(2 * W + 572); }); // The half-folded sheet reversed (#119), cut along its top edge: @@ -289,7 +291,8 @@ test("the numbers of one level stand apart where two faces of the crease pattern const numbers = [...svg.matchAll(/data-kind="layer-name" data-name="(\d+)" x="([\d.]+)"/g)] .map((m) => ({ name: m[1]!, x: Number(m[2]) })).sort((m, n) => m.x - n.x); expect(numbers.map((m) => m.name)).toEqual(["1", "2", "3"]); - // the part between the marks is narrower than its number + // the part between the marks, 1/64 of the line, is narrower than its + // number, since the drawing keeps the proportions of the paper const lines = drawn(svg, 0).lines; expect(lines[1]!.x2 - lines[1]!.x1).toBeLessThan(8); // each number starts past the end of the one before it, at 8 px a digit @@ -313,3 +316,42 @@ test("a line along a crease draws each layer once", async () => { expect(strips.map((s) => s.level).sort()).toEqual([0, 1]); expect(turns.length).toBe(1); }); + +// A sheet folded in half along a crease the program names, then in half +// again along one it does not name, cut across both: +// +// paper square +// fold (map .b onto .a) as --d +// .m = --d * --ab +// fold (map .m onto .a) +// .e = free on --da from .a at 1/2 +// --k = (perp --da through .e) +test("a turn on a crease the program names carries that name, and a turn on an unnamed crease none", async () => { + const scene = parseFold(await Bun.file(new URL("./fixtures/side-named.fold", import.meta.url)).text()); + const { turns } = sideSection(scene, "k"); + // one turn on --d, two on the unnamed second crease + expect(turns.map((u) => u.crease).sort()).toEqual(["d", null, null]); + const svg = renderSide(scene, { along: "k" }).toString(); + expect([...svg.matchAll(/data-kind="crease-name" data-name="([^"]+)"/g)].map((m) => m[1])).toEqual(["--d"]); +}); + +test.each(["side-preliminary-1", "side-preliminary-2", "side-preliminary-3", "side-reverse", "side-cupboard", "side-named"])( + "no strip of %s reaches into a turn", async (name) => { + const scene = parseFold(await Bun.file(new URL(`./fixtures/${name}.fold`, import.meta.url)).text()); + const svg = renderSide(scene, { along: (scene.namedLines.find((l) => l.name === "s") ? "s" : "k") }).toString(); + // each turn a half circle of radius data-r from data-x, between y0 and y1 + const turns = [...svg.matchAll(/data-kind="turn" data-out="(-?1)" data-x="([\d.]+)" data-r="([\d.]+)" data-y0="([\d.]+)" data-y1="([\d.]+)"/g)] + .map((m) => [1, 2, 3, 4, 5].map((k) => Number(m[k])) as [number, number, number, number, number]); + expect(turns.length).toBeGreaterThan(0); + const lines = [...svg.matchAll(/data-kind="layer" data-name="\d+" data-level="\d+" x1="([\d.]+)" y1="([\d.]+)" x2="([\d.]+)"/g)] + .map((m) => ({ x: [Number(m[1]), Number(m[3])].sort((p, q) => p - q), y: Number(m[2]) })); + // a strip at a level strictly between the ends of a turn shares no width + // with the inside of the turn at that level + for (const [out, x, r, y0, y1] of turns) for (const l of lines) { + const [mid, h] = [(y0 + y1) / 2, Math.abs(y1 - y0) / 2]; + if (Math.abs(l.y - mid) >= h - 1e-6) continue; + const reach = x + out * r * Math.sqrt(1 - ((l.y - mid) / h) ** 2); + const [a, b] = [Math.min(x, reach), Math.max(x, reach)]; + expect(Math.min(l.x[1]!, b) - Math.max(l.x[0]!, a)).toBeLessThanOrEqual(1e-6); + } + }); diff --git a/spec/CLI.md b/spec/CLI.md index 2e9f9f89..ca36479c 100644 --- a/spec/CLI.md +++ b/spec/CLI.md @@ -112,8 +112,24 @@ The numbers depend on the paper under the cut and the state drawn alone, so two programs that fold the same state with creases scored in another order number it alike. A strip carries the number of each of its parts where the part starts; where the number before it on the level would overlap it, the number -moves right until it stands clear. Hinges carry no names. The crease pattern -beside the section carries the same parts with the same numbers and colors. +moves right until it stands clear. A folded hinge on a crease the program +names carries that name, as the program writes it (`--q`): once for each crease +at one place and side, outside the outermost turn there; where it would overlap +a name placed before it, it moves down until it stands clear. Other hinges +carry no name. + +Along the line the section keeps the proportions of the paper: one scale for +the whole line, so places that lie above one another in the folded state lie +above one another in the section. A turn is a half circle on the place of its +hinge, from the end of one strip to the end of the other and bulging out past +them; its radius is half the height between the two. Layers stand 34 px apart. +Where a half circle would cross a strip or the half circle of another place, +the layers move closer, 2 px at a time down to 22 px; where that is not +enough, the line grows longer at the same scale, a tenth at a time up to +three times its length. Where turns open to both sides of one place, the +section opens a gap there wide enough for both, and a strip that runs on +through the place stretches across it. The section grows wider than 572 px +where turns bulge out past the ends of the line or the line grows longer. A part that a later crease splits is drawn as the parts it will split into, divided by a short tick. Two pieces joined by a flat hinge run on as one layer