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,271 @@
1
+ /**
2
+ * Data tracking types for experiment data collection
3
+ *
4
+ * This module defines the schema for capturing user interactions and trial outcomes.
5
+ * Two main callback points:
6
+ * 1. onInteraction - fires after each place-down (complete interaction)
7
+ * 2. onTrialEnd - fires once when trial completes
8
+ */
9
+
10
+ // ===== Interaction Event =====
11
+ // Fired when a piece is placed down (spawn, move, or delete)
12
+
13
+ /**
14
+ * Interaction event
15
+ */
16
+ export interface InteractionEvent {
17
+ // === Event Metadata ===
18
+ interactionId: string; // UUID for this interaction
19
+ trialId: string; // UUID for the trial
20
+ gameId: string; // UUID for the game session
21
+ interactionIndex: number; // Sequential counter (0, 1, 2, ...) within the trial
22
+
23
+ // === Interaction Type ===
24
+ interactionType: "spawn" | "move" | "delete";
25
+
26
+ // === Piece Information ===
27
+ pieceId: string;
28
+ blueprintId: string;
29
+ blueprintType: "primitive" | "composite";
30
+
31
+ // === Pickup Event ===
32
+ pickupTimestamp: number; // timestamp in ms (Date.now()) when piece was picked up
33
+ pickupSource: "blueprint" | "sector"; // Where piece came from
34
+ pickupSectorId: string | undefined; // Defined if pickupSource is "sector"
35
+ pickupPosition: { x: number; y: number };
36
+ pickupVertices: number[][][]; // World-space polygons at pickup
37
+
38
+ // === Placedown Event ===
39
+ placedownTimestamp: number; // timestamp in ms (Date.now()) when piece was placed down
40
+ placedownOutcome: "placed" | "deleted";
41
+ // If placed: position/vertices/sector required; if deleted: all undefined
42
+ placedownSectorId?: string; // Target sector (required if placed, undefined if deleted)
43
+ placedownPosition?: { x: number; y: number }; // Required if placed, undefined if deleted
44
+ placedownVertices?: number[][][]; // Required if placed, undefined if deleted
45
+ placedownAnchorId?: string; // Which anchor was snapped to (only if placed)
46
+
47
+ // === Validation Context ===
48
+ // Only relevant if placed (always false if deleted)
49
+ wasValid: boolean; // Was placement valid at drop time
50
+ wasOverlapping: boolean; // Was overlapping with other pieces
51
+
52
+ // === Timing ===
53
+ holdDuration: number; // ms between pickup and placedown
54
+
55
+ // === Click Events ===
56
+ // All click-related events that occurred before/during this interaction
57
+ // Includes blueprint switches, invalid placement attempts, etc.
58
+ clickEvents: Array<{
59
+ timestamp: number; // timestamp in ms (Date.now())
60
+ location: { x: number; y: number }; // Anchor grid coordinates for the click
61
+ clickType: "blueprint_view_switch" | "invalid_placement" | "sector_complete_attempt";
62
+ // Type-specific data
63
+ blueprintViewSwitch?: {
64
+ from: "quickstash" | "primitives";
65
+ to: "quickstash" | "primitives";
66
+ };
67
+ invalidPlacement?: {
68
+ reason: "overlapping" | "outside_bounds" | "no_valid_anchor" | "sector_complete";
69
+ attemptedSectorId?: string;
70
+ };
71
+ }>;
72
+
73
+ // === Mouse Tracking (Discrete Anchor Coordinates) ===
74
+ // Only logged when mouse snaps to a different anchor point
75
+ // Covers entire interaction: before pickup + while holding piece
76
+ mouseTracking: Array<{
77
+ timestamp: number; // timestamp in ms (Date.now())
78
+ anchorX: number; // Anchor grid X coordinate
79
+ anchorY: number; // Anchor grid Y coordinate
80
+ sectorId: string | undefined; // Which sector mouse is in (undefined if in inner ring)
81
+ phase: "before_pickup" | "while_holding"; // Before or during interaction
82
+ }>;
83
+
84
+ // === State Snapshots (for replay) ===
85
+ preSnapshotId?: string; // UUID of previous interaction (null if first interaction)
86
+ postSnapshot: StateSnapshot; // State after this interaction
87
+ }
88
+
89
+ // ===== State Snapshot =====
90
+
91
+ /**
92
+ * Complete state snapshot at a point in time
93
+ * Used for pre/post interaction snapshots and final state
94
+ */
95
+ export interface StateSnapshot {
96
+ snapshotId: string; // UUID for this snapshot
97
+ timestamp: number; // timestamp in ms (Date.now())
98
+ sectors: SectorSnapshot[];
99
+ completedSectorIds: string[];
100
+
101
+ // Maps sector IDs to tangram targets (static for trial, but included for completeness)
102
+ sectorTangramMap: Array<{
103
+ sectorId: string;
104
+ tangramId: string; // Which tangram target this sector represents
105
+ }>;
106
+
107
+ // Blueprint ring order (primitives and quickstash)
108
+ // Captures the current state of available blueprints in the center ring
109
+ blueprintOrder: {
110
+ primitives: string[]; // Ordered list of primitive blueprint IDs
111
+ quickstash: string[]; // Ordered list of quickstash blueprint IDs
112
+ };
113
+ }
114
+
115
+ // ===== Trial-End Data =====
116
+
117
+ /**
118
+ * Base trial data shared between construction and prep trials
119
+ */
120
+ export interface BaseTrialData {
121
+ // Trial identifiers
122
+ trialId: string; // UUID for this trial
123
+ gameId: string; // UUID for game session
124
+ trialNum: number; // Trial number in experiment sequence
125
+
126
+ // Timing
127
+ trialStartTime: number; // Absolute timestamp (Date.now())
128
+ trialEndTime: number; // Absolute timestamp (Date.now())
129
+ totalDuration: number; // duration in ms (trialEndTime - trialStartTime)
130
+
131
+ // Final state snapshot
132
+ finalSnapshot: StateSnapshot;
133
+ }
134
+
135
+ /**
136
+ * Construction trial end data
137
+ */
138
+ export interface ConstructionTrialData extends BaseTrialData {
139
+ trialType: "construction";
140
+
141
+ // End reason (construction-specific)
142
+ endReason: "timeout" | "auto_complete";
143
+
144
+ // Construction-specific data
145
+ completionTimes: Array<{
146
+ sectorId: string;
147
+ completedAt: number; // timestamp in ms (Date.now())
148
+ }>;
149
+
150
+ // Final blueprint state (usage counts + definitions)
151
+ finalBlueprintState: Array<{
152
+ blueprintId: string;
153
+ blueprintType: "primitive" | "composite";
154
+ totalPieces: number; // Total across all sectors
155
+ bySector: Array<{
156
+ sectorId: string;
157
+ count: number;
158
+ }>;
159
+ // Blueprint definition (for composites)
160
+ parts?: Array<{
161
+ kind: string;
162
+ anchorOffset: { x: number; y: number };
163
+ }>;
164
+ shape?: Array<Array<{ x: number; y: number }>>; // Shape in anchor coordinates
165
+ label?: string;
166
+ }>;
167
+
168
+ // Quickstash macros ready for next trial (composites only, in plugin input format)
169
+ quickstashMacros: Array<{
170
+ id: string;
171
+ parts: Array<{
172
+ kind: string;
173
+ anchorOffset: { x: number; y: number };
174
+ }>;
175
+ label?: string;
176
+ }>;
177
+ }
178
+
179
+ /**
180
+ * Prep trial end data
181
+ */
182
+ export interface PrepTrialData extends BaseTrialData {
183
+ trialType: "prep";
184
+
185
+ // End reason (prep-specific)
186
+ endReason: "submit";
187
+
188
+ // Prep-specific data: created macros
189
+ createdMacros: MacroSnapshot[];
190
+
191
+ // Quickstash macros ready for next trial (in plugin input format)
192
+ quickstashMacros: Array<{
193
+ id: string;
194
+ parts: Array<{
195
+ kind: string;
196
+ anchorOffset: { x: number; y: number };
197
+ }>;
198
+ label?: string;
199
+ }>;
200
+ }
201
+
202
+ /**
203
+ * Union type for any trial data
204
+ */
205
+ export type TrialEndData = ConstructionTrialData | PrepTrialData;
206
+
207
+ /**
208
+ * Snapshot of a sector's final state
209
+ */
210
+ export interface SectorSnapshot {
211
+ sectorId: string;
212
+ completed: boolean;
213
+ completedAt?: number; // timestamp in ms (Date.now()) (undefined if not completed)
214
+ pieceCount: number;
215
+ pieces: PieceSnapshot[];
216
+ }
217
+
218
+ /**
219
+ * Snapshot of a single piece's final state
220
+ */
221
+ export interface PieceSnapshot {
222
+ pieceId: string;
223
+ blueprintId: string;
224
+ blueprintType: "primitive" | "composite";
225
+ position: { x: number; y: number };
226
+ vertices: number[][][];
227
+ }
228
+
229
+ /**
230
+ * Snapshot of a created macro (prep mode only)
231
+ */
232
+ export interface MacroSnapshot {
233
+ macroId: string; // sector ID (prep mode uses sectors as macro slots)
234
+ parts: Array<{
235
+ kind: string; // TanKind
236
+ anchorOffset: { x: number; y: number }; // Relative offset in anchor coordinates (centroid-based)
237
+ }>;
238
+ shape: number[][][]; // Union shape polygons
239
+ pieceCount: number;
240
+ }
241
+
242
+ // ===== Callback Interface =====
243
+
244
+ /**
245
+ * Callbacks for data tracking
246
+ * Provide these to the InteractionTracker to receive data events
247
+ */
248
+ export interface DataTrackingCallbacks {
249
+ /**
250
+ * Called immediately after each complete interaction (when piece is placed down)
251
+ * Use this for incremental data saving (e.g., socket.emit())
252
+ */
253
+ onInteraction?: (event: InteractionEvent) => void;
254
+
255
+ /**
256
+ * Called once when trial ends
257
+ * Contains complete interaction history + final state
258
+ * Type discriminated by trialType: "construction" | "prep"
259
+ */
260
+ onTrialEnd?: (data: TrialEndData) => void;
261
+ }
262
+
263
+ // ===== Helper Type Guards =====
264
+
265
+ export function isConstructionTrial(data: TrialEndData): data is ConstructionTrialData {
266
+ return data.trialType === "construction";
267
+ }
268
+
269
+ export function isPrepTrial(data: TrialEndData): data is PrepTrialData {
270
+ return data.trialType === "prep";
271
+ }
@@ -0,0 +1,110 @@
1
+ // Convert raw stims JSON to clean TangramSpec[] for plugin interface
2
+ import type { TangramSpec } from "@/core/types/plugin-interfaces";
3
+
4
+ /** Raw JSON shapes from dev/assets/stims_dev.json */
5
+ type RawTan = {
6
+ name: string;
7
+ verticesAtOrigin: Array<[number, number] | { x: number; y: number }>;
8
+ };
9
+
10
+ type RawStim = {
11
+ solutionTans: RawTan[];
12
+ stimImgPath?: string;
13
+ stimSilhouetteImgPath?: string;
14
+ set?: string;
15
+ id?: string | number;
16
+ };
17
+
18
+ /** Canonical tangram names we accept as actual piece polys. */
19
+ const CANON = new Set([
20
+ "square",
21
+ "small_triangle",
22
+ "parallelogram",
23
+ "med_triangle",
24
+ "large_triangle",
25
+ ]);
26
+
27
+ // ----------------------- guards & converters -----------------------
28
+ function isPoint(a: unknown): a is [number, number] {
29
+ return Array.isArray(a) && a.length >= 2
30
+ && typeof a[0] === "number" && typeof a[1] === "number";
31
+ }
32
+
33
+ function isPointObj(a: unknown): a is { x: number; y: number } {
34
+ return !!a && typeof a === "object"
35
+ && "x" in (a as any) && "y" in (a as any)
36
+ && typeof (a as any).x === "number" && typeof (a as any).y === "number";
37
+ }
38
+
39
+ function toNumberPair(p: [number, number] | { x: number; y: number }): [number, number] {
40
+ // Keep in math coords (+y up) - conversion to SVG coords happens in plugin wrapper
41
+ if (isPoint(p)) return [p[0], p[1]];
42
+ const obj = p as any;
43
+ return [obj.x, obj.y];
44
+ }
45
+
46
+ function polygonToNumbers(vertices: Array<[number, number] | { x: number; y: number }>): number[][] {
47
+ const out: number[][] = [];
48
+ for (const v of vertices) {
49
+ if (isPoint(v) || isPointObj(v)) {
50
+ out.push(toNumberPair(v));
51
+ }
52
+ }
53
+ return out;
54
+ }
55
+
56
+ // ----------------------- main converter -----------------------
57
+ /**
58
+ * Convert raw stims JSON to TangramSpec[] for clean plugin interface.
59
+ * This replaces the old normalizeStims() function with proper separation of concerns.
60
+ */
61
+ export function rawStimsToTangramSpecs(
62
+ src: unknown,
63
+ sectorIds: string[],
64
+ defaultRequired = 2
65
+ ): TangramSpec[] {
66
+ const rawList: RawStim[] = Array.isArray(src)
67
+ ? (src as RawStim[])
68
+ : (src && typeof src === "object" && Array.isArray((src as any).stims))
69
+ ? ((src as any).stims as RawStim[])
70
+ : [];
71
+
72
+ const tangrams: TangramSpec[] = [];
73
+
74
+ for (let i = 0; i < Math.min(sectorIds.length, rawList.length); i++) {
75
+ const raw = rawList[i];
76
+ if (!raw) continue;
77
+
78
+ // Extract only canonical piece shapes; ignore macros
79
+ const mask: number[][][] = [];
80
+ for (const tan of raw.solutionTans ?? []) {
81
+ if (!tan || !CANON.has(tan.name)) continue;
82
+ if (Array.isArray(tan.verticesAtOrigin) && tan.verticesAtOrigin.length >= 3) {
83
+ mask.push(polygonToNumbers(tan.verticesAtOrigin));
84
+ }
85
+ }
86
+
87
+ const id = sectorIds[i] ?? String(i);
88
+ tangrams.push({
89
+ id,
90
+ silhouette: {
91
+ mask,
92
+ requiredCount: defaultRequired,
93
+ },
94
+ });
95
+ }
96
+
97
+ return tangrams;
98
+ }
99
+
100
+ /** Fetch + convert helper for dev path (e.g., "/dev/assets/stims_dev.json"). */
101
+ export async function loadTangramSpecsFromUrl(
102
+ url: string,
103
+ sectorIds: string[],
104
+ defaultRequired = 2
105
+ ): Promise<TangramSpec[]> {
106
+ const res = await fetch(url);
107
+ if (!res.ok) throw new Error(`Failed to load stims: ${res.status} ${res.statusText}`);
108
+ const json = await res.json();
109
+ return rawStimsToTangramSpecs(json, sectorIds, defaultRequired);
110
+ }
@@ -0,0 +1,141 @@
1
+ // src/core/io/quickstash.ts
2
+ import { CONFIG } from "@/core/config/config";
3
+ import type { Blueprint, CompositeBlueprint, PrimitiveBlueprint, TanKind, Vec } from "@/core/domain/types";
4
+ import { primitiveBlueprintsHalfEdge } from "@/core/domain/primitives";
5
+
6
+ /**
7
+ * Convert anchor coordinates to pixel coordinates
8
+ * @param anchorX - X coordinate in anchor units (integer)
9
+ * @param anchorY - Y coordinate in anchor units (integer)
10
+ * @returns Pixel coordinates
11
+ */
12
+ export function anchorToPixels(anchorX: number, anchorY: number): Vec {
13
+ return {
14
+ x: anchorX * CONFIG.layout.grid.stepPx,
15
+ y: anchorY * CONFIG.layout.grid.stepPx
16
+ };
17
+ }
18
+
19
+ /**
20
+ * Convert pixel coordinates to anchor coordinates
21
+ * @param pixelX - X coordinate in pixels
22
+ * @param pixelY - Y coordinate in pixels
23
+ * @returns Anchor coordinates (rounded to nearest grid point)
24
+ */
25
+ export function pixelsToAnchor(pixelX: number, pixelY: number): Vec {
26
+ return {
27
+ x: Math.round(pixelX / CONFIG.layout.grid.stepPx),
28
+ y: Math.round(pixelY / CONFIG.layout.grid.stepPx)
29
+ };
30
+ }
31
+
32
+ /**
33
+ * Convert anchor-based composite definition to pixel-based composite with custom grid step
34
+ * @param anchorComposite - Composite defined with anchor coordinates
35
+ * @param primsByKind - Map of primitive blueprints by kind
36
+ * @param gridStepPx - Grid step in pixels (defaults to current CONFIG)
37
+ * @returns CompositeBlueprint with pixel coordinates
38
+ */
39
+ export function convertAnchorCompositeToPixels(
40
+ anchorComposite: { id: string; parts: Array<{ kind: TanKind; anchorOffset: { x: number; y: number } }>; label?: string },
41
+ primsByKind: Map<TanKind, PrimitiveBlueprint>,
42
+ gridStepPx: number = CONFIG.layout.grid.stepPx
43
+ ): CompositeBlueprint {
44
+ const { id, parts, label } = anchorComposite;
45
+
46
+ // Convert anchor offsets to pixel offsets using custom grid step
47
+ const pixelParts: Array<{ kind: TanKind; offset: Vec }> = parts.map(p => ({
48
+ kind: p.kind,
49
+ offset: {
50
+ x: p.anchorOffset.x * gridStepPx,
51
+ y: p.anchorOffset.y * gridStepPx
52
+ }
53
+ }));
54
+
55
+ // Compute shape using custom grid step
56
+ const shape: Vec[][] = [];
57
+ for (const p of parts) {
58
+ const prim = primsByKind.get(p.kind);
59
+ if (!prim) continue;
60
+
61
+ const pixelOffset = {
62
+ x: p.anchorOffset.x * gridStepPx,
63
+ y: p.anchorOffset.y * gridStepPx
64
+ };
65
+ for (const poly of prim.shape) {
66
+ shape.push(poly.map(v => ({ x: v.x + pixelOffset.x, y: v.y + pixelOffset.y })));
67
+ }
68
+ }
69
+
70
+ return { id, parts: pixelParts, shape, label: label ?? `Composite-${id}` };
71
+ }
72
+
73
+ /**
74
+ * Anchor-based composite definitions (configuration-independent)
75
+ * These are converted to pixel coordinates at render time
76
+ */
77
+ const ANCHOR_COMPOSITES = [
78
+ {
79
+ id: "comp:parallelogram+parallelogram",
80
+ parts: [
81
+ { kind: "parallelogram" as TanKind, anchorOffset: { x: 0, y: 0 } },
82
+ { kind: "parallelogram" as TanKind, anchorOffset: { x: 2, y: 2 } }, // Diagonal contact
83
+ ],
84
+ label: "Parallelogram+Parallelogram"
85
+ },
86
+ {
87
+ id: "comp:small_triangle+med_triangle",
88
+ parts: [
89
+ { kind: "small_triangle" as TanKind, anchorOffset: { x: -2, y: -2 } },
90
+ { kind: "med_triangle" as TanKind, anchorOffset: { x: 0, y: 0 } },
91
+ ],
92
+ label: "SmallTriangle+MedTriangle"
93
+ },
94
+ {
95
+ id: "comp:square",
96
+ parts: [
97
+ { kind: "square" as TanKind, anchorOffset: { x: 0, y: 0 } },
98
+ ],
99
+ label: "Square"
100
+ }
101
+ ];
102
+
103
+ /**
104
+ * Create quickstash with custom grid step (useful for testing different configurations)
105
+ * @param gridStepPx - Grid step in pixels
106
+ * @returns Array of blueprints with pixel coordinates based on custom grid step
107
+ */
108
+ export function createQuickstashWithGridStep(gridStepPx: number): Blueprint[] {
109
+ const prims = primitiveBlueprintsHalfEdge();
110
+ const byKind = new Map<TanKind, PrimitiveBlueprint>();
111
+ prims.forEach(p => byKind.set(p.kind, p));
112
+
113
+ // Convert anchor-based definitions to pixel-based composites with custom grid step
114
+ const list: Blueprint[] = ANCHOR_COMPOSITES.map(anchorComposite =>
115
+ convertAnchorCompositeToPixels(anchorComposite, byKind, gridStepPx)
116
+ );
117
+
118
+ const maxSlots = CONFIG.layout.defaults.maxQuickstashSlots;
119
+ if (list.length > maxSlots) {
120
+ throw new Error(
121
+ `Quickstash has ${list.length} items but max is ${maxSlots}. Trim defaultQuickstash() or raise CONFIG.layout.defaults.maxQuickstashSlots.`
122
+ );
123
+ }
124
+ return list;
125
+ }
126
+
127
+ /**
128
+ * Get anchor-based composite definitions (for debugging/testing)
129
+ * @returns Array of anchor-based composite definitions
130
+ */
131
+ export function getAnchorComposites() {
132
+ return ANCHOR_COMPOSITES;
133
+ }
134
+
135
+ /**
136
+ * Default Quickstash using anchor coordinates
137
+ * Conversion to pixels happens at render time using current CONFIG
138
+ */
139
+ export function defaultQuickstash(): Blueprint[] {
140
+ return createQuickstashWithGridStep(CONFIG.layout.grid.stepPx);
141
+ }
@@ -0,0 +1,110 @@
1
+ // core/io/stims.ts
2
+ import type { Poly, Sector, Vec } from "@/core/domain/types";
3
+
4
+ /** Raw JSON shapes from dev/assets/stims_dev.json */
5
+ type RawTan = {
6
+ name: string;
7
+ verticesAtOrigin: Array<[number, number] | { x: number; y: number }>;
8
+ };
9
+
10
+ type RawStim = {
11
+ solutionTans: RawTan[];
12
+ stimImgPath?: string;
13
+ stimSilhouetteImgPath?: string;
14
+ set?: string;
15
+ id?: string | number;
16
+ };
17
+
18
+ /** Canonical tangram names we accept as actual piece polys. */
19
+ const CANON = new Set([
20
+ "square",
21
+ "small_triangle",
22
+ "parallelogram",
23
+ "med_triangle",
24
+ "large_triangle",
25
+ ]);
26
+
27
+ // ----------------------- guards & converters -----------------------
28
+ function isPoint(a: unknown): a is [number, number] {
29
+ return Array.isArray(a) && a.length >= 2
30
+ && typeof a[0] === "number" && typeof a[1] === "number";
31
+ }
32
+ function isPointObj(a: unknown): a is { x: number; y: number } {
33
+ return !!a && typeof a === "object"
34
+ && "x" in (a as any) && "y" in (a as any)
35
+ && typeof (a as any).x === "number" && typeof (a as any).y === "number";
36
+ }
37
+
38
+ function toVec(p: [number, number] | { x: number; y: number }): Vec {
39
+ // JSON vertices are authored in math coords (+y up). Convert to SVG (+y down).
40
+ if (isPoint(p)) return { x: p[0], y: -p[1] };
41
+ const obj = p as any;
42
+ return { x: obj.x, y: -obj.y };
43
+ }
44
+
45
+ // NOTE: We flip Y above so silhouettes render right-side-up in SVG.
46
+ function polyFromVertices(vertices: Array<[number, number] | { x: number; y: number }>): Poly {
47
+ // defensively map every element that looks like a point
48
+ const out: Vec[] = [];
49
+ for (const v of vertices) {
50
+ if (isPoint(v) || isPointObj(v)) out.push(toVec(v));
51
+ }
52
+ return out;
53
+ }
54
+
55
+ // ----------------------- normalization -----------------------
56
+ /**
57
+ * Accepts either an array of raw stims or an object with { stims: [...] }.
58
+ * Returns Sectors with silhouette.mask = array of polys (one per tan).
59
+ */
60
+ export function normalizeStims(
61
+ src: unknown,
62
+ fallbackSectorIds: string[],
63
+ defaultRequired = 2
64
+ ): Sector[] {
65
+ const rawList: RawStim[] = Array.isArray(src)
66
+ ? (src as RawStim[])
67
+ : (src && typeof src === "object" && Array.isArray((src as any).stims))
68
+ ? ((src as any).stims as RawStim[])
69
+ : [];
70
+
71
+ const sectors: Sector[] = [];
72
+
73
+ for (let i = 0; i < Math.min(fallbackSectorIds.length, rawList.length); i++) {
74
+ const raw = rawList[i];
75
+ if (!raw) continue;
76
+
77
+ // take only canonical piece shapes; ignore macros
78
+ const polys: Poly[] = [];
79
+ for (const tan of raw.solutionTans ?? []) {
80
+ if (!tan || !CANON.has(tan.name)) continue;
81
+ if (Array.isArray(tan.verticesAtOrigin) && tan.verticesAtOrigin.length >= 3) {
82
+ polys.push(polyFromVertices(tan.verticesAtOrigin));
83
+ }
84
+ }
85
+
86
+ const id = fallbackSectorIds[i] ?? String(i);
87
+ sectors.push({
88
+ id,
89
+ silhouette: {
90
+ id,
91
+ mask: polys,
92
+ requiredCount: defaultRequired,
93
+ },
94
+ });
95
+ }
96
+
97
+ return sectors;
98
+ }
99
+
100
+ /** Fetch + normalize helper for dev path (e.g., "/dev/assets/stims_dev.json"). */
101
+ export async function loadStimSectorsFromUrl(
102
+ url: string,
103
+ sectorIds: string[],
104
+ defaultRequired = 2
105
+ ): Promise<Sector[]> {
106
+ const res = await fetch(url);
107
+ if (!res.ok) throw new Error(`Failed to load stims: ${res.status} ${res.statusText}`);
108
+ const json = await res.json();
109
+ return normalizeStims(json, sectorIds, defaultRequired);
110
+ }
@@ -0,0 +1,5 @@
1
+ // Central type exports for the refactored architecture
2
+ export * from './plugin-interfaces';
3
+
4
+ // Re-export commonly used type combinations
5
+ export type { ConstructionTrialParams, PrepTrialParams } from './plugin-interfaces';