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.
- package/README.md +25 -0
- package/dist/construct/index.browser.js +20431 -0
- package/dist/construct/index.browser.js.map +1 -0
- package/dist/construct/index.browser.min.js +42 -0
- package/dist/construct/index.browser.min.js.map +1 -0
- package/dist/construct/index.cjs +3720 -0
- package/dist/construct/index.cjs.map +1 -0
- package/dist/construct/index.d.ts +204 -0
- package/dist/construct/index.js +3718 -0
- package/dist/construct/index.js.map +1 -0
- package/dist/index.cjs +3920 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +340 -0
- package/dist/index.js +3917 -0
- package/dist/index.js.map +1 -0
- package/dist/prep/index.browser.js +20455 -0
- package/dist/prep/index.browser.js.map +1 -0
- package/dist/prep/index.browser.min.js +42 -0
- package/dist/prep/index.browser.min.js.map +1 -0
- package/dist/prep/index.cjs +3744 -0
- package/dist/prep/index.cjs.map +1 -0
- package/dist/prep/index.d.ts +139 -0
- package/dist/prep/index.js +3742 -0
- package/dist/prep/index.js.map +1 -0
- package/package.json +77 -0
- package/src/core/components/README.md +249 -0
- package/src/core/components/board/BoardView.tsx +352 -0
- package/src/core/components/board/GameBoard.tsx +682 -0
- package/src/core/components/board/index.ts +70 -0
- package/src/core/components/board/useAnchorGrid.ts +110 -0
- package/src/core/components/board/useClickController.ts +436 -0
- package/src/core/components/board/useDragController.ts +1051 -0
- package/src/core/components/board/usePieceState.ts +178 -0
- package/src/core/components/board/utils.ts +76 -0
- package/src/core/components/index.ts +33 -0
- package/src/core/components/pieces/BlueprintRing.tsx +238 -0
- package/src/core/config/config.ts +85 -0
- package/src/core/domain/blueprints.ts +25 -0
- package/src/core/domain/layout.ts +159 -0
- package/src/core/domain/primitives.ts +159 -0
- package/src/core/domain/solve.ts +184 -0
- package/src/core/domain/types.ts +111 -0
- package/src/core/engine/collision/grid-snapping.ts +283 -0
- package/src/core/engine/collision/index.ts +4 -0
- package/src/core/engine/collision/sat-collision.ts +46 -0
- package/src/core/engine/collision/validation.ts +166 -0
- package/src/core/engine/geometry/bounds.ts +91 -0
- package/src/core/engine/geometry/collision.ts +64 -0
- package/src/core/engine/geometry/index.ts +19 -0
- package/src/core/engine/geometry/math.ts +101 -0
- package/src/core/engine/geometry/pieces.ts +290 -0
- package/src/core/engine/geometry/polygons.ts +43 -0
- package/src/core/engine/state/BaseGameController.ts +368 -0
- package/src/core/engine/validation/border-rendering.ts +318 -0
- package/src/core/engine/validation/complete.ts +102 -0
- package/src/core/engine/validation/face-to-face.ts +217 -0
- package/src/core/index.ts +3 -0
- package/src/core/io/InteractionTracker.ts +742 -0
- package/src/core/io/data-tracking.ts +271 -0
- package/src/core/io/json-to-tangram-spec.ts +110 -0
- package/src/core/io/quickstash.ts +141 -0
- package/src/core/io/stims.ts +110 -0
- package/src/core/types/index.ts +5 -0
- package/src/core/types/plugin-interfaces.ts +101 -0
- package/src/index.spec.ts +19 -0
- package/src/index.ts +2 -0
- package/src/plugins/tangram-construct/ConstructionApp.tsx +105 -0
- package/src/plugins/tangram-construct/index.ts +156 -0
- package/src/plugins/tangram-prep/PrepApp.tsx +182 -0
- package/src/plugins/tangram-prep/index.ts +122 -0
- package/tangram-construct.min.js +42 -0
- package/tangram-prep.min.js +42 -0
|
@@ -0,0 +1,1051 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { BaseGameController } from "@/core/engine/state/BaseGameController";
|
|
3
|
+
import type { CircleLayout } from "@/core/domain/layout";
|
|
4
|
+
import type { Blueprint } from "@/core/domain/types";
|
|
5
|
+
import { sectorAtPoint } from "@/core/domain/layout";
|
|
6
|
+
import {
|
|
7
|
+
boundsOfBlueprint,
|
|
8
|
+
computeSupportOffsets,
|
|
9
|
+
clampTopLeftBySupport,
|
|
10
|
+
polysOverlap,
|
|
11
|
+
piecePolysAt
|
|
12
|
+
} from "@/core/engine/geometry";
|
|
13
|
+
import { nearestNodeSnap, polyFullyInside } from "@/core/engine/collision/grid-snapping";
|
|
14
|
+
import { checkFaceToFaceAttachment, wouldRemovalDisconnectSector } from "@/core/engine/validation/face-to-face";
|
|
15
|
+
import { blueprintLocalFromWorld } from "./utils";
|
|
16
|
+
import type { AnchorDots } from "./useAnchorGrid";
|
|
17
|
+
import type { PieceData } from "./usePieceState";
|
|
18
|
+
import { CONFIG } from "@/core/config/config";
|
|
19
|
+
|
|
20
|
+
interface DragState {
|
|
21
|
+
id: string;
|
|
22
|
+
dx: number;
|
|
23
|
+
dy: number;
|
|
24
|
+
tlx: number;
|
|
25
|
+
tly: number;
|
|
26
|
+
aabb: { width: number; height: number };
|
|
27
|
+
minx: number;
|
|
28
|
+
miny: number;
|
|
29
|
+
support: { x: number; y: number }[];
|
|
30
|
+
fromIcon?: boolean;
|
|
31
|
+
validSnap?: boolean;
|
|
32
|
+
prevValidSnap?: boolean;
|
|
33
|
+
raf?: number | null;
|
|
34
|
+
originalPos?: { x: number; y: number; sectorId: string | undefined } | undefined; // For prep mode: return to original position when invalid
|
|
35
|
+
pointerAnchor?: { x: number; y: number } | null;
|
|
36
|
+
pointerAnchorId?: string | null;
|
|
37
|
+
pointerAnchorSectorId?: string | undefined;
|
|
38
|
+
snapAnchor?: { x: number; y: number } | null;
|
|
39
|
+
snapAnchorId?: string | null;
|
|
40
|
+
snapAnchorSectorId?: string | undefined;
|
|
41
|
+
snapAnchorDist?: number | null;
|
|
42
|
+
overlaps?: boolean;
|
|
43
|
+
maxExceeded?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Return interface for useDragController hook
|
|
48
|
+
*
|
|
49
|
+
* Provides complete drag interaction functionality for both click and drag input modes.
|
|
50
|
+
* Handles piece spawning, dragging, collision detection, snapping, and placement validation.
|
|
51
|
+
*/
|
|
52
|
+
export interface DragControllerHook {
|
|
53
|
+
// State
|
|
54
|
+
/** ID of currently dragging piece (null if none) */
|
|
55
|
+
draggingId: string | null;
|
|
56
|
+
|
|
57
|
+
/** True if current drag position is invalid (for visual feedback) */
|
|
58
|
+
dragInvalid: boolean;
|
|
59
|
+
|
|
60
|
+
/** ID of piece that is locked due to connectivity constraints (for visual feedback) */
|
|
61
|
+
lockedPieceId: string | null;
|
|
62
|
+
|
|
63
|
+
/** Ref to SVG element for coordinate transformation and pointer capture */
|
|
64
|
+
svgRef: React.RefObject<SVGSVGElement>;
|
|
65
|
+
|
|
66
|
+
// Handlers
|
|
67
|
+
/**
|
|
68
|
+
* Handler for piece pointer down events (both click and drag modes)
|
|
69
|
+
* @param e - Pointer event from SVG path or group element
|
|
70
|
+
* @param p - Piece data including position and blueprint information
|
|
71
|
+
*/
|
|
72
|
+
onPiecePointerDown: (
|
|
73
|
+
e: React.PointerEvent<SVGPathElement | SVGGElement>,
|
|
74
|
+
p: { id: string; x: number; y: number; blueprintId: string; sectorId?: string }
|
|
75
|
+
) => void;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Handler for blueprint icon pointer down events (spawning new pieces)
|
|
79
|
+
* @param e - Pointer event from blueprint icon
|
|
80
|
+
* @param bp - Blueprint definition for the new piece
|
|
81
|
+
* @param bpGeom - Geometry information for the blueprint icon
|
|
82
|
+
*/
|
|
83
|
+
onBlueprintPointerDown: (
|
|
84
|
+
e: React.PointerEvent,
|
|
85
|
+
bp: Blueprint,
|
|
86
|
+
bpGeom: { bx: number; by: number; cx: number; cy: number }
|
|
87
|
+
) => void;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Handler for pointer move events during drag operations
|
|
91
|
+
* @param e - Pointer event from SVG root element
|
|
92
|
+
*/
|
|
93
|
+
onPointerMove: (e: React.PointerEvent<SVGSVGElement>) => void;
|
|
94
|
+
|
|
95
|
+
/** Handler for pointer up events (completing drag operations) */
|
|
96
|
+
onPointerUp: () => void;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Factory function for piece element refs (for direct DOM manipulation)
|
|
100
|
+
* @param id - Piece ID
|
|
101
|
+
* @returns Ref callback for SVG group element
|
|
102
|
+
*/
|
|
103
|
+
setPieceRef: (id: string) => (el: SVGGElement | null) => void;
|
|
104
|
+
|
|
105
|
+
// Click controller integration
|
|
106
|
+
/**
|
|
107
|
+
* Setter for dragging ID (used by click controller for carry state)
|
|
108
|
+
* @param id - Piece ID or null to clear
|
|
109
|
+
*/
|
|
110
|
+
setDraggingId: (id: string | null) => void;
|
|
111
|
+
|
|
112
|
+
// TODO: Remove this once click mode is extracted - temporary for compatibility
|
|
113
|
+
/** Internal drag state ref (temporary for click controller integration) */
|
|
114
|
+
dragRef: React.RefObject<DragState | null>;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Hook for managing drag interactions with RAF optimization and collision detection
|
|
119
|
+
*
|
|
120
|
+
* This is the most complex hook in the system, handling all aspects of piece interaction
|
|
121
|
+
* for both click and drag input modes. It manages piece spawning, dragging, collision
|
|
122
|
+
* detection, grid snapping, placement validation, and visual feedback.
|
|
123
|
+
*
|
|
124
|
+
* ## Key Features
|
|
125
|
+
* - **Dual input mode support**: Handles both click and drag interaction patterns
|
|
126
|
+
* - **RAF-optimized rendering**: Uses requestAnimationFrame for smooth drag performance
|
|
127
|
+
* - **Collision detection**: Real-time overlap checking with existing pieces
|
|
128
|
+
* - **Grid snapping**: Automatic snapping to anchor points with validation
|
|
129
|
+
* - **Placement validation**: Comprehensive validation for workspace/silhouette modes
|
|
130
|
+
* - **Visual feedback**: Real-time visual indicators for valid/invalid placement
|
|
131
|
+
*
|
|
132
|
+
* ## Input Mode Behavior
|
|
133
|
+
* - **Click mode**: First click starts "carry" state, second click places/cancels
|
|
134
|
+
* - **Drag mode**: Traditional drag-and-drop with immediate visual feedback
|
|
135
|
+
*
|
|
136
|
+
* ## Validation Rules
|
|
137
|
+
* - **Workspace mode**: Pieces must not overlap, any grid position valid
|
|
138
|
+
* - **Silhouette mode**: Pieces must be within silhouette mask, on valid anchors, no overlap
|
|
139
|
+
* - **Completed sectors**: No placement allowed, pieces deleted if dropped
|
|
140
|
+
*
|
|
141
|
+
* @param controller - BaseGameController for state management
|
|
142
|
+
* @param layout - Computed CircleLayout for sector geometry
|
|
143
|
+
* @param pieces - Current piece data from usePieceState
|
|
144
|
+
* @param pieceById - Lookup function for piece data
|
|
145
|
+
* @param anchorDots - Anchor grid data from useAnchorGrid
|
|
146
|
+
* @param placedSilBySector - Silhouette polygons for containment checking
|
|
147
|
+
* @param isSectorLocked - Function to check if sector is completed
|
|
148
|
+
* @param maybeCompleteSector - Callback to check sector completion after placement
|
|
149
|
+
* @param force - Force React re-render function
|
|
150
|
+
* @param clickController - Optional click controller integration
|
|
151
|
+
* @returns Hook interface with drag state and event handlers
|
|
152
|
+
*
|
|
153
|
+
* @example
|
|
154
|
+
* ```typescript
|
|
155
|
+
* const dragController = useDragController(
|
|
156
|
+
* controller, layout, pieces, pieceById, anchorDots,
|
|
157
|
+
* placedSilBySector, isSectorLocked, maybeCompleteSector, force
|
|
158
|
+
* );
|
|
159
|
+
*
|
|
160
|
+
* // Usage in JSX:
|
|
161
|
+
* <svg ref={dragController.svgRef} onPointerMove={dragController.onPointerMove}>
|
|
162
|
+
* <g onPointerDown={(e) => dragController.onPiecePointerDown(e, piece)}>
|
|
163
|
+
* <path />
|
|
164
|
+
* </g>
|
|
165
|
+
* </svg>
|
|
166
|
+
* ```
|
|
167
|
+
*/
|
|
168
|
+
export function useDragController(
|
|
169
|
+
controller: BaseGameController,
|
|
170
|
+
layout: CircleLayout,
|
|
171
|
+
pieces: PieceData[],
|
|
172
|
+
pieceById: (id: string | null) => PieceData | null,
|
|
173
|
+
anchorDots: AnchorDots[],
|
|
174
|
+
placedSilBySector: Map<string, any[]>,
|
|
175
|
+
isSectorLocked: (id: string) => boolean,
|
|
176
|
+
maybeCompleteSector: (secId: string) => boolean,
|
|
177
|
+
force: () => void,
|
|
178
|
+
// Click controller integration
|
|
179
|
+
clickController?: {
|
|
180
|
+
setSelectedPiece: (id: string | null) => void;
|
|
181
|
+
setPendingBp: (id: string | null) => void;
|
|
182
|
+
},
|
|
183
|
+
// Interaction tracking
|
|
184
|
+
tracker?: {
|
|
185
|
+
recordPickup: (
|
|
186
|
+
pieceId: string,
|
|
187
|
+
blueprintId: string,
|
|
188
|
+
blueprintType: 'primitive' | 'composite',
|
|
189
|
+
source: 'blueprint' | 'sector',
|
|
190
|
+
position: { x: number; y: number },
|
|
191
|
+
vertices: number[][][],
|
|
192
|
+
sectorId?: string
|
|
193
|
+
) => void;
|
|
194
|
+
recordPlacedown: (
|
|
195
|
+
outcome: 'placed' | 'deleted',
|
|
196
|
+
sectorId?: string,
|
|
197
|
+
position?: { x: number; y: number },
|
|
198
|
+
vertices?: number[][][],
|
|
199
|
+
anchorId?: string,
|
|
200
|
+
wasValid?: boolean,
|
|
201
|
+
wasOverlapping?: boolean,
|
|
202
|
+
completedSector?: boolean
|
|
203
|
+
) => void;
|
|
204
|
+
recordMouseMove?: (anchorX: number, anchorY: number, sectorId?: string) => void;
|
|
205
|
+
recordClickEvent?: (
|
|
206
|
+
location: { x: number; y: number },
|
|
207
|
+
clickType: 'blueprint_view_switch' | 'invalid_placement' | 'sector_complete_attempt',
|
|
208
|
+
metadata?: any
|
|
209
|
+
) => void;
|
|
210
|
+
} | null
|
|
211
|
+
): DragControllerHook {
|
|
212
|
+
const cfg = controller.state.cfg;
|
|
213
|
+
const clickMode = cfg.input === "click";
|
|
214
|
+
|
|
215
|
+
// Core drag state
|
|
216
|
+
const svgRef = React.useRef<SVGSVGElement | null>(null);
|
|
217
|
+
const dragRef = React.useRef<DragState | null>(null);
|
|
218
|
+
const [draggingId, setDraggingId] = React.useState<string | null>(null);
|
|
219
|
+
const elMap = React.useRef(new Map<string, SVGGElement>());
|
|
220
|
+
|
|
221
|
+
// Visual feedback for connectivity-locked pieces
|
|
222
|
+
const [lockedPieceId, setLockedPieceId] = React.useState<string | null>(null);
|
|
223
|
+
|
|
224
|
+
// Ensure dragRef is cleared when draggingId becomes null (for click controller integration)
|
|
225
|
+
React.useEffect(() => {
|
|
226
|
+
if (draggingId === null) {
|
|
227
|
+
dragRef.current = null;
|
|
228
|
+
}
|
|
229
|
+
}, [draggingId]);
|
|
230
|
+
|
|
231
|
+
const setPieceRef = (id: string) => (el: SVGGElement | null) => {
|
|
232
|
+
const m = elMap.current;
|
|
233
|
+
if (el) m.set(id, el); else m.delete(id);
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const svgPoint = (clientX: number, clientY: number) => {
|
|
237
|
+
const svg = svgRef.current!;
|
|
238
|
+
const pt = svg.createSVGPoint();
|
|
239
|
+
pt.x = clientX; pt.y = clientY;
|
|
240
|
+
const ctm = svg.getScreenCTM();
|
|
241
|
+
if (!ctm) return { x: 0, y: 0 };
|
|
242
|
+
const sp = pt.matrixTransform(ctm.inverse());
|
|
243
|
+
return { x: sp.x, y: sp.y };
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const scheduleFrame = () => {
|
|
247
|
+
if (!dragRef.current) return;
|
|
248
|
+
const d = dragRef.current;
|
|
249
|
+
if (d.raf != null) return;
|
|
250
|
+
d.raf = requestAnimationFrame(() => {
|
|
251
|
+
d.raf = null;
|
|
252
|
+
const g = elMap.current.get(d.id);
|
|
253
|
+
if (g) g.setAttribute("transform", `translate(${d.tlx - d.minx}, ${d.tly - d.miny})`);
|
|
254
|
+
});
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const sectorIdAt = (x: number, y: number) => sectorAtPoint(x, y, layout, cfg.target);
|
|
258
|
+
const GRID_STEP = CONFIG.layout.grid.stepPx;
|
|
259
|
+
|
|
260
|
+
const anchorIdFromNode = (node: { x: number; y: number } | null | undefined): string | null => {
|
|
261
|
+
if (!node) return null;
|
|
262
|
+
const ax = Math.round(node.x / GRID_STEP);
|
|
263
|
+
const ay = Math.round(node.y / GRID_STEP);
|
|
264
|
+
return `${ax},${ay}`;
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const anchorNodeFromPoint = (point: { x: number; y: number }) => ({
|
|
268
|
+
x: Math.round(point.x / GRID_STEP) * GRID_STEP,
|
|
269
|
+
y: Math.round(point.y / GRID_STEP) * GRID_STEP
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
const updatePointerAnchor = (point: { x: number; y: number }, sectorId: string | undefined) => {
|
|
273
|
+
if (!dragRef.current) return;
|
|
274
|
+
const d = dragRef.current;
|
|
275
|
+
const anchorNode = anchorNodeFromPoint(point);
|
|
276
|
+
const anchorId = anchorIdFromNode(anchorNode);
|
|
277
|
+
|
|
278
|
+
if (tracker?.recordMouseMove && (anchorId !== d.pointerAnchorId || sectorId !== d.pointerAnchorSectorId)) {
|
|
279
|
+
tracker.recordMouseMove(anchorNode.x, anchorNode.y, sectorId);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
d.pointerAnchor = anchorNode;
|
|
283
|
+
d.pointerAnchorId = anchorId;
|
|
284
|
+
d.pointerAnchorSectorId = sectorId;
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const updateSnapAnchor = (
|
|
288
|
+
node: { x: number; y: number } | null,
|
|
289
|
+
sectorId: string | undefined,
|
|
290
|
+
dist?: number
|
|
291
|
+
) => {
|
|
292
|
+
if (!dragRef.current) return;
|
|
293
|
+
const d = dragRef.current;
|
|
294
|
+
d.snapAnchor = node;
|
|
295
|
+
d.snapAnchorId = anchorIdFromNode(node);
|
|
296
|
+
d.snapAnchorSectorId = sectorId;
|
|
297
|
+
d.snapAnchorDist = node && dist !== undefined ? dist : null;
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const emitClickEvent = (
|
|
301
|
+
location: { x: number; y: number },
|
|
302
|
+
clickType: 'blueprint_view_switch' | 'invalid_placement' | 'sector_complete_attempt',
|
|
303
|
+
metadata?: any
|
|
304
|
+
) => {
|
|
305
|
+
if (!tracker?.recordClickEvent) return;
|
|
306
|
+
tracker.recordClickEvent(location, clickType, metadata);
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
// Helper: Compute piece vertices in world space
|
|
310
|
+
const getPieceVertices = (pieceId: string): number[][][] => {
|
|
311
|
+
const piece = controller.findPiece(pieceId);
|
|
312
|
+
if (!piece) return [];
|
|
313
|
+
const bp = controller.getBlueprint(piece.blueprintId);
|
|
314
|
+
if (!bp) return [];
|
|
315
|
+
const bb = boundsOfBlueprint(bp, controller.getPrimitive);
|
|
316
|
+
const polys = piecePolysAt(bp, bb, piece.pos);
|
|
317
|
+
// Convert from {x,y}[][] to number[][][]
|
|
318
|
+
return polys.map(ring => ring.map(pt => [pt.x, pt.y]));
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
const onPiecePointerDown = (
|
|
322
|
+
e: React.PointerEvent<SVGPathElement | SVGGElement>,
|
|
323
|
+
p: { id: string; x: number; y: number; blueprintId: string; sectorId?: string }
|
|
324
|
+
) => {
|
|
325
|
+
if (p.sectorId && isSectorLocked(p.sectorId)) return;
|
|
326
|
+
if (clickMode && draggingId) return; // ignore if already carrying
|
|
327
|
+
|
|
328
|
+
// Check connectivity in prep mode - prevent removal if it would disconnect remaining pieces
|
|
329
|
+
if (cfg.mode === "prep" && p.sectorId) {
|
|
330
|
+
const allPiecesInSector = controller.getPiecesInSector(p.sectorId);
|
|
331
|
+
const pieceToRemove = controller.findPiece(p.id);
|
|
332
|
+
|
|
333
|
+
if (pieceToRemove && wouldRemovalDisconnectSector(
|
|
334
|
+
pieceToRemove,
|
|
335
|
+
allPiecesInSector,
|
|
336
|
+
(id) => controller.getBlueprint(id),
|
|
337
|
+
(kind) => controller.getPrimitive(kind)
|
|
338
|
+
)) {
|
|
339
|
+
// Flash red briefly to indicate piece cannot be removed
|
|
340
|
+
setLockedPieceId(p.id);
|
|
341
|
+
setTimeout(() => setLockedPieceId(null), 500);
|
|
342
|
+
return; // Prevent pickup
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (clickMode) {
|
|
347
|
+
// Start a "carry" in click mode: make the piece follow the cursor
|
|
348
|
+
e.stopPropagation();
|
|
349
|
+
clickController?.setSelectedPiece(p.id);
|
|
350
|
+
clickController?.setPendingBp(null);
|
|
351
|
+
|
|
352
|
+
const bp = controller.getBlueprint(p.blueprintId)!;
|
|
353
|
+
const bb = boundsOfBlueprint(bp, (k) => controller.getPrimitive(k));
|
|
354
|
+
const support = computeSupportOffsets(bp, bb);
|
|
355
|
+
const { x, y } = svgPoint(e.clientX, e.clientY);
|
|
356
|
+
|
|
357
|
+
const pointerAnchorNode = anchorNodeFromPoint({ x, y });
|
|
358
|
+
const pointerSector = p.sectorId;
|
|
359
|
+
if (tracker?.recordMouseMove) {
|
|
360
|
+
tracker.recordMouseMove(pointerAnchorNode.x, pointerAnchorNode.y, pointerSector);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Record pickup event
|
|
364
|
+
if (tracker) {
|
|
365
|
+
const blueprintType = ('kind' in bp) ? 'primitive' : 'composite';
|
|
366
|
+
tracker.recordPickup(
|
|
367
|
+
p.id,
|
|
368
|
+
p.blueprintId,
|
|
369
|
+
blueprintType,
|
|
370
|
+
'sector',
|
|
371
|
+
{ x: p.x, y: p.y },
|
|
372
|
+
getPieceVertices(p.id),
|
|
373
|
+
p.sectorId
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
dragRef.current = {
|
|
378
|
+
id: p.id,
|
|
379
|
+
dx: x - p.x, dy: y - p.y,
|
|
380
|
+
tlx: p.x, tly: p.y,
|
|
381
|
+
aabb: { width: bb.width, height: bb.height },
|
|
382
|
+
minx: bb.min.x, miny: bb.min.y,
|
|
383
|
+
support,
|
|
384
|
+
fromIcon: false,
|
|
385
|
+
validSnap: true,
|
|
386
|
+
prevValidSnap: true,
|
|
387
|
+
raf: null,
|
|
388
|
+
originalPos: { x: p.x, y: p.y, sectorId: p.sectorId },
|
|
389
|
+
pointerAnchor: pointerAnchorNode,
|
|
390
|
+
pointerAnchorId: anchorIdFromNode(pointerAnchorNode),
|
|
391
|
+
pointerAnchorSectorId: pointerSector,
|
|
392
|
+
snapAnchor: null,
|
|
393
|
+
snapAnchorId: null,
|
|
394
|
+
snapAnchorSectorId: undefined,
|
|
395
|
+
snapAnchorDist: null,
|
|
396
|
+
overlaps: false,
|
|
397
|
+
maxExceeded: false,
|
|
398
|
+
};
|
|
399
|
+
updateSnapAnchor(null, p.sectorId);
|
|
400
|
+
setDraggingId(p.id);
|
|
401
|
+
force();
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// DRAG path
|
|
406
|
+
svgRef.current?.setPointerCapture(e.pointerId);
|
|
407
|
+
const bp = controller.getBlueprint(p.blueprintId)!;
|
|
408
|
+
const bb = boundsOfBlueprint(bp, (k) => controller.getPrimitive(k));
|
|
409
|
+
const support = computeSupportOffsets(bp, bb);
|
|
410
|
+
const { x, y } = svgPoint(e.clientX, e.clientY);
|
|
411
|
+
|
|
412
|
+
const pointerAnchorNode = anchorNodeFromPoint({ x, y });
|
|
413
|
+
const pointerSector = p.sectorId;
|
|
414
|
+
if (tracker?.recordMouseMove) {
|
|
415
|
+
tracker.recordMouseMove(pointerAnchorNode.x, pointerAnchorNode.y, pointerSector);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Record pickup event
|
|
419
|
+
if (tracker) {
|
|
420
|
+
const blueprintType = ('kind' in bp) ? 'primitive' : 'composite';
|
|
421
|
+
tracker.recordPickup(
|
|
422
|
+
p.id,
|
|
423
|
+
p.blueprintId,
|
|
424
|
+
blueprintType,
|
|
425
|
+
'sector',
|
|
426
|
+
{ x: p.x, y: p.y },
|
|
427
|
+
getPieceVertices(p.id),
|
|
428
|
+
p.sectorId
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
dragRef.current = {
|
|
433
|
+
id: p.id,
|
|
434
|
+
dx: x - p.x, dy: y - p.y,
|
|
435
|
+
tlx: p.x, tly: p.y,
|
|
436
|
+
aabb: { width: bb.width, height: bb.height },
|
|
437
|
+
minx: bb.min.x, miny: bb.min.y,
|
|
438
|
+
support,
|
|
439
|
+
fromIcon: false,
|
|
440
|
+
validSnap: true,
|
|
441
|
+
prevValidSnap: true,
|
|
442
|
+
raf: null,
|
|
443
|
+
originalPos: { x: p.x, y: p.y, sectorId: p.sectorId },
|
|
444
|
+
pointerAnchor: pointerAnchorNode,
|
|
445
|
+
pointerAnchorId: anchorIdFromNode(pointerAnchorNode),
|
|
446
|
+
pointerAnchorSectorId: pointerSector,
|
|
447
|
+
snapAnchor: null,
|
|
448
|
+
snapAnchorId: null,
|
|
449
|
+
snapAnchorSectorId: undefined,
|
|
450
|
+
snapAnchorDist: null,
|
|
451
|
+
overlaps: false,
|
|
452
|
+
maxExceeded: false,
|
|
453
|
+
};
|
|
454
|
+
updateSnapAnchor(null, p.sectorId);
|
|
455
|
+
setDraggingId(p.id);
|
|
456
|
+
force();
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
const onPointerMove = (e: React.PointerEvent<SVGSVGElement>) => {
|
|
460
|
+
const { x, y } = svgPoint(e.clientX, e.clientY);
|
|
461
|
+
|
|
462
|
+
// Always log pointer movement for mouse tracking, even before pickup
|
|
463
|
+
const pointerAnchorNode = anchorNodeFromPoint({ x, y });
|
|
464
|
+
const pointerR = Math.hypot(x - layout.cx, y - layout.cy);
|
|
465
|
+
const pointerSectorId = pointerR < layout.innerR ? undefined : sectorIdAt(x, y) ?? undefined;
|
|
466
|
+
if (!dragRef.current) {
|
|
467
|
+
if (tracker?.recordMouseMove) {
|
|
468
|
+
tracker.recordMouseMove(pointerAnchorNode.x, pointerAnchorNode.y, pointerSectorId);
|
|
469
|
+
}
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const d = dragRef.current;
|
|
474
|
+
const tlx = x - d.dx, tly = y - d.dy;
|
|
475
|
+
|
|
476
|
+
updatePointerAnchor({ x, y }, pointerSectorId);
|
|
477
|
+
|
|
478
|
+
const clamped = clampTopLeftBySupport(
|
|
479
|
+
tlx, tly,
|
|
480
|
+
{ aabb: d.aabb, support: d.support },
|
|
481
|
+
layout,
|
|
482
|
+
controller.state.cfg.target,
|
|
483
|
+
pointerR < layout.innerR
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
let tl = clamped;
|
|
487
|
+
// Snap the *piece itself* to nearest nodes in the active sector (or inner ring),
|
|
488
|
+
// and compute validity (silhouette: within snapRadius; workspace: always valid).
|
|
489
|
+
const centerX = tl.x + d.aabb.width / 2;
|
|
490
|
+
const centerY = tl.y + d.aabb.height / 2;
|
|
491
|
+
const rFromCenter = Math.hypot(centerX - layout.cx, centerY - layout.cy);
|
|
492
|
+
const inInnerRing = rFromCenter < layout.innerR;
|
|
493
|
+
const secId = inInnerRing ? null : sectorIdAt(centerX, centerY);
|
|
494
|
+
let snapNode: { x: number; y: number } | null = null;
|
|
495
|
+
let snapDist: number | null = null;
|
|
496
|
+
let snapSectorId: string | undefined = secId ?? undefined;
|
|
497
|
+
let overlapsDetected = false;
|
|
498
|
+
let maxExceededDetected = false;
|
|
499
|
+
|
|
500
|
+
// Check for inner ring snapping first
|
|
501
|
+
if (inInnerRing) {
|
|
502
|
+
const innerEntry = anchorDots.find(a => a.sectorId === "inner-ring");
|
|
503
|
+
if (innerEntry && innerEntry.valid.length) {
|
|
504
|
+
const piece = pieceById(d.id);
|
|
505
|
+
if (piece) {
|
|
506
|
+
const bp = controller.getBlueprint(piece.blueprintId)!;
|
|
507
|
+
const snap = nearestNodeSnap(tl, bp, controller.getPrimitive, innerEntry.valid);
|
|
508
|
+
tl = snap.tl;
|
|
509
|
+
snapNode = snap.node;
|
|
510
|
+
snapDist = snap.dist;
|
|
511
|
+
snapSectorId = undefined;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
// Inner ring shows RED (invalid) to indicate piece will be deleted
|
|
515
|
+
d.validSnap = false;
|
|
516
|
+
} else if (secId) {
|
|
517
|
+
const isCompletedSector = isSectorLocked(secId);
|
|
518
|
+
const entry = anchorDots.find(a => a.sectorId === secId);
|
|
519
|
+
const allNodes =
|
|
520
|
+
(cfg.target === "workspace" && cfg.mode !== "prep")
|
|
521
|
+
? (entry?.valid ?? []) // Construction workspace: only valid nodes
|
|
522
|
+
: [ ...(entry?.valid ?? []), ...(entry?.invalid ?? []) ]; // Silhouette + Prep: all nodes
|
|
523
|
+
|
|
524
|
+
// Snap to anchors even in completed sectors
|
|
525
|
+
if (allNodes.length) {
|
|
526
|
+
const piece = pieceById(d.id);
|
|
527
|
+
if (piece) {
|
|
528
|
+
const bp = controller.getBlueprint(piece.blueprintId)!;
|
|
529
|
+
const snap = nearestNodeSnap(
|
|
530
|
+
tl,
|
|
531
|
+
bp,
|
|
532
|
+
controller.getPrimitive,
|
|
533
|
+
allNodes
|
|
534
|
+
);
|
|
535
|
+
tl = snap.tl;
|
|
536
|
+
snapNode = snap.node;
|
|
537
|
+
snapDist = snap.dist;
|
|
538
|
+
snapSectorId = secId;
|
|
539
|
+
|
|
540
|
+
// Completed sectors: always show red (invalid)
|
|
541
|
+
if (isCompletedSector) {
|
|
542
|
+
d.validSnap = false;
|
|
543
|
+
} else {
|
|
544
|
+
// Normal validation for incomplete sectors
|
|
545
|
+
const radius = controller.state.cfg.snapRadiusPx;
|
|
546
|
+
// Gather world polys for the carried piece at the *snapped* TL
|
|
547
|
+
const carried = pieceById(d.id);
|
|
548
|
+
let overlapsOk = true;
|
|
549
|
+
if (carried) {
|
|
550
|
+
const bpCar = controller.getBlueprint(carried.blueprintId)!;
|
|
551
|
+
const bbCar = boundsOfBlueprint(
|
|
552
|
+
bpCar,
|
|
553
|
+
controller.getPrimitive
|
|
554
|
+
);
|
|
555
|
+
const carriedPolys = piecePolysAt(bpCar, bbCar, tl);
|
|
556
|
+
|
|
557
|
+
// only compare against pieces in the same sector we're currently over
|
|
558
|
+
const sameSector = pieces.filter(
|
|
559
|
+
pp =>
|
|
560
|
+
pp.id !== d.id &&
|
|
561
|
+
sectorIdAt(
|
|
562
|
+
pp.x +
|
|
563
|
+
boundsOfBlueprint(
|
|
564
|
+
controller.getBlueprint(pp.blueprintId)!,
|
|
565
|
+
controller.getPrimitive
|
|
566
|
+
).width / 2,
|
|
567
|
+
pp.y +
|
|
568
|
+
boundsOfBlueprint(
|
|
569
|
+
controller.getBlueprint(pp.blueprintId)!,
|
|
570
|
+
controller.getPrimitive
|
|
571
|
+
).height / 2,
|
|
572
|
+
) === secId
|
|
573
|
+
);
|
|
574
|
+
|
|
575
|
+
for (const other of sameSector) {
|
|
576
|
+
const bpO = controller.getBlueprint(other.blueprintId)!;
|
|
577
|
+
const bbO = boundsOfBlueprint(
|
|
578
|
+
bpO,
|
|
579
|
+
controller.getPrimitive
|
|
580
|
+
);
|
|
581
|
+
const otherPolys = piecePolysAt(bpO, bbO, { x: other.x, y: other.y });
|
|
582
|
+
if (polysOverlap(carriedPolys, otherPolys)) {
|
|
583
|
+
overlapsOk = false;
|
|
584
|
+
break;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// workspace: validity depends on mode
|
|
589
|
+
if (cfg.target === "workspace") {
|
|
590
|
+
if (cfg.mode === "prep") {
|
|
591
|
+
// Prep mode: ALWAYS allow movement (just show visual feedback)
|
|
592
|
+
// Face-to-face validation only affects visual feedback, not movement
|
|
593
|
+
const allPiecesInSector = controller.getPiecesInSector(secId);
|
|
594
|
+
const otherPiecesInSector = allPiecesInSector.filter(piece => piece.id !== d.id);
|
|
595
|
+
const isFirstPiece = otherPiecesInSector.length === 0;
|
|
596
|
+
const storedPiece = controller.findPiece(d.id);
|
|
597
|
+
const currentSectorId = storedPiece?.sectorId;
|
|
598
|
+
const maxPieces = controller.state.cfg.maxCompositeSize ?? 0;
|
|
599
|
+
const enforceMaxPieces = maxPieces > 0;
|
|
600
|
+
const effectiveCount = allPiecesInSector.length - (currentSectorId === secId ? 1 : 0);
|
|
601
|
+
const wouldExceedMax = enforceMaxPieces && (effectiveCount + 1 > maxPieces);
|
|
602
|
+
|
|
603
|
+
if (isFirstPiece) {
|
|
604
|
+
// First piece: visual feedback based on overlaps and max-piece limit
|
|
605
|
+
d.validSnap = overlapsOk && !wouldExceedMax;
|
|
606
|
+
maxExceededDetected = wouldExceedMax;
|
|
607
|
+
} else if (storedPiece) {
|
|
608
|
+
// Subsequent pieces: visual feedback based on face-to-face attachment
|
|
609
|
+
const pieceAtDragPosition = {
|
|
610
|
+
...storedPiece,
|
|
611
|
+
pos: { x: tl.x, y: tl.y }
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
const attachmentResult = checkFaceToFaceAttachment(
|
|
615
|
+
pieceAtDragPosition,
|
|
616
|
+
otherPiecesInSector,
|
|
617
|
+
(id) => controller.getBlueprint(id),
|
|
618
|
+
(kind) => controller.getPrimitive(kind)
|
|
619
|
+
);
|
|
620
|
+
|
|
621
|
+
d.validSnap = overlapsOk && attachmentResult.isAttached && !wouldExceedMax;
|
|
622
|
+
maxExceededDetected = wouldExceedMax;
|
|
623
|
+
} else {
|
|
624
|
+
d.validSnap = overlapsOk && !wouldExceedMax;
|
|
625
|
+
maxExceededDetected = wouldExceedMax;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// IMPORTANT: In prep mode, always allow movement regardless of validSnap
|
|
629
|
+
// validSnap is only for visual feedback (red/blue coloring)
|
|
630
|
+
} else {
|
|
631
|
+
// Construction mode: original behavior (validSnap blocks movement)
|
|
632
|
+
d.validSnap = overlapsOk;
|
|
633
|
+
maxExceededDetected = false;
|
|
634
|
+
}
|
|
635
|
+
overlapsDetected = !overlapsOk;
|
|
636
|
+
} else {
|
|
637
|
+
// silhouette: node must be valid AND fully inside AND !overlap
|
|
638
|
+
const key = (p: { x: number; y: number }) => `${p.x},${p.y}`;
|
|
639
|
+
const validSet = new Set((entry?.valid ?? []).map(key));
|
|
640
|
+
const nodeOk =
|
|
641
|
+
!!snap.node && validSet.has(key(snap.node)) && snap.dist <= (radius ?? 0);
|
|
642
|
+
let insideOk = false;
|
|
643
|
+
if (nodeOk) {
|
|
644
|
+
const silPolys = placedSilBySector.get(secId) ?? [];
|
|
645
|
+
insideOk = polyFullyInside(carriedPolys, silPolys);
|
|
646
|
+
}
|
|
647
|
+
d.validSnap = nodeOk && insideOk && overlapsOk;
|
|
648
|
+
overlapsDetected = !overlapsOk;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
} else {
|
|
655
|
+
// not over any sector band
|
|
656
|
+
d.validSnap = (controller.state.cfg.target === "workspace");
|
|
657
|
+
snapNode = null;
|
|
658
|
+
snapDist = null;
|
|
659
|
+
snapSectorId = undefined;
|
|
660
|
+
maxExceededDetected = false;
|
|
661
|
+
}
|
|
662
|
+
d.overlaps = overlapsDetected;
|
|
663
|
+
d.maxExceeded = maxExceededDetected;
|
|
664
|
+
updateSnapAnchor(snapNode, snapSectorId, snapDist ?? undefined);
|
|
665
|
+
d.tlx = tl.x; d.tly = tl.y;
|
|
666
|
+
|
|
667
|
+
// If the validity state changed, force a React render so fill/stroke update immediately.
|
|
668
|
+
if (d.prevValidSnap !== d.validSnap) {
|
|
669
|
+
d.prevValidSnap = d.validSnap ?? false;
|
|
670
|
+
force();
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
scheduleFrame();
|
|
674
|
+
};
|
|
675
|
+
|
|
676
|
+
const onPointerUp = () => {
|
|
677
|
+
if (!dragRef.current) return;
|
|
678
|
+
if (clickMode) return;
|
|
679
|
+
|
|
680
|
+
const d = dragRef.current;
|
|
681
|
+
// If the candidate is invalid for any reason (overlap/outside/bad node), handle based on mode
|
|
682
|
+
if (d.validSnap === false) {
|
|
683
|
+
const piece = controller.findPiece(d.id);
|
|
684
|
+
const cfg = controller.state.cfg;
|
|
685
|
+
const centerX = d.tlx + d.aabb.width / 2;
|
|
686
|
+
const centerY = d.tly + d.aabb.height / 2;
|
|
687
|
+
const attemptedSector = sectorIdAt(centerX, centerY);
|
|
688
|
+
let invalidReason: 'overlapping' | 'outside_bounds' | 'no_valid_anchor' | 'sector_complete' = 'outside_bounds';
|
|
689
|
+
if (d.maxExceeded) {
|
|
690
|
+
invalidReason = 'no_valid_anchor';
|
|
691
|
+
} else if (d.overlaps) {
|
|
692
|
+
invalidReason = 'overlapping';
|
|
693
|
+
} else if (attemptedSector) {
|
|
694
|
+
invalidReason = 'no_valid_anchor';
|
|
695
|
+
}
|
|
696
|
+
emitClickEvent(
|
|
697
|
+
{ x: centerX, y: centerY },
|
|
698
|
+
'invalid_placement',
|
|
699
|
+
{ invalidPlacement: { reason: invalidReason, attemptedSectorId: attemptedSector ?? undefined } }
|
|
700
|
+
);
|
|
701
|
+
|
|
702
|
+
// In prep mode: return to original position (if exists) or delete (if from icon)
|
|
703
|
+
if (cfg.mode === "prep" && d.originalPos) {
|
|
704
|
+
// Return piece to its original position
|
|
705
|
+
controller.move(d.id, { x: d.originalPos.x, y: d.originalPos.y });
|
|
706
|
+
|
|
707
|
+
// Record placedown as "placed" back in original position
|
|
708
|
+
if (tracker) {
|
|
709
|
+
tracker.recordPlacedown(
|
|
710
|
+
'placed',
|
|
711
|
+
d.originalPos.sectorId,
|
|
712
|
+
{ x: d.originalPos.x, y: d.originalPos.y },
|
|
713
|
+
getPieceVertices(d.id),
|
|
714
|
+
d.snapAnchorId ?? undefined,
|
|
715
|
+
false,
|
|
716
|
+
d.overlaps ?? false
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
} else {
|
|
720
|
+
// Construction mode or fromIcon in prep mode: delete piece
|
|
721
|
+
const removedFromSector = piece?.sectorId;
|
|
722
|
+
controller.remove(d.id);
|
|
723
|
+
// Re-check completion for the sector the piece was removed from
|
|
724
|
+
if (removedFromSector) maybeCompleteSector(removedFromSector);
|
|
725
|
+
|
|
726
|
+
// Record placedown as "deleted"
|
|
727
|
+
if (tracker) {
|
|
728
|
+
tracker.recordPlacedown(
|
|
729
|
+
'deleted',
|
|
730
|
+
removedFromSector,
|
|
731
|
+
undefined,
|
|
732
|
+
undefined,
|
|
733
|
+
d.snapAnchorId ?? undefined,
|
|
734
|
+
false,
|
|
735
|
+
d.overlaps ?? false
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
dragRef.current = null;
|
|
741
|
+
setDraggingId(null);
|
|
742
|
+
force();
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
dragRef.current = null;
|
|
747
|
+
if (d.raf) { cancelAnimationFrame(d.raf); d.raf = null; }
|
|
748
|
+
|
|
749
|
+
const commitTL = { x: d.tlx, y: d.tly };
|
|
750
|
+
const centerX = commitTL.x + d.aabb.width / 2;
|
|
751
|
+
const centerY = commitTL.y + d.aabb.height / 2;
|
|
752
|
+
|
|
753
|
+
// delete if inside the inner hole
|
|
754
|
+
const rFromCenter = Math.hypot(centerX - layout.cx, centerY - layout.cy);
|
|
755
|
+
if (rFromCenter < layout.innerR) {
|
|
756
|
+
const piece = controller.findPiece(d.id);
|
|
757
|
+
const removedFromSector = piece?.sectorId;
|
|
758
|
+
controller.remove(d.id);
|
|
759
|
+
// Re-check completion for the sector the piece was removed from
|
|
760
|
+
if (removedFromSector) maybeCompleteSector(removedFromSector);
|
|
761
|
+
|
|
762
|
+
// Record placedown as "deleted"
|
|
763
|
+
if (tracker) {
|
|
764
|
+
tracker.recordPlacedown(
|
|
765
|
+
'deleted',
|
|
766
|
+
removedFromSector,
|
|
767
|
+
undefined,
|
|
768
|
+
undefined,
|
|
769
|
+
d.snapAnchorId ?? undefined,
|
|
770
|
+
false,
|
|
771
|
+
d.overlaps ?? false
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
setDraggingId(null);
|
|
776
|
+
force();
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
const secId = sectorIdAt(centerX, centerY);
|
|
781
|
+
|
|
782
|
+
// Completed sector: no-op (do nothing, same as clicking incomplete sector in silhouette mode)
|
|
783
|
+
if (secId && isSectorLocked(secId)) {
|
|
784
|
+
emitClickEvent(
|
|
785
|
+
{ x: centerX, y: centerY },
|
|
786
|
+
'sector_complete_attempt',
|
|
787
|
+
{ invalidPlacement: { reason: 'sector_complete', attemptedSectorId: secId } }
|
|
788
|
+
);
|
|
789
|
+
// Return piece to original position if it exists, otherwise delete (from icon spawn)
|
|
790
|
+
if (d.originalPos) {
|
|
791
|
+
controller.move(d.id, { x: d.originalPos.x, y: d.originalPos.y });
|
|
792
|
+
if (d.originalPos.sectorId) {
|
|
793
|
+
controller.drop(d.id, d.originalPos.sectorId);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// Record placedown as "placed" back in original position
|
|
797
|
+
if (tracker) {
|
|
798
|
+
tracker.recordPlacedown(
|
|
799
|
+
'placed',
|
|
800
|
+
d.originalPos.sectorId,
|
|
801
|
+
{ x: d.originalPos.x, y: d.originalPos.y },
|
|
802
|
+
getPieceVertices(d.id),
|
|
803
|
+
d.snapAnchorId ?? undefined,
|
|
804
|
+
false,
|
|
805
|
+
d.overlaps ?? false
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
} else {
|
|
809
|
+
// Piece was spawned from icon and dropped in completed sector → delete
|
|
810
|
+
controller.remove(d.id);
|
|
811
|
+
|
|
812
|
+
// Record placedown as "deleted"
|
|
813
|
+
if (tracker) {
|
|
814
|
+
tracker.recordPlacedown(
|
|
815
|
+
'deleted',
|
|
816
|
+
undefined,
|
|
817
|
+
undefined,
|
|
818
|
+
undefined,
|
|
819
|
+
d.snapAnchorId ?? undefined,
|
|
820
|
+
false,
|
|
821
|
+
d.overlaps ?? false
|
|
822
|
+
);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
setDraggingId(null);
|
|
826
|
+
force();
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
controller.move(d.id, commitTL);
|
|
831
|
+
controller.drop(d.id, secId);
|
|
832
|
+
|
|
833
|
+
const justCompleted = secId ? maybeCompleteSector(secId) : false;
|
|
834
|
+
|
|
835
|
+
// Record placedown as "placed" successfully
|
|
836
|
+
const wasValid = d.validSnap === undefined ? true : d.validSnap;
|
|
837
|
+
if (tracker) {
|
|
838
|
+
tracker.recordPlacedown(
|
|
839
|
+
'placed',
|
|
840
|
+
secId,
|
|
841
|
+
commitTL,
|
|
842
|
+
getPieceVertices(d.id),
|
|
843
|
+
d.snapAnchorId ?? undefined,
|
|
844
|
+
wasValid,
|
|
845
|
+
d.overlaps ?? false,
|
|
846
|
+
justCompleted
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
setDraggingId(null);
|
|
851
|
+
|
|
852
|
+
// auto-revert AFTER completing a drag spawn from the center
|
|
853
|
+
if (d.fromIcon && controller.state.blueprintView === "primitives") {
|
|
854
|
+
controller.switchBlueprintView();
|
|
855
|
+
}
|
|
856
|
+
force();
|
|
857
|
+
};
|
|
858
|
+
|
|
859
|
+
const onBlueprintPointerDown = (
|
|
860
|
+
e: React.PointerEvent,
|
|
861
|
+
bp: Blueprint,
|
|
862
|
+
bpGeom: { bx: number; by: number; cx: number; cy: number }
|
|
863
|
+
) => {
|
|
864
|
+
if (clickMode && draggingId) return;
|
|
865
|
+
e.stopPropagation();
|
|
866
|
+
|
|
867
|
+
const { x: px, y: py } = svgPoint(e.clientX, e.clientY);
|
|
868
|
+
const bb = boundsOfBlueprint(bp, controller.getPrimitive);
|
|
869
|
+
const support = computeSupportOffsets(bp, bb);
|
|
870
|
+
|
|
871
|
+
if (clickMode) {
|
|
872
|
+
// Click mode: spawn then enter carry state
|
|
873
|
+
|
|
874
|
+
// Compute the local point of the blueprint glyph that was clicked
|
|
875
|
+
const q = blueprintLocalFromWorld(px, py, bpGeom);
|
|
876
|
+
// Compute TL so that the clicked point stays under the cursor
|
|
877
|
+
const tl0 = { x: px - (q.x - bb.min.x), y: py - (q.y - bb.min.y) };
|
|
878
|
+
const pointerR = Math.hypot(px - layout.cx, py - layout.cy);
|
|
879
|
+
const allowInside = pointerR < layout.innerR;
|
|
880
|
+
const clamped = clampTopLeftBySupport(
|
|
881
|
+
tl0.x, tl0.y,
|
|
882
|
+
{ aabb: bb, support },
|
|
883
|
+
layout,
|
|
884
|
+
controller.state.cfg.target,
|
|
885
|
+
allowInside
|
|
886
|
+
);
|
|
887
|
+
const pieceId = controller.spawnFromBlueprint(bp, clamped);
|
|
888
|
+
const pointerAnchorNode = anchorNodeFromPoint({ x: px, y: py });
|
|
889
|
+
const pointerSector = sectorIdAt(px, py) ?? undefined;
|
|
890
|
+
if (tracker?.recordMouseMove) {
|
|
891
|
+
tracker.recordMouseMove(pointerAnchorNode.x, pointerAnchorNode.y, pointerSector);
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// Record pickup event for blueprint spawn
|
|
895
|
+
if (tracker) {
|
|
896
|
+
const blueprintType = ('kind' in bp) ? 'primitive' : 'composite';
|
|
897
|
+
tracker.recordPickup(
|
|
898
|
+
pieceId,
|
|
899
|
+
bp.id,
|
|
900
|
+
blueprintType,
|
|
901
|
+
'blueprint',
|
|
902
|
+
clamped,
|
|
903
|
+
getPieceVertices(pieceId),
|
|
904
|
+
undefined
|
|
905
|
+
);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
const tl = clamped;
|
|
909
|
+
// If anchors mode, immediately snap initial TL to the nearest nodes in the active sector
|
|
910
|
+
let finalTl = tl;
|
|
911
|
+
let snapInfo: { node: { x: number; y: number } | null; dist: number } | null = null;
|
|
912
|
+
const centerX = tl.x + bb.width / 2;
|
|
913
|
+
const centerY = tl.y + bb.height / 2;
|
|
914
|
+
const secId = sectorIdAt(centerX, centerY);
|
|
915
|
+
if (secId) {
|
|
916
|
+
const entry = anchorDots.find(a => a.sectorId === secId);
|
|
917
|
+
const nodes =
|
|
918
|
+
controller.state.cfg.target === "workspace"
|
|
919
|
+
? (entry?.valid ?? [])
|
|
920
|
+
: [ ...(entry?.valid ?? []), ...(entry?.invalid ?? []) ];
|
|
921
|
+
if (nodes.length) {
|
|
922
|
+
const snap = nearestNodeSnap(tl, bp, controller.getPrimitive, nodes);
|
|
923
|
+
finalTl = snap.tl;
|
|
924
|
+
snapInfo = { node: snap.node, dist: snap.dist };
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
controller.move(pieceId, finalTl);
|
|
929
|
+
dragRef.current = {
|
|
930
|
+
id: pieceId,
|
|
931
|
+
dx: px - finalTl.x,
|
|
932
|
+
dy: py - finalTl.y,
|
|
933
|
+
tlx: finalTl.x,
|
|
934
|
+
tly: finalTl.y,
|
|
935
|
+
aabb: bb,
|
|
936
|
+
minx: bb.min.x,
|
|
937
|
+
miny: bb.min.y,
|
|
938
|
+
support,
|
|
939
|
+
fromIcon: true,
|
|
940
|
+
validSnap: true,
|
|
941
|
+
prevValidSnap: true,
|
|
942
|
+
raf: null,
|
|
943
|
+
originalPos: undefined, // No original position for fromIcon drags
|
|
944
|
+
pointerAnchor: pointerAnchorNode,
|
|
945
|
+
pointerAnchorId: anchorIdFromNode(pointerAnchorNode),
|
|
946
|
+
pointerAnchorSectorId: pointerSector,
|
|
947
|
+
snapAnchor: null,
|
|
948
|
+
snapAnchorId: null,
|
|
949
|
+
snapAnchorSectorId: undefined,
|
|
950
|
+
snapAnchorDist: null,
|
|
951
|
+
overlaps: false,
|
|
952
|
+
maxExceeded: false,
|
|
953
|
+
};
|
|
954
|
+
updatePointerAnchor({ x: px, y: py }, pointerSector);
|
|
955
|
+
const snappingSecId = sectorIdAt(finalTl.x + bb.width / 2, finalTl.y + bb.height / 2) ?? undefined;
|
|
956
|
+
updateSnapAnchor(snapInfo?.node ?? null, snappingSecId, snapInfo?.dist);
|
|
957
|
+
setDraggingId(pieceId); // now onPointerMove will keep it in sync
|
|
958
|
+
force();
|
|
959
|
+
scheduleFrame();
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// Drag mode: spawn at pointer with immediate drag
|
|
964
|
+
svgRef.current?.setPointerCapture(e.pointerId);
|
|
965
|
+
|
|
966
|
+
// Compute the local point of the blueprint glyph that was clicked
|
|
967
|
+
const q = blueprintLocalFromWorld(px, py, bpGeom);
|
|
968
|
+
// Compute TL so that the clicked point stays under the cursor
|
|
969
|
+
const tl0 = { x: px - (q.x - bb.min.x), y: py - (q.y - bb.min.y) };
|
|
970
|
+
const pointerR = Math.hypot(px - layout.cx, py - layout.cy);
|
|
971
|
+
const allowInside = pointerR < layout.innerR;
|
|
972
|
+
const clamped = clampTopLeftBySupport(
|
|
973
|
+
tl0.x, tl0.y,
|
|
974
|
+
{ aabb: bb, support },
|
|
975
|
+
layout,
|
|
976
|
+
controller.state.cfg.target,
|
|
977
|
+
allowInside
|
|
978
|
+
);
|
|
979
|
+
const pieceId = controller.spawnFromBlueprint(bp, clamped);
|
|
980
|
+
const pointerAnchorNode = anchorNodeFromPoint({ x: px, y: py });
|
|
981
|
+
const pointerSector = sectorIdAt(px, py) ?? undefined;
|
|
982
|
+
if (tracker?.recordMouseMove) {
|
|
983
|
+
tracker.recordMouseMove(pointerAnchorNode.x, pointerAnchorNode.y, pointerSector);
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// Record pickup event for blueprint spawn
|
|
987
|
+
if (tracker) {
|
|
988
|
+
const blueprintType = ('kind' in bp) ? 'primitive' : 'composite';
|
|
989
|
+
tracker.recordPickup(
|
|
990
|
+
pieceId,
|
|
991
|
+
bp.id,
|
|
992
|
+
blueprintType,
|
|
993
|
+
'blueprint',
|
|
994
|
+
clamped,
|
|
995
|
+
getPieceVertices(pieceId),
|
|
996
|
+
undefined
|
|
997
|
+
);
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
dragRef.current = {
|
|
1001
|
+
id: pieceId,
|
|
1002
|
+
dx: px - clamped.x,
|
|
1003
|
+
dy: py - clamped.y,
|
|
1004
|
+
tlx: clamped.x,
|
|
1005
|
+
tly: clamped.y,
|
|
1006
|
+
aabb: bb,
|
|
1007
|
+
minx: bb.min.x,
|
|
1008
|
+
miny: bb.min.y,
|
|
1009
|
+
support,
|
|
1010
|
+
fromIcon: true,
|
|
1011
|
+
validSnap: true,
|
|
1012
|
+
prevValidSnap: true,
|
|
1013
|
+
raf: null,
|
|
1014
|
+
originalPos: undefined, // No original position for fromIcon drags
|
|
1015
|
+
pointerAnchor: pointerAnchorNode,
|
|
1016
|
+
pointerAnchorId: anchorIdFromNode(pointerAnchorNode),
|
|
1017
|
+
pointerAnchorSectorId: pointerSector,
|
|
1018
|
+
snapAnchor: null,
|
|
1019
|
+
snapAnchorId: null,
|
|
1020
|
+
snapAnchorSectorId: undefined,
|
|
1021
|
+
snapAnchorDist: null,
|
|
1022
|
+
overlaps: false,
|
|
1023
|
+
};
|
|
1024
|
+
updatePointerAnchor({ x: px, y: py }, pointerSector);
|
|
1025
|
+
updateSnapAnchor(null, undefined);
|
|
1026
|
+
setDraggingId(pieceId);
|
|
1027
|
+
force();
|
|
1028
|
+
scheduleFrame();
|
|
1029
|
+
};
|
|
1030
|
+
|
|
1031
|
+
return {
|
|
1032
|
+
// State
|
|
1033
|
+
draggingId,
|
|
1034
|
+
dragInvalid: !!(dragRef.current && dragRef.current.validSnap === false),
|
|
1035
|
+
lockedPieceId,
|
|
1036
|
+
svgRef,
|
|
1037
|
+
|
|
1038
|
+
// Handlers
|
|
1039
|
+
onPiecePointerDown,
|
|
1040
|
+
onBlueprintPointerDown,
|
|
1041
|
+
onPointerMove,
|
|
1042
|
+
onPointerUp,
|
|
1043
|
+
setPieceRef,
|
|
1044
|
+
|
|
1045
|
+
// Click controller integration
|
|
1046
|
+
setDraggingId,
|
|
1047
|
+
|
|
1048
|
+
// TODO: Remove this once click mode is extracted - temporary for compatibility
|
|
1049
|
+
dragRef
|
|
1050
|
+
};
|
|
1051
|
+
}
|