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,70 @@
1
+ /**
2
+ * Board Component Hooks - Individual exports for advanced plugin development
3
+ *
4
+ * This module exports the individual React hooks that power the GameBoard component.
5
+ * These hooks can be used directly by plugin developers who need more granular
6
+ * control over board behavior than what GameBoard provides.
7
+ *
8
+ * ## Hook Architecture
9
+ *
10
+ * The board functionality is decomposed into focused hooks that each handle
11
+ * a specific aspect of the game mechanics:
12
+ *
13
+ * - **usePieceState**: Manages piece data and polygon computation
14
+ * - **useAnchorGrid**: Computes snap points and validation grids
15
+ * - **useDragController**: Handles drag interactions and collision detection
16
+ * - **useClickController**: Manages click-based interactions
17
+ *
18
+ * ## Usage Patterns
19
+ *
20
+ * ### Option 1: Use GameBoard (Recommended)
21
+ * Most plugins should use the GameBoard component which internally uses all hooks:
22
+ * ```typescript
23
+ * import { GameBoard } from "@/core/components";
24
+ * <GameBoard sectors={sectors} quickstash={macros} ... />
25
+ * ```
26
+ *
27
+ * ### Option 2: Use Individual Hooks (Advanced)
28
+ * For custom board implementations, use hooks directly:
29
+ * ```typescript
30
+ * import { usePieceState, useDragController } from "@/core/components/board";
31
+ *
32
+ * function CustomBoard({ controller, layout }) {
33
+ * const pieceState = usePieceState(controller);
34
+ * const dragController = useDragController(
35
+ * controller, layout, pieceState.pieces, // additional params
36
+ * );
37
+ *
38
+ * return <div>Custom UI using hook state</div>;
39
+ * }
40
+ * ```
41
+ *
42
+ * ## Hook Dependencies
43
+ *
44
+ * The hooks have dependencies on each other:
45
+ * - useDragController depends on usePieceState output
46
+ * - useClickController integrates with useDragController
47
+ * - useAnchorGrid is independent but used by drag/click controllers
48
+ *
49
+ * @see {@link GameBoard} For the complete integrated component
50
+ * @see {@link ../README.md} For comprehensive usage documentation
51
+ */
52
+
53
+ // Piece state management
54
+ export { usePieceState } from './usePieceState';
55
+ export type { PieceData, PieceStateHook } from './usePieceState';
56
+
57
+ // Anchor grid computation
58
+ export { useAnchorGrid } from './useAnchorGrid';
59
+ export type { AnchorDots } from './BoardView';
60
+
61
+ // Drag interaction handling
62
+ export { useDragController } from './useDragController';
63
+ export type { DragControllerHook } from './useDragController';
64
+
65
+ // Click interaction handling
66
+ export { useClickController } from './useClickController';
67
+ export type { ClickControllerHook } from './useClickController';
68
+
69
+ // Utility functions
70
+ export { blueprintLocalFromWorld, scalePolys } from './utils';
@@ -0,0 +1,110 @@
1
+ import React from "react";
2
+ import { BaseGameController } from "@/core/engine/state/BaseGameController";
3
+ import type { CircleLayout } from "@/core/domain/layout";
4
+ import { rectForBand } from "@/core/domain/layout";
5
+ import { placeSilhouetteGridAlignedAsPolys } from "@/core/engine/geometry";
6
+ import {
7
+ workspaceNodes,
8
+ silhouetteNodes,
9
+ silhouetteBandNodes,
10
+ innerRingNodes
11
+ } from "@/core/engine/collision/grid-snapping";
12
+
13
+ /**
14
+ * Anchor dots data for a single sector
15
+ *
16
+ * Represents the valid and invalid snap points for piece placement within
17
+ * a sector. Valid dots allow piece placement, invalid dots show grid structure
18
+ * but don't allow snapping.
19
+ */
20
+ export interface AnchorDots {
21
+ /** Sector ID these anchor dots belong to */
22
+ sectorId: string;
23
+
24
+ /** Array of valid snap points where pieces can be placed */
25
+ valid: { x: number; y: number }[];
26
+
27
+ /** Array of invalid snap points (visual grid, no placement allowed) */
28
+ invalid: { x: number; y: number }[];
29
+ }
30
+
31
+ /**
32
+ * Return interface for useAnchorGrid hook
33
+ *
34
+ * Provides computed anchor grid data for all active sectors in the layout.
35
+ * Used by rendering components to draw snap points and by interaction hooks
36
+ * for piece placement validation.
37
+ */
38
+ export interface AnchorGridHook {
39
+ /** Array of anchor dot data for all sectors (excludes completed sectors) */
40
+ anchorDots: AnchorDots[];
41
+ }
42
+
43
+ /**
44
+ * Hook for computing anchor grid data for all sectors
45
+ *
46
+ * This hook computes the snap points (anchor dots) for piece placement validation
47
+ * across all active sectors. It handles both workspace and silhouette target modes,
48
+ * computing appropriate valid and invalid snap points for each.
49
+ *
50
+ * ## Key Features
51
+ * - **Workspace mode**: All grid nodes within sector bounds are valid
52
+ * - **Silhouette mode**: Only nodes within silhouette mask are valid
53
+ * - **Completion hiding**: Completed sectors don't show anchor dots
54
+ * - **Scale-aware**: Handles silhouette scaling for proper grid alignment
55
+ *
56
+ * ## Target Mode Behavior
57
+ * - **Workspace**: `valid` contains all workspace nodes, `invalid` is empty
58
+ * - **Silhouette**: `valid` contains nodes inside silhouette, `invalid` contains band nodes outside
59
+ *
60
+ * @param controller - BaseGameController instance for accessing game state
61
+ * @param layout - Computed CircleLayout containing sector geometry
62
+ * @param scaleS - Scaling factor for silhouette placement (from solveLogicalBox)
63
+ * @returns Hook interface with anchor dots for all active sectors
64
+ *
65
+ * @example
66
+ * ```typescript
67
+ * const { anchorDots } = useAnchorGrid(controller, layout, scaleS);
68
+ * const sectorAnchors = anchorDots.find(a => a.sectorId === "sector_1");
69
+ * const snapPoints = sectorAnchors?.valid ?? [];
70
+ * ```
71
+ */
72
+ export function useAnchorGrid(
73
+ controller: BaseGameController,
74
+ layout: CircleLayout,
75
+ scaleS: number
76
+ ): AnchorGridHook {
77
+ const cfg = controller.state.cfg;
78
+
79
+ const anchorDots: AnchorDots[] = React.useMemo(() => {
80
+ const out: AnchorDots[] = [];
81
+
82
+ // Add inner ring anchors (always valid, shown throughout)
83
+ out.push({ sectorId: "inner-ring", valid: innerRingNodes(layout), invalid: [] });
84
+
85
+ for (const s of layout.sectors) {
86
+ // Show anchors for ALL sectors, including completed ones
87
+ if (cfg.target === "workspace") {
88
+ out.push({ sectorId: s.id, valid: workspaceNodes(layout, s), invalid: [] });
89
+ } else {
90
+ const mask = controller.state.cfg.sectors.find(ss => ss.id === s.id)?.silhouette.mask ?? [];
91
+ if (!mask || mask.length === 0) continue;
92
+ const rect = rectForBand(layout, s, "silhouette", 1.0); // center only; size was handled in global scale
93
+ const placedPolys = placeSilhouetteGridAlignedAsPolys(mask, scaleS, { cx: rect.cx, cy: rect.cy });
94
+ const bandAll = silhouetteBandNodes(layout, s);
95
+ const valid = silhouetteNodes(layout, s, placedPolys);
96
+ // invalid = bandAll \ valid (by coordinate)
97
+ const key = (p: { x: number; y: number }) => `${p.x},${p.y}`;
98
+ const validSet = new Set(valid.map(key));
99
+ const invalid = bandAll.filter(p => !validSet.has(key(p)));
100
+ out.push({ sectorId: s.id, valid, invalid });
101
+ }
102
+ }
103
+ return out;
104
+ // eslint-disable-next-line react-hooks/exhaustive-deps
105
+ }, [cfg.target, controller.state.sectors, layout, scaleS]);
106
+
107
+ return {
108
+ anchorDots
109
+ };
110
+ }
@@ -0,0 +1,436 @@
1
+ import React from "react";
2
+ import { BaseGameController } from "@/core/engine/state/BaseGameController";
3
+ import type { CircleLayout } from "@/core/domain/layout";
4
+ import { sectorAtPoint } from "@/core/domain/layout";
5
+ import { boundsOfBlueprint, computeSupportOffsets, clampTopLeftBySupport, piecePolysAt } from "@/core/engine/geometry";
6
+ import type { PieceData } from "./usePieceState";
7
+
8
+ // Click controller state is managed internally with React hooks
9
+
10
+ /**
11
+ * Return interface for useClickController hook
12
+ *
13
+ * Provides click-based interaction functionality that works alongside
14
+ * useDragController to support click-and-place interaction patterns.
15
+ */
16
+ export interface ClickControllerHook {
17
+ // State
18
+ /** ID of pending blueprint for placement (from blueprint ring clicks) */
19
+ pendingBpId: string | null;
20
+
21
+ /** ID of currently selected piece (for click-to-move functionality) */
22
+ selectedPieceId: string | null;
23
+
24
+ // Handlers
25
+ /**
26
+ * Handler for root SVG clicks (placement, movement, deletion)
27
+ * @param e - Pointer event from SVG root element
28
+ */
29
+ onRootPointerDown: (e: React.PointerEvent<SVGSVGElement>) => void;
30
+
31
+ // Helper methods for drag controller integration
32
+ /** Clear all selection state */
33
+ clearSelection: () => void;
34
+
35
+ /**
36
+ * Set pending blueprint ID for placement
37
+ * @param id - Blueprint ID or null to clear
38
+ */
39
+ setPendingBp: (id: string | null) => void;
40
+
41
+ /**
42
+ * Set selected piece ID for movement
43
+ * @param id - Piece ID or null to clear
44
+ */
45
+ setSelectedPiece: (id: string | null) => void;
46
+ }
47
+
48
+ /**
49
+ * Hook for managing click-based interactions in click input mode
50
+ *
51
+ * This hook works in conjunction with useDragController to provide click-and-place
52
+ * interaction patterns. It manages the selection state for both blueprint icons and
53
+ * live pieces, handling placement, movement, and deletion through click interactions.
54
+ *
55
+ * ## Click Interaction Patterns
56
+ * - **Blueprint click**: Selects blueprint for placement (pendingBpId)
57
+ * - **Piece click**: Starts "carry" state via drag controller integration
58
+ * - **Empty click**: Places pending blueprint or moves selected piece
59
+ * - **Center badge click**: Deletes carried/selected piece
60
+ * - **Completed sector click**: Deletes pieces (invalid placement)
61
+ *
62
+ * ## State Management
63
+ * - **pendingBpId**: Blueprint selected from ring, awaiting placement
64
+ * - **selectedPieceId**: Live piece selected for movement
65
+ * - **Integration with drag controller**: Uses draggingId for "carry" state
66
+ *
67
+ * ## Dependencies
68
+ * This hook requires tight integration with useDragController as it uses the
69
+ * drag controller's carry mechanism for piece movement in click mode.
70
+ *
71
+ * @param controller - BaseGameController for state management
72
+ * @param layout - Computed CircleLayout for sector geometry
73
+ * @param pieces - Current piece data from usePieceState
74
+ * @param clickMode - Whether click input mode is active
75
+ * @param draggingId - Current dragging piece ID from drag controller
76
+ * @param setDraggingId - Setter for dragging ID (drag controller integration)
77
+ * @param dragRef - Drag state ref from drag controller
78
+ * @param isInsideBadge - Function to check if point is inside center badge
79
+ * @param isSectorLocked - Function to check if sector is completed
80
+ * @param maybeCompleteSector - Callback to check sector completion
81
+ * @param svgPoint - Function to convert screen to SVG coordinates
82
+ * @param force - Force React re-render function
83
+ * @returns Hook interface with click state and handlers
84
+ *
85
+ * @example
86
+ * ```typescript
87
+ * const clickController = useClickController(
88
+ * controller, layout, pieces, clickMode,
89
+ * draggingId, setDraggingId, dragRef,
90
+ * isInsideBadge, isSectorLocked, maybeCompleteSector, svgPoint, force
91
+ * );
92
+ *
93
+ * // Usage in JSX:
94
+ * <svg onPointerDown={clickController.onRootPointerDown}>
95
+ * Game content
96
+ * </svg>
97
+ * ```
98
+ */
99
+ export function useClickController(
100
+ controller: BaseGameController,
101
+ layout: CircleLayout,
102
+ pieces: PieceData[],
103
+ clickMode: boolean,
104
+ draggingId: string | null,
105
+ setDraggingId: (id: string | null) => void,
106
+ dragRef: React.RefObject<any>,
107
+ isInsideBadge: (x: number, y: number) => boolean,
108
+ isSectorLocked: (id: string) => boolean,
109
+ maybeCompleteSector: (secId: string) => boolean,
110
+ svgPoint: (clientX: number, clientY: number) => { x: number; y: number },
111
+ force: () => void,
112
+ // Interaction tracking
113
+ tracker?: {
114
+ recordPlacedown: (
115
+ outcome: 'placed' | 'deleted',
116
+ sectorId?: string,
117
+ position?: { x: number; y: number },
118
+ vertices?: number[][][],
119
+ anchorId?: string,
120
+ wasValid?: boolean,
121
+ wasOverlapping?: boolean,
122
+ completedSector?: boolean
123
+ ) => void;
124
+ recordClickEvent?: (
125
+ location: { x: number; y: number },
126
+ clickType: 'blueprint_view_switch' | 'invalid_placement' | 'sector_complete_attempt',
127
+ metadata?: any
128
+ ) => void;
129
+ } | null
130
+ ): ClickControllerHook {
131
+ const cfg = controller.state.cfg;
132
+ const sectorIdAt = (x: number, y: number) => sectorAtPoint(x, y, layout, cfg.target);
133
+
134
+ // Helper: Compute piece vertices in world space
135
+ const getPieceVertices = (pieceId: string): number[][][] => {
136
+ const piece = controller.findPiece(pieceId);
137
+ if (!piece) return [];
138
+ const bp = controller.getBlueprint(piece.blueprintId);
139
+ if (!bp) return [];
140
+ const bb = boundsOfBlueprint(bp, controller.getPrimitive);
141
+ const polys = piecePolysAt(bp, bb, piece.pos);
142
+ // Convert from {x,y}[][] to number[][][]
143
+ return polys.map(ring => ring.map(pt => [pt.x, pt.y]));
144
+ };
145
+
146
+ const getCurrentAnchorInfo = () => {
147
+ const d = dragRef.current;
148
+ return {
149
+ anchorId: d?.snapAnchorId ?? d?.pointerAnchorId ?? undefined,
150
+ wasValid: d?.validSnap !== false,
151
+ wasOverlapping: d?.overlaps ?? false,
152
+ anchorX: d?.pointerAnchor?.x,
153
+ anchorY: d?.pointerAnchor?.y,
154
+ sectorId: d?.snapAnchorSectorId ?? d?.pointerAnchorSectorId
155
+ };
156
+ };
157
+
158
+ const emitClickEvent = (
159
+ location: { x: number; y: number },
160
+ clickType: 'blueprint_view_switch' | 'invalid_placement' | 'sector_complete_attempt',
161
+ metadata?: any
162
+ ) => {
163
+ if (!tracker?.recordClickEvent) return;
164
+ tracker.recordClickEvent(location, clickType, metadata);
165
+ };
166
+
167
+ // Click mode state
168
+ const [pendingBpId, setPendingBpId] = React.useState<string | null>(null); // selected icon
169
+ const [selectedPieceId, setSelectedPieceId] = React.useState<string | null>(null); // selected live piece
170
+
171
+ const onRootPointerDown = (e: React.PointerEvent<SVGSVGElement>) => {
172
+ const { x: px, y: py } = svgPoint(e.clientX, e.clientY);
173
+
174
+ // --- CLICK MODE: second-click behaviors (move/delete/place)
175
+ if (clickMode) {
176
+ const secId = sectorIdAt(px, py);
177
+
178
+ // If we're currently carrying a piece (draggingId set via click), this click commits (or deletes)
179
+ if (draggingId && dragRef.current) {
180
+ const d = dragRef.current;
181
+ const centerX2 = d.tlx + d.aabb.width / 2;
182
+ const centerY2 = d.tly + d.aabb.height / 2;
183
+
184
+ // delete/cancel if clicked center (carry piece is already under pointer)
185
+ if (isInsideBadge(px, py)) {
186
+ const piece = controller.findPiece(d.id);
187
+ const removedFromSector = piece?.sectorId;
188
+ const anchorInfo = getCurrentAnchorInfo();
189
+ controller.remove(d.id);
190
+ // Re-check completion for the sector the piece was removed from
191
+ if (removedFromSector) maybeCompleteSector(removedFromSector);
192
+
193
+ // Record placedown as "deleted"
194
+ if (tracker) {
195
+ tracker.recordPlacedown(
196
+ 'deleted',
197
+ removedFromSector,
198
+ undefined,
199
+ undefined,
200
+ anchorInfo.anchorId,
201
+ anchorInfo.wasValid,
202
+ anchorInfo.wasOverlapping
203
+ );
204
+ }
205
+
206
+ // TODO: Remove dragRef manipulation once drag controller provides proper interface
207
+ // dragRef.current = null; // Cannot assign to readonly property - drag controller should handle this
208
+ setDraggingId(null);
209
+ setSelectedPieceId(null);
210
+ setPendingBpId(null);
211
+ force();
212
+ return; // don't toggle view
213
+ }
214
+
215
+ // Check if piece is in inner ring - if so, delete it
216
+ const rFromCenter = Math.hypot(centerX2 - layout.cx, centerY2 - layout.cy);
217
+ if (rFromCenter < layout.innerR) {
218
+ const piece = controller.findPiece(d.id);
219
+ const removedFromSector = piece?.sectorId;
220
+ const anchorInfo = getCurrentAnchorInfo();
221
+ controller.remove(d.id);
222
+ if (removedFromSector) maybeCompleteSector(removedFromSector);
223
+
224
+ // Record placedown as "deleted"
225
+ if (tracker) {
226
+ tracker.recordPlacedown(
227
+ 'deleted',
228
+ removedFromSector,
229
+ undefined,
230
+ undefined,
231
+ anchorInfo.anchorId,
232
+ anchorInfo.wasValid,
233
+ anchorInfo.wasOverlapping
234
+ );
235
+ }
236
+
237
+ setDraggingId(null);
238
+ setSelectedPieceId(null);
239
+ setPendingBpId(null);
240
+ force();
241
+ return;
242
+ }
243
+
244
+ const secId2 = sectorIdAt(centerX2, centerY2);
245
+
246
+ if (!secId2) {
247
+ emitClickEvent(
248
+ { x: centerX2, y: centerY2 },
249
+ 'invalid_placement',
250
+ { invalidPlacement: { reason: 'outside_bounds' } }
251
+ );
252
+ force();
253
+ return;
254
+ }
255
+
256
+ if (isSectorLocked(secId2)) {
257
+ emitClickEvent(
258
+ { x: centerX2, y: centerY2 },
259
+ 'sector_complete_attempt',
260
+ { invalidPlacement: { reason: 'sector_complete', attemptedSectorId: secId2 } }
261
+ );
262
+ force();
263
+ return;
264
+ }
265
+
266
+ // If the candidate is invalid for any reason (overlap / outside / bad node), keep carrying
267
+ if (d.validSnap === false) {
268
+ const anchorInfo = getCurrentAnchorInfo();
269
+ const invalidReason = anchorInfo.wasOverlapping ? 'overlapping' : 'no_valid_anchor';
270
+ emitClickEvent(
271
+ { x: centerX2, y: centerY2 },
272
+ 'invalid_placement',
273
+ { invalidPlacement: { reason: invalidReason, attemptedSectorId: secId2 } }
274
+ );
275
+ force();
276
+ return;
277
+ }
278
+
279
+ controller.move(d.id, { x: d.tlx, y: d.tly }); // TL already snapped (valid)
280
+ controller.drop(d.id, secId2);
281
+
282
+ const anchorInfo = getCurrentAnchorInfo();
283
+ const justCompleted = maybeCompleteSector(secId2);
284
+
285
+ // Record placedown as "placed" successfully
286
+ if (tracker) {
287
+ tracker.recordPlacedown(
288
+ 'placed',
289
+ secId2,
290
+ { x: d.tlx, y: d.tly },
291
+ getPieceVertices(d.id),
292
+ anchorInfo.anchorId,
293
+ anchorInfo.wasValid,
294
+ anchorInfo.wasOverlapping,
295
+ justCompleted
296
+ );
297
+ }
298
+ // TODO: Remove dragRef manipulation once drag controller provides proper interface
299
+ // dragRef.current = null; // Cannot assign to readonly property - drag controller should handle this
300
+ setDraggingId(null);
301
+ setSelectedPieceId(null);
302
+ setPendingBpId(null);
303
+ // auto-revert AFTER placement completes (click mode)
304
+ if (controller.state.blueprintView === "primitives") {
305
+ controller.switchBlueprintView();
306
+ }
307
+ force();
308
+ return;
309
+ }
310
+
311
+ // Selected piece → delete / move / deselect
312
+ if (selectedPieceId) {
313
+ if (isInsideBadge(px, py)) {
314
+ const piece = controller.findPiece(selectedPieceId);
315
+ const removedFromSector = piece?.sectorId;
316
+ controller.remove(selectedPieceId);
317
+ // Re-check completion for the sector the piece was removed from
318
+ if (removedFromSector) maybeCompleteSector(removedFromSector);
319
+
320
+ // Record placedown as "deleted"
321
+ if (tracker) {
322
+ tracker.recordPlacedown('deleted');
323
+ }
324
+
325
+ setSelectedPieceId(null);
326
+ force();
327
+ return; // DO NOT toggle view after delete
328
+ }
329
+ // Completed sector: no-op (do nothing, same as incomplete sector in silhouette mode)
330
+ if (secId && isSectorLocked(secId)) {
331
+ setSelectedPieceId(null);
332
+ force();
333
+ return;
334
+ }
335
+ if (secId) {
336
+ const p = pieces.find(pp => pp.id === selectedPieceId);
337
+ setSelectedPieceId(null);
338
+ if (p) {
339
+ const bp = controller.getBlueprint(p.blueprintId)!;
340
+ const bb = boundsOfBlueprint(bp, controller.getPrimitive);
341
+ const support = computeSupportOffsets(bp, bb);
342
+ const tl0 = { x: px - bb.width / 2, y: py - bb.height / 2 };
343
+ const clamped = clampTopLeftBySupport(
344
+ tl0.x, tl0.y,
345
+ { aabb: { width: bb.width, height: bb.height }, support },
346
+ layout,
347
+ controller.state.cfg.target,
348
+ false
349
+ );
350
+
351
+ const maxPieces = controller.state.cfg.maxCompositeSize ?? 0;
352
+ if (controller.state.cfg.mode === "prep" && maxPieces > 0) {
353
+ const sectorPieces = controller.getPiecesInSector(secId);
354
+ const effectiveCount = sectorPieces.length - (p.sectorId === secId ? 1 : 0);
355
+ if (effectiveCount + 1 > maxPieces) {
356
+ force();
357
+ return;
358
+ }
359
+ }
360
+
361
+ controller.move(p.id, { x: clamped.x, y: clamped.y });
362
+ controller.drop(p.id, secId);
363
+ maybeCompleteSector(secId);
364
+ force();
365
+ }
366
+ return;
367
+ }
368
+ // click outside → deselect only
369
+ setSelectedPieceId(null);
370
+ force();
371
+ return;
372
+ }
373
+
374
+ // Pending icon → place
375
+ if (pendingBpId) {
376
+ const secId2 = sectorIdAt(px, py);
377
+
378
+ if (isInsideBadge(px, py) || !secId2 || isSectorLocked(secId2)) {
379
+ setPendingBpId(null); // cancel
380
+ force();
381
+ return;
382
+ }
383
+
384
+ const bp = controller.getBlueprint(pendingBpId)!;
385
+ const bb = boundsOfBlueprint(bp, controller.getPrimitive);
386
+ const support = computeSupportOffsets(bp, bb);
387
+ const tl0 = { x: px - bb.width / 2, y: py - bb.height / 2 };
388
+ const clamped = clampTopLeftBySupport(
389
+ tl0.x, tl0.y,
390
+ { aabb: { width: bb.width, height: bb.height }, support },
391
+ layout,
392
+ controller.state.cfg.target,
393
+ false
394
+ );
395
+ const id = controller.spawnFromBlueprint(bp, clamped);
396
+
397
+ const maxPieces = controller.state.cfg.maxCompositeSize ?? 0;
398
+ if (controller.state.cfg.mode === "prep" && maxPieces > 0) {
399
+ const sectorPieces = controller.getPiecesInSector(secId2);
400
+ if (sectorPieces.length + 1 > maxPieces) {
401
+ controller.remove(id);
402
+ setPendingBpId(null);
403
+ force();
404
+ return;
405
+ }
406
+ }
407
+
408
+ controller.drop(id, secId2);
409
+ setPendingBpId(null);
410
+ maybeCompleteSector(secId2);
411
+
412
+ // auto-revert AFTER placement completes (click mode)
413
+ if (controller.state.blueprintView === "primitives") controller.switchBlueprintView();
414
+ force();
415
+ return;
416
+ }
417
+ }
418
+ };
419
+
420
+ return {
421
+ // State
422
+ pendingBpId,
423
+ selectedPieceId,
424
+
425
+ // Handlers
426
+ onRootPointerDown,
427
+
428
+ // Helper methods for integration
429
+ clearSelection: () => {
430
+ setSelectedPieceId(null);
431
+ setPendingBpId(null);
432
+ },
433
+ setPendingBp: setPendingBpId,
434
+ setSelectedPiece: setSelectedPieceId,
435
+ };
436
+ }