jspsych-tangram 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +25 -0
  2. package/dist/construct/index.browser.js +20431 -0
  3. package/dist/construct/index.browser.js.map +1 -0
  4. package/dist/construct/index.browser.min.js +42 -0
  5. package/dist/construct/index.browser.min.js.map +1 -0
  6. package/dist/construct/index.cjs +3720 -0
  7. package/dist/construct/index.cjs.map +1 -0
  8. package/dist/construct/index.d.ts +204 -0
  9. package/dist/construct/index.js +3718 -0
  10. package/dist/construct/index.js.map +1 -0
  11. package/dist/index.cjs +3920 -0
  12. package/dist/index.cjs.map +1 -0
  13. package/dist/index.d.ts +340 -0
  14. package/dist/index.js +3917 -0
  15. package/dist/index.js.map +1 -0
  16. package/dist/prep/index.browser.js +20455 -0
  17. package/dist/prep/index.browser.js.map +1 -0
  18. package/dist/prep/index.browser.min.js +42 -0
  19. package/dist/prep/index.browser.min.js.map +1 -0
  20. package/dist/prep/index.cjs +3744 -0
  21. package/dist/prep/index.cjs.map +1 -0
  22. package/dist/prep/index.d.ts +139 -0
  23. package/dist/prep/index.js +3742 -0
  24. package/dist/prep/index.js.map +1 -0
  25. package/package.json +77 -0
  26. package/src/core/components/README.md +249 -0
  27. package/src/core/components/board/BoardView.tsx +352 -0
  28. package/src/core/components/board/GameBoard.tsx +682 -0
  29. package/src/core/components/board/index.ts +70 -0
  30. package/src/core/components/board/useAnchorGrid.ts +110 -0
  31. package/src/core/components/board/useClickController.ts +436 -0
  32. package/src/core/components/board/useDragController.ts +1051 -0
  33. package/src/core/components/board/usePieceState.ts +178 -0
  34. package/src/core/components/board/utils.ts +76 -0
  35. package/src/core/components/index.ts +33 -0
  36. package/src/core/components/pieces/BlueprintRing.tsx +238 -0
  37. package/src/core/config/config.ts +85 -0
  38. package/src/core/domain/blueprints.ts +25 -0
  39. package/src/core/domain/layout.ts +159 -0
  40. package/src/core/domain/primitives.ts +159 -0
  41. package/src/core/domain/solve.ts +184 -0
  42. package/src/core/domain/types.ts +111 -0
  43. package/src/core/engine/collision/grid-snapping.ts +283 -0
  44. package/src/core/engine/collision/index.ts +4 -0
  45. package/src/core/engine/collision/sat-collision.ts +46 -0
  46. package/src/core/engine/collision/validation.ts +166 -0
  47. package/src/core/engine/geometry/bounds.ts +91 -0
  48. package/src/core/engine/geometry/collision.ts +64 -0
  49. package/src/core/engine/geometry/index.ts +19 -0
  50. package/src/core/engine/geometry/math.ts +101 -0
  51. package/src/core/engine/geometry/pieces.ts +290 -0
  52. package/src/core/engine/geometry/polygons.ts +43 -0
  53. package/src/core/engine/state/BaseGameController.ts +368 -0
  54. package/src/core/engine/validation/border-rendering.ts +318 -0
  55. package/src/core/engine/validation/complete.ts +102 -0
  56. package/src/core/engine/validation/face-to-face.ts +217 -0
  57. package/src/core/index.ts +3 -0
  58. package/src/core/io/InteractionTracker.ts +742 -0
  59. package/src/core/io/data-tracking.ts +271 -0
  60. package/src/core/io/json-to-tangram-spec.ts +110 -0
  61. package/src/core/io/quickstash.ts +141 -0
  62. package/src/core/io/stims.ts +110 -0
  63. package/src/core/types/index.ts +5 -0
  64. package/src/core/types/plugin-interfaces.ts +101 -0
  65. package/src/index.spec.ts +19 -0
  66. package/src/index.ts +2 -0
  67. package/src/plugins/tangram-construct/ConstructionApp.tsx +105 -0
  68. package/src/plugins/tangram-construct/index.ts +156 -0
  69. package/src/plugins/tangram-prep/PrepApp.tsx +182 -0
  70. package/src/plugins/tangram-prep/index.ts +122 -0
  71. package/tangram-construct.min.js +42 -0
  72. package/tangram-prep.min.js +42 -0
@@ -0,0 +1,159 @@
1
+ import type { LayoutMode, PlacementTarget, Poly } from "./types";
2
+ import { anchorsDiameterToPx } from "@/core/engine/geometry";
3
+ import { solveRadii, optimizeBandSplit } from "@/core/domain/solve";
4
+ import { CONFIG } from "@/core/config/config";
5
+
6
+ export type SectorGeom = { id: string; index: number; start: number; end: number; mid: number };
7
+
8
+ export type RingBands = {
9
+ silhouette: [number, number];
10
+ workspace: [number, number];
11
+ };
12
+
13
+ export type CircleLayout = {
14
+ mode: LayoutMode;
15
+ cx: number; cy: number;
16
+ innerR: number; outerR: number;
17
+ sectors: SectorGeom[];
18
+ bands: RingBands;
19
+ sweepStart: number;
20
+ sweepEnd: number;
21
+ };
22
+
23
+ export function computeCircleLayout(
24
+ viewport: { w: number; h: number },
25
+ n: number,
26
+ sectorIds: string[],
27
+ mode: LayoutMode = "circle",
28
+ target: PlacementTarget = "workspace",
29
+ extras: { qsMaxSlots: number; primitivesSlots: number; masks?: Poly[][] }
30
+ ): CircleLayout {
31
+ const pad = CONFIG.layout.paddingPx;
32
+
33
+ // place the semicircle so its diameter sits on the bottom edge of the SVG
34
+ const cx = viewport.w / 2;
35
+ const cy = mode === "semicircle" ? viewport.h : viewport.h / 2;
36
+
37
+ // The viewport bound must match the viewBox computed by solveLogicalBox so ring sizes stay consistent.
38
+ // vertical radius must not cross the top padding
39
+ const maxRVertical = mode === "semicircle" ? (viewport.h - pad) : cy;
40
+ const outerRViewportBound = Math.min(cx, maxRVertical) - pad;
41
+
42
+ // Solve *minimum* feasible radii and then optimize the band split within the viewport bound
43
+ const sol = solveRadii({
44
+ n,
45
+ layoutMode: mode,
46
+ target,
47
+ qsMaxSlots: extras.qsMaxSlots,
48
+ primitivesSlots: extras.primitivesSlots,
49
+ masks: extras.masks ?? [],
50
+ });
51
+
52
+ let innerR: number, outerR: number, bands: RingBands;
53
+
54
+ if (target === "workspace") {
55
+ const D_ws_px = anchorsDiameterToPx(CONFIG.layout.constraints.workspaceDiamAnchors);
56
+ const opt = optimizeBandSplit({
57
+ outerRViewportBound,
58
+ innerR_min: sol.innerR_min,
59
+ Tw_min: sol.Tw_min,
60
+ Tp_min: sol.Tp_min,
61
+ Rshape_px_max: sol.Rshape_px_max,
62
+ D_ws_px,
63
+ });
64
+ innerR = opt.innerR;
65
+ outerR = opt.outerR;
66
+ bands = {
67
+ workspace: [opt.innerR, opt.innerR + opt.Tw],
68
+ silhouette: [opt.innerR + opt.Tw, opt.outerR],
69
+ };
70
+ } else {
71
+ // silhouette-only: maximize thickness; entire ring is silhouette
72
+ outerR = Math.max(outerRViewportBound, sol.outerR_min);
73
+ // minimize innerR to maximize thickness, but not below minimal requirement
74
+ innerR = Math.min(sol.innerR_min, outerR);
75
+ if (outerR < sol.outerR_min) { outerR = sol.outerR_min; innerR = sol.innerR_min; }
76
+ bands = { silhouette: [innerR, outerR], workspace: [innerR, outerR] };
77
+ }
78
+
79
+ const sweep = mode === "circle" ? Math.PI * 2 : Math.PI;
80
+ // semicircle: sweep the upper arc by anchoring the diameter on the bottom (π→2π)
81
+ const sweepStart = mode === "circle" ? -Math.PI / 2 : Math.PI;
82
+ const sweepEnd = sweepStart + sweep;
83
+ const step = sweep / n;
84
+
85
+ const sectors: SectorGeom[] = [];
86
+ for (let i = 0; i < n; i++) {
87
+ const id = sectorIds[i] ?? String(i);
88
+ const start = sweepStart + i * step;
89
+ const end = start + step;
90
+ const mid = (start + end) / 2;
91
+ sectors.push({ id, index: i, start, end, mid });
92
+ }
93
+ return { mode, cx, cy, innerR, outerR, sectors, bands, sweepStart, sweepEnd };
94
+ }
95
+
96
+ /** Ring wedge path helper */
97
+ export function wedgePath(
98
+ cx: number, cy: number,
99
+ innerR: number, outerR: number,
100
+ start: number, end: number
101
+ ): string {
102
+ const largeArc = end - start > Math.PI ? 1 : 0;
103
+ const x0 = cx + innerR * Math.cos(start);
104
+ const y0 = cy + innerR * Math.sin(start);
105
+ const x1 = cx + outerR * Math.cos(start);
106
+ const y1 = cy + outerR * Math.sin(start);
107
+ const x2 = cx + outerR * Math.cos(end);
108
+ const y2 = cy + outerR * Math.sin(end);
109
+ const x3 = cx + innerR * Math.cos(end);
110
+ const y3 = cy + innerR * Math.sin(end);
111
+ return [
112
+ `M ${x0} ${y0}`,
113
+ `L ${x1} ${y1}`,
114
+ `A ${outerR} ${outerR} 0 ${largeArc} 1 ${x2} ${y2}`,
115
+ `L ${x3} ${y3}`,
116
+ `A ${innerR} ${innerR} 0 ${largeArc} 0 ${x0} ${y0}`,
117
+ "Z",
118
+ ].join(" ");
119
+ }
120
+
121
+ /** Sector hit-test (band optional) */
122
+ export function sectorAtPoint(
123
+ x: number, y: number, layout: CircleLayout, band?: keyof RingBands
124
+ ): string | undefined {
125
+ const dx = x - layout.cx;
126
+ const dy = y - layout.cy;
127
+ const r = Math.hypot(dx, dy);
128
+
129
+ const [rIn, rOut] = band ? layout.bands[band] : [layout.innerR, layout.outerR];
130
+ if (r < rIn || r > rOut) return undefined;
131
+
132
+ let theta = Math.atan2(dy, dx); // [-π, +π]
133
+ if (layout.mode === "circle") {
134
+ if (theta < -Math.PI / 2) theta += 2 * Math.PI;
135
+ } else {
136
+ // semicircle covers [π, 2π]
137
+ if (theta < Math.PI) theta += 2 * Math.PI;
138
+ }
139
+
140
+ for (const s of layout.sectors) {
141
+ if (theta >= s.start && theta < s.end) return s.id;
142
+ }
143
+ return layout.sectors.at(-1)?.id;
144
+ }
145
+
146
+ /** Convenience: axis-aligned rect for fitting silhouettes in a band */
147
+ export function rectForBand(
148
+ layout: CircleLayout, sector: SectorGeom, band: keyof RingBands, pad = 0.85
149
+ ) {
150
+ const [rIn, rOut] = layout.bands[band];
151
+ const rMid = (rIn + rOut) / 2;
152
+ const dr = rOut - rIn;
153
+ const arc = (sector.end - sector.start) * rMid * 0.9;
154
+ const w = arc * pad;
155
+ const h = dr * pad;
156
+ const cx = layout.cx + rMid * Math.cos(sector.mid);
157
+ const cy = layout.cy + rMid * Math.sin(sector.mid);
158
+ return { cx, cy, w, h };
159
+ }
@@ -0,0 +1,159 @@
1
+ import type { PrimitiveBlueprint, Poly, Vec, TanKind } from "@/core/domain/types";
2
+
3
+ // ===== numeric units (match Python SideLength scaling) ======================
4
+ const U = 40; // 1 UNIT in pixels
5
+ const HALFUNIT = U / 2;
6
+ const DIAGONAL = U * Math.SQRT2;
7
+ const HALFDIAGONAL = DIAGONAL / 2;
8
+
9
+ // ===== small vec helpers ====================================================
10
+ const P = (x: number, y: number): Vec => ({ x, y });
11
+
12
+ /** rotate array left by k (Python deque.rotate(-k)) */
13
+ function rotLeft<T>(arr: T[], k: number): T[] {
14
+ const n = arr.length; if (n === 0) return [];
15
+ const s = ((k % n) + n) % n;
16
+ return arr.slice(s).concat(arr.slice(0, s));
17
+ }
18
+
19
+ /**
20
+ * Mirrors your Python `next_point`:
21
+ * interior angle is CLOCKWISE at the vertex; we walk the boundary clockwise.
22
+ */
23
+ function nextPoint(
24
+ x0: number, y0: number, // previous vertex
25
+ x1: number, y1: number, // current vertex
26
+ interiorDeg: number,
27
+ dist: number
28
+ ): Vec {
29
+ const phi = Math.atan2(y0 - y1, x0 - x1); // incoming edge (x0->x1)
30
+ const theta = phi - (interiorDeg * Math.PI) / 180; // rotate clockwise by interior angle
31
+ return P(x1 + dist * Math.cos(theta), y1 + dist * Math.sin(theta));
32
+ }
33
+
34
+ /**
35
+ * Build polygon from side lengths & clockwise interior angles.
36
+ * If `firstEdgeUnits` is provided ([(x0,y0),(x1,y1)] in **unit** coords with +y up),
37
+ * we use that exact first edge. Otherwise we default to +X of length sideLens[0].
38
+ * Returns vertices without repeating the start (close is implied).
39
+ */
40
+ function constructFromSpec(
41
+ sideLens: number[],
42
+ angles: number[],
43
+ firstEdgeUnits?: [Vec, Vec]
44
+ ): Poly {
45
+ let pts: Vec[];
46
+ if (firstEdgeUnits) {
47
+ // map unit coords (up +y) -> canvas coords (down +y): invert y and scale by U
48
+ const a = firstEdgeUnits[0]; // typically (0,0)
49
+ const b = firstEdgeUnits[1];
50
+ const A = P(-a.x * U, a.y * U);
51
+ const B = P(-b.x * U, b.y * U);
52
+ pts = [A, B];
53
+ } else {
54
+ // default: first edge along +X
55
+ const firstSideLen = sideLens[0];
56
+ if (firstSideLen === undefined) throw new Error("No side lengths provided");
57
+ pts = [P(0, 0), P(firstSideLen, 0)];
58
+ }
59
+
60
+ // Python logic: rotate side lengths by -1 (since first edge already placed), angles unchanged
61
+ const sidesRot = rotLeft(sideLens, 1);
62
+ const angRot = angles;
63
+
64
+ // add all but the final closing edge
65
+ for (let i = 1; i < sideLens.length - 1; i++) {
66
+ const last = pts[i - 1];
67
+ const curr = pts[i];
68
+ if (!last || !curr) continue;
69
+ const sLen = sidesRot[i - 1];
70
+ const aDeg = angRot[i - 1];
71
+ if (sLen === undefined || aDeg === undefined) continue;
72
+ pts.push(nextPoint(last.x, last.y, curr.x, curr.y, aDeg, sLen));
73
+ }
74
+ return pts;
75
+ }
76
+
77
+ // ===== default first edges in UNIT coords ( +y is UP here ) =================
78
+ // Copied from your `default_pilot_rotations`. We invert y above for canvas.
79
+ const FIRST_EDGES_UNITS: Record<TanKind, [Vec, Vec]> = {
80
+ small_triangle: [P(0, 0), P(0.5, 0.5)],
81
+ parallelogram: [P(0, 0), P(0.5, 0)],
82
+ large_triangle: [P(0, 0), P(0.5, -0.5)],
83
+ med_triangle: [P(0, 0), P(0.5, 0)],
84
+ square: [P(0, 0), P(0.5, 0)],
85
+ };
86
+
87
+ // ===== canonical half-edge primitives ===================
88
+ // TODO: Sean we need to talk about aligning this with your Python implementation
89
+ // Unchanging primitive tangram pieces - these are constants, not configurable
90
+ const PRIMITIVE_BLUEPRINTS_CACHE = (() => {
91
+ // Sequences copied verbatim from your Python tanprimitives_halfedges()
92
+ const specs: Array<{
93
+ id: string;
94
+ kind: TanKind;
95
+ sideLens: number[];
96
+ angles: number[];
97
+ color: string;
98
+ }> = [
99
+ {
100
+ id: "prim:square",
101
+ kind: "square",
102
+ sideLens: [HALFUNIT, HALFUNIT, HALFUNIT, HALFUNIT, HALFUNIT, HALFUNIT, HALFUNIT, HALFUNIT],
103
+ angles: [180, 90, 180, 90, 180, 90, 180, 90],
104
+ color: "#f43f5e",
105
+ },
106
+ {
107
+ id: "prim:small",
108
+ kind: "small_triangle",
109
+ sideLens: [HALFDIAGONAL, HALFDIAGONAL, HALFUNIT, HALFUNIT, HALFUNIT, HALFUNIT],
110
+ angles: [180, 45, 180, 90, 180, 45],
111
+ color: "#f59e0b",
112
+ },
113
+ {
114
+ id: "prim:parallelogram",
115
+ kind: "parallelogram",
116
+ sideLens: [HALFUNIT, HALFUNIT, HALFDIAGONAL, HALFDIAGONAL, HALFUNIT, HALFUNIT, HALFDIAGONAL, HALFDIAGONAL],
117
+ angles: [180, 135, 180, 45, 180, 135, 180, 45],
118
+ color: "#10b981",
119
+ },
120
+ {
121
+ id: "prim:med",
122
+ kind: "med_triangle",
123
+ sideLens: [HALFUNIT, HALFUNIT, HALFUNIT, HALFUNIT, HALFDIAGONAL, HALFDIAGONAL, HALFDIAGONAL, HALFDIAGONAL],
124
+ angles: [180, 180, 180, 45, 180, 90, 180, 45],
125
+ color: "#3b82f6",
126
+ },
127
+ {
128
+ id: "prim:large",
129
+ kind: "large_triangle",
130
+ sideLens: [
131
+ HALFDIAGONAL, HALFDIAGONAL, HALFDIAGONAL, HALFDIAGONAL,
132
+ HALFUNIT, HALFUNIT, HALFUNIT, HALFUNIT, HALFUNIT, HALFUNIT, HALFUNIT, HALFUNIT
133
+ ],
134
+ angles: [
135
+ 180, 180, 180, 45,
136
+ 180, 180, 180, 90, 180, 180, 180, 45
137
+ ],
138
+ color: "#8b5cf6",
139
+ },
140
+ ];
141
+
142
+ return specs.map(({ id, kind, sideLens, angles, color }) => {
143
+ const firstEdge = FIRST_EDGES_UNITS[kind]; // always present for these 5
144
+ const verts = constructFromSpec(sideLens, angles, firstEdge);
145
+ return {
146
+ id,
147
+ kind,
148
+ shape: [verts],
149
+ colorHint: color,
150
+ } as PrimitiveBlueprint;
151
+ });
152
+ })();
153
+
154
+ export function primitiveBlueprintsHalfEdge(): PrimitiveBlueprint[] {
155
+ return PRIMITIVE_BLUEPRINTS_CACHE;
156
+ }
157
+
158
+ // Direct access to primitives constant (for dependency injection)
159
+ export const PRIMITIVE_BLUEPRINTS: PrimitiveBlueprint[] = PRIMITIVE_BLUEPRINTS_CACHE;
@@ -0,0 +1,184 @@
1
+ // src/core/domain/solve.ts
2
+ import type { LayoutMode, PlacementTarget, Poly } from "@/core/domain/types";
3
+ import { anchorsDiameterToPx } from "@/core/engine/geometry";
4
+ import { CONFIG } from "@/core/config/config";
5
+
6
+ // --- helpers to infer lattice unit and silhouette radius -------------------
7
+ function igcd(a: number, b: number) {
8
+ a = Math.round(Math.abs(a)); b = Math.round(Math.abs(b));
9
+ while (b) [a, b] = [b, a % b];
10
+ return a || 1;
11
+ }
12
+ function inferUnitFromPolys(polys: Poly[]): number {
13
+ let g = 0;
14
+ for (const poly of polys) {
15
+ for (let i = 0; i < poly.length; i++) {
16
+ const a = poly[i], b = poly[(i + 1) % poly.length];
17
+ if (!a || !b) continue;
18
+ const dx = Math.round(Math.abs(b.x - a.x));
19
+ const dy = Math.round(Math.abs(b.y - a.y));
20
+ if (dx) g = g ? igcd(g, dx) : dx;
21
+ if (dy) g = g ? igcd(g, dy) : dy;
22
+ }
23
+ }
24
+ return g || 1;
25
+ }
26
+ function polysAABB(polys: Poly[]) {
27
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
28
+ for (const poly of polys) for (const p of poly) {
29
+ if (p.x < minX) minX = p.x;
30
+ if (p.y < minY) minY = p.y;
31
+ if (p.x > maxX) maxX = p.x;
32
+ if (p.y > maxY) maxY = p.y;
33
+ }
34
+ const cx = (minX + maxX) / 2, cy = (minY + maxY) / 2;
35
+ return { minX, minY, maxX, maxY, cx, cy };
36
+ }
37
+
38
+ type RadiiParams = {
39
+ n: number; // number of outer sectors
40
+ layoutMode: LayoutMode; // "circle" | "semicircle"
41
+ target: PlacementTarget; // "workspace" | "silhouette"
42
+ qsMaxSlots: number; // number of quickstash slots on inner ring
43
+ primitivesSlots: number; // number of primitive slots on inner ring
44
+ gridPx?: number; // override GRID_PX (rare)
45
+ reqWorkspaceDiamAnchors?: number; // override default capacity (rare)
46
+ reqQuickstashDiamAnchors?: number; // override default capacity (rare)
47
+ reqPrimitiveDiamAnchors?: number; // override defaults (rare)
48
+ layoutPadPx?: number; // override outer pad (rare)
49
+ masks?: Poly[][]; // raw silhouette polys per sector (for workspace fit calc)
50
+ };
51
+
52
+ export type RadiiSolution = {
53
+ innerR_min: number;
54
+ dr_min: number;
55
+ outerR_min: number;
56
+ Tw_min: number; // minimal workspace-band thickness (px) (= required diagonal diameter)
57
+ Tp_min: number; // minimal silhouette-plate thickness (px) (= 2*max silhouette radius or 0)
58
+ Rshape_px_max: number; // max raw-shape radius after canonical unit scaling
59
+ };
60
+
61
+ export function solveRadii(params: RadiiParams): RadiiSolution {
62
+ const {
63
+ n,
64
+ layoutMode,
65
+ target,
66
+ qsMaxSlots,
67
+ primitivesSlots,
68
+ gridPx = CONFIG.layout.grid.stepPx,
69
+ reqWorkspaceDiamAnchors = CONFIG.layout.constraints.workspaceDiamAnchors,
70
+ reqQuickstashDiamAnchors = CONFIG.layout.constraints.quickstashDiamAnchors,
71
+ reqPrimitiveDiamAnchors = CONFIG.layout.constraints.primitiveDiamAnchors,
72
+ } = params;
73
+
74
+ // Canonical lattice scale from raw masks (if provided)
75
+ const allPolys: Poly[] = (params.masks ?? []).flat();
76
+ const rawUnit = allPolys.length ? inferUnitFromPolys(allPolys) : 0;
77
+ const unitScaleS = rawUnit ? (2 * gridPx) / rawUnit : 1;
78
+
79
+ // Max silhouette radius (px) across all sectors, measured from each sector mask AABB center
80
+ let Rshape_px_max = 0;
81
+ if (params.masks && params.masks.length) {
82
+ for (const sectorPolys of params.masks) {
83
+ if (!sectorPolys?.length) continue;
84
+ const a = polysAABB(sectorPolys);
85
+ const { cx, cy } = a;
86
+ for (const poly of sectorPolys) {
87
+ for (const p of poly) {
88
+ const rx = (p.x - cx) * unitScaleS;
89
+ const ry = (p.y - cy) * unitScaleS;
90
+ const r = Math.hypot(rx, ry);
91
+ if (r > Rshape_px_max) Rshape_px_max = r;
92
+ }
93
+ }
94
+ }
95
+ }
96
+
97
+ const sweep = layoutMode === "circle" ? Math.PI * 2 : Math.PI;
98
+ const dOut = sweep / Math.max(1, n);
99
+ const dIn = sweep / Math.max(1, qsMaxSlots);
100
+ const dPrim = sweep / Math.max(1, primitivesSlots);
101
+ const sinOut = Math.sin(dOut / 2);
102
+ const sinIn = Math.sin(dIn / 2);
103
+ const sinPrim = Math.sin(dPrim / 2);
104
+
105
+ // Diagonal-based anchor diameters (so "X anchors" refers to the 45° lattice)
106
+ const D_ws = anchorsDiameterToPx(reqWorkspaceDiamAnchors, gridPx);
107
+ const D_bp = anchorsDiameterToPx(reqQuickstashDiamAnchors, gridPx);
108
+ const D_prim = anchorsDiameterToPx(reqPrimitiveDiamAnchors, gridPx);
109
+
110
+ // Inner-ring minimum from chord ≥ required diameter (blueprints + primitives)
111
+ const innerR_min_bp = D_bp * (0.5 + 1 / (2 * Math.max(1e-9, sinIn)));
112
+ const innerR_min_prim = D_prim * (0.5 + 1 / (2 * Math.max(1e-9, sinPrim)));
113
+ const innerR_min_ws = D_ws / (2 * Math.max(1e-9, sinOut)) - D_ws / 2;
114
+
115
+ const innerR_min = Math.max(innerR_min_bp, innerR_min_prim, innerR_min_ws);
116
+
117
+ // Component minima: workspace band must be at least D_ws thick; silhouette plate
118
+ // must be thick enough to contain the largest mask radius (both sides).
119
+ const Tw_min = D_ws;
120
+ const Tp_min = target === "workspace" ? (2 * Rshape_px_max) : 0;
121
+ const dr_min = Tw_min + Tp_min;
122
+
123
+ const outerR_min = innerR_min + dr_min;
124
+
125
+ return { innerR_min, dr_min, outerR_min, Tw_min, Tp_min, Rshape_px_max };
126
+ }
127
+
128
+ export function optimizeBandSplit(args: {
129
+ outerRViewportBound: number;
130
+ innerR_min: number;
131
+ Tw_min: number;
132
+ Tp_min: number;
133
+ Rshape_px_max: number;
134
+ D_ws_px: number; // anchorsDiameterToPx(REQ_WORKSPACE_DIAM_ANCHORS)
135
+ }): { innerR: number; Tw: number; Tp: number; outerR: number } {
136
+ const { outerRViewportBound, innerR_min, Tw_min, Tp_min, Rshape_px_max, D_ws_px } = args;
137
+
138
+ const dr_min = Tw_min + Tp_min;
139
+
140
+ // If the viewport can’t even accommodate the minima, clamp to the minimal feasible ring.
141
+ if (outerRViewportBound < innerR_min + dr_min) {
142
+ const outerR = innerR_min + dr_min;
143
+ return { innerR: innerR_min, Tw: Tw_min, Tp: Tp_min, outerR };
144
+ }
145
+
146
+ // Maximize ring thickness by minimizing innerR (this maximizes anchor size everywhere).
147
+ const outerR = outerRViewportBound;
148
+ const innerR = innerR_min;
149
+ const dr = outerR - innerR;
150
+
151
+ // Distribute slack between bands with a gentle bias towards the silhouette plate
152
+ // when masks have large radial extent (so they don’t look cramped).
153
+ const slack = Math.max(0, dr - dr_min);
154
+ const wWork = 1;
155
+ const wSil = 1 + (Rshape_px_max / Math.max(1e-9, D_ws_px));
156
+ const addWork = slack * (wWork / (wWork + wSil));
157
+ const Tw = Tw_min + addWork;
158
+ const Tp = dr - Tw;
159
+
160
+ return { innerR, Tw, Tp, outerR };
161
+ }
162
+
163
+ /**
164
+ * Compute the *logical* SVG viewBox (LOGICAL_W/H) from the minimal feasible
165
+ * outer radius implied by the anchor-capacity constraints. The result ensures
166
+ * lattice density is invariant across devices/zoom levels.
167
+ *
168
+ * NOTE: This should be called prior to computeCircleLayout and the returned
169
+ * width/height must be passed as the viewport to computeCircleLayout so both
170
+ * functions agree on the same bound.
171
+ */
172
+ export function solveLogicalBox(params: RadiiParams & { layoutPadPx: number }): { LOGICAL_W: number; LOGICAL_H: number } {
173
+ const { layoutMode, layoutPadPx } = params;
174
+ const { outerR_min } = solveRadii(params);
175
+
176
+ if (layoutMode === "circle") {
177
+ const LOGICAL_W = 2 * (outerR_min + layoutPadPx);
178
+ return { LOGICAL_W, LOGICAL_H: LOGICAL_W };
179
+ } else {
180
+ // semicircle: height is half the width; outerR computed against both axes ends up ~W/2
181
+ const LOGICAL_W = 2 * (outerR_min + layoutPadPx);
182
+ return { LOGICAL_W, LOGICAL_H: LOGICAL_W / 2 };
183
+ }
184
+ }
@@ -0,0 +1,111 @@
1
+ // Domain primitives (no rotation/flip; XY only)
2
+ export type Vec = { x: number; y: number };
3
+ export type Poly = Vec[];
4
+
5
+ // Canonical tans
6
+ export type TanKind =
7
+ | "square"
8
+ | "small_triangle"
9
+ | "parallelogram"
10
+ | "med_triangle"
11
+ | "large_triangle";
12
+
13
+ // 2×2 interaction axes
14
+ export type PlacementTarget = "workspace" | "silhouette";
15
+ export type InputMode = "drag" | "click";
16
+
17
+ // Blueprints (generators)
18
+ export type PrimitiveBlueprint = {
19
+ id: string;
20
+ kind: TanKind;
21
+ shape: Poly[]; // canonical polygon(s) at origin (no rotation)
22
+ colorHint?: string;
23
+ };
24
+
25
+ export type CompositePart = { kind: TanKind; offset: Vec };
26
+
27
+ export type CompositeBlueprint = {
28
+ id: string;
29
+ parts: CompositePart[]; // rigid offsets from origin
30
+ shape: Poly[]; // precomputed union polygons at origin (optional now)
31
+ label?: string;
32
+ };
33
+
34
+ export type Blueprint = PrimitiveBlueprint | CompositeBlueprint;
35
+
36
+ // Live instance
37
+ export type Piece = {
38
+ id: string;
39
+ blueprintId: string;
40
+ pos: Vec; // world position of blueprint origin (top-left of AABB offset separately)
41
+ sectorId?: string; // set when placed
42
+ };
43
+
44
+ export type Anchor = {
45
+ id: string;
46
+ position: Vec;
47
+ accepts: { kind?: TanKind; compositeSignature?: string }[];
48
+ };
49
+
50
+ export type SilhouetteSpec = {
51
+ id: string;
52
+ anchors?: Anchor[];
53
+ mask?: Poly[]; // polygon masks for silhouette matching
54
+ requiredCount?: number; // minimal pieces to consider the sector complete (M4)
55
+ };
56
+
57
+ export type Sector = {
58
+ id: string;
59
+ silhouette: SilhouetteSpec;
60
+ };
61
+
62
+ export type LayoutMode = "circle" | "semicircle";
63
+
64
+ export type RoundConfig = {
65
+ n: number;
66
+ layout: LayoutMode;
67
+ target: PlacementTarget; // one-ring (silhouette) or two-ring (workspace)
68
+ input: InputMode; // drag | click (click comes later)
69
+ maxQuickstashSlots: number; // number of blueprint/quickstash slots on inner ring
70
+ timeLimitMs: number;
71
+ snapRadiusPx?: number;
72
+ maxCompositeSize: number;
73
+ sectors: Sector[]; // length = n
74
+ mode: "construction" | "prep"; // Game mode for behavior differences
75
+ };
76
+
77
+ export type SectorState = {
78
+ sectorId: string;
79
+ pieces: Piece[];
80
+ completedAt?: number; // stamped once and never cleared (locked)
81
+ };
82
+
83
+ export type BlueprintView = "quickstash" | "primitives";
84
+
85
+ export type RoundState = {
86
+ cfg: RoundConfig;
87
+ blueprintView: BlueprintView;
88
+ primitives: PrimitiveBlueprint[];
89
+ quickstash: Blueprint[];
90
+ sectors: Record<string, SectorState>;
91
+ startedAt: number;
92
+ endedAt?: number; // set when all sectors are complete
93
+ };
94
+
95
+ export type PointerSample = { t: number; x: number; y: number };
96
+
97
+ export type ActionEvent =
98
+ | { t:number; type:"spawn"; blueprintId:string; from:"quickstash"|"primitives"; pieceId:string }
99
+ | { t:number; type:"drag"; pieceId:string; to?:{ x:number; y:number }; pointer?:PointerSample[] }
100
+ | { t:number; type:"drop"; pieceId:string; sectorId?:string; anchorId?:string; accepted:boolean }
101
+ | { t:number; type:"remove"; pieceId:string }
102
+ | { t:number; type:"sectorComplete"; sectorId:string }
103
+ | { t:number; type:"blueprintView"; to:BlueprintView }
104
+ | { t:number; type:"timeout" | "autoAdvance" };
105
+
106
+ export type RoundSnapshot = {
107
+ perSector: Array<{ sectorId: string; completedAt?: number; pieceCount: number }>;
108
+ events: ActionEvent[];
109
+ timeMs: number;
110
+ completed: boolean;
111
+ };