castle-web-cli 0.4.71 → 0.4.73
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/dist/agent-prompts.js +22 -3
- package/dist/agent.js +731 -313
- package/dist/init.js +1 -1
- package/dist/shell/assets/index-Dfn29Bkt.js +108 -0
- package/dist/shell/assets/{index-CVEnWuGV.css → index-WNbOHPBj.css} +1 -1
- package/dist/shell/index.html +2 -2
- package/dist/vitePlugins.js +3 -2
- package/kits/basic-2d/CLAUDE.md +29 -8
- package/kits/basic-2d/behaviors/Collider.jsx +6 -4
- package/kits/basic-2d/behaviors/Layout.jsx +2 -2
- package/kits/basic-2d/behaviors/Sprite.jsx +210 -0
- package/kits/basic-2d/behaviors/tint.js +47 -0
- package/kits/basic-2d/docs/pxart-format.md +298 -0
- package/kits/basic-2d/drawings/pig.pxart +59 -0
- package/kits/basic-2d/editors/App.jsx +125 -76
- package/kits/basic-2d/editors/CodeEditor.jsx +9 -45
- package/kits/basic-2d/editors/FileBrowser.jsx +234 -47
- package/kits/basic-2d/editors/PlayOnly.jsx +9 -7
- package/kits/basic-2d/editors/PxArtEditor.jsx +662 -0
- package/kits/basic-2d/editors/SceneEditor.jsx +587 -221
- package/kits/basic-2d/editors/SelectionOverlay.jsx +808 -0
- package/kits/basic-2d/editors/SingleEditor.jsx +38 -20
- package/kits/basic-2d/editors/codeTheme.js +135 -0
- package/kits/basic-2d/editors/editorHistory.js +44 -17
- package/kits/basic-2d/editors/inspectorSheet.js +23 -0
- package/kits/basic-2d/editors/pixelCanvas.js +11 -0
- package/kits/basic-2d/editors/pixelEditorChrome.jsx +55 -0
- package/kits/basic-2d/editors/pixelGeometry.js +45 -0
- package/kits/basic-2d/editors/pixelInspector.jsx +416 -0
- package/kits/basic-2d/editors/pxArtEditorModel.js +718 -0
- package/kits/basic-2d/editors/pxArtPlayback.js +92 -0
- package/kits/basic-2d/editors/pxArtTimeline.jsx +752 -0
- package/kits/basic-2d/editors/pxArtTimeline.module.css +506 -0
- package/kits/basic-2d/editors/pxArtTools.js +124 -0
- package/kits/basic-2d/editors/useArtboardFit.js +102 -0
- package/kits/basic-2d/engine/ScenePlayer.jsx +10 -4
- package/kits/basic-2d/engine/SceneUI.jsx +3 -11
- package/kits/basic-2d/engine/assets.js +15 -0
- package/kits/basic-2d/engine/files.js +57 -2
- package/kits/basic-2d/engine/pxart.js +985 -0
- package/kits/basic-2d/engine/scene.js +222 -41
- package/kits/basic-2d/engine/ui.jsx +155 -26
- package/kits/basic-2d/engine/ui.module.css +1280 -344
- package/kits/basic-2d/eslint.config.js +21 -0
- package/kits/basic-2d/index.html +13 -0
- package/kits/basic-2d/package.json +1 -0
- package/kits/basic-2d/pnpm-lock.yaml +5 -5
- package/kits/basic-2d/scenes/main.scene +19 -26
- package/kits/basic-2d/scripts/draw.mjs +121 -0
- package/kits/basic-3d/editors/PlayOnly.jsx +9 -1
- package/kits/basic-3d/engine/ScenePlayer.jsx +7 -1
- package/package.json +1 -1
- package/dist/shell/assets/index-BY21Og40.js +0 -106
- package/kits/basic-2d/behaviors/Drawing.jsx +0 -142
- package/kits/basic-2d/drawings/block.drawing +0 -70
- package/kits/basic-2d/drawings/default.drawing +0 -70
- package/kits/basic-2d/editors/DrawingEditor.jsx +0 -224
|
@@ -0,0 +1,808 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { getColliderRect } from '../behaviors/Collider';
|
|
3
|
+
import { cardSize, screenToCard } from '../engine/scene';
|
|
4
|
+
import { cx, Icon, styles, useElementSize } from '../engine/ui';
|
|
5
|
+
|
|
6
|
+
// On-canvas selection toolbar ("selection handles"). Mounts inside `.stageCard`
|
|
7
|
+
// as a sibling of the `<canvas>` and mirrors the `SceneUI` overlay approach:
|
|
8
|
+
//
|
|
9
|
+
// - A ResizeObserver measures the canvas-sized box so a card-unit (500x700)
|
|
10
|
+
// layer can be `scale()`d onto it -- the chrome (dashed box, corner dots,
|
|
11
|
+
// center pivot) lives in card units inside that scaled layer and is
|
|
12
|
+
// translated by -camera so it tracks the edit camera exactly like the canvas
|
|
13
|
+
// draw path (`ctx.translate(-camera.x, -camera.y)`).
|
|
14
|
+
// - The floating buttons live in a separate, un-scaled px layer. Their
|
|
15
|
+
// positions are projected from world card units into screen px, so they stay
|
|
16
|
+
// crisp and fixed-size with fixed px gaps regardless of the responsive card
|
|
17
|
+
// scale (the alternative -- buttons inside the scaled layer -- would distort
|
|
18
|
+
// them and make gaps scale).
|
|
19
|
+
//
|
|
20
|
+
// Clone/Delete reuse the existing duplicate/remove scene actions; Move and
|
|
21
|
+
// Rotate are pointer drags that translate / rotate every selected actor and
|
|
22
|
+
// commit one history entry per gesture.
|
|
23
|
+
|
|
24
|
+
const HANDLE_OFFSET = 8; // card units between the real bounds and transform handles
|
|
25
|
+
const GAP_PX = 30; // px from a box edge to a floating button's center
|
|
26
|
+
const ACTION_GAP_PX = 22; // px from a box edge to clone/delete buttons
|
|
27
|
+
const ROTATE_STEP = 15; // degrees -- rotation snaps to this increment when grid snap is on
|
|
28
|
+
const ROTATE_MAGNET = 7; // degrees -- within this band of the pre-drag angle, snap back to it exactly
|
|
29
|
+
const MIN_LAYOUT_SIZE = 4; // card units; prevents inverted / zero-size Layout boxes
|
|
30
|
+
// Move/grab handle is intentionally hidden for now (its drag logic + helpers
|
|
31
|
+
// stay wired below so it can be re-enabled later); flip this to render it.
|
|
32
|
+
const SHOW_MOVE_HANDLE = false;
|
|
33
|
+
const ARRANGE_OPTIONS = [
|
|
34
|
+
{ label: 'Bring to Front', action: 'front' },
|
|
35
|
+
{ label: 'Bring Forward', action: 'forward' },
|
|
36
|
+
{ label: 'Send Backward', action: 'backward' },
|
|
37
|
+
{ label: 'Send to Back', action: 'back' },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const SCALE_HANDLES = [
|
|
41
|
+
{ id: 'nw', x: -1, y: -1, cursor: 'nwse-resize', corner: true },
|
|
42
|
+
{ id: 'n', x: 0, y: -1, cursor: 'ns-resize' },
|
|
43
|
+
{ id: 'ne', x: 1, y: -1, cursor: 'nesw-resize', corner: true },
|
|
44
|
+
{ id: 'e', x: 1, y: 0, cursor: 'ew-resize' },
|
|
45
|
+
{ id: 'se', x: 1, y: 1, cursor: 'nwse-resize', corner: true },
|
|
46
|
+
{ id: 's', x: 0, y: 1, cursor: 'ns-resize' },
|
|
47
|
+
{ id: 'sw', x: -1, y: 1, cursor: 'nesw-resize', corner: true },
|
|
48
|
+
{ id: 'w', x: -1, y: 0, cursor: 'ew-resize' },
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
export function SelectionOverlay({
|
|
52
|
+
canvasRef,
|
|
53
|
+
editCameraRef,
|
|
54
|
+
sceneData,
|
|
55
|
+
selectedActorIds,
|
|
56
|
+
snap,
|
|
57
|
+
onArrange,
|
|
58
|
+
onClone,
|
|
59
|
+
onDelete,
|
|
60
|
+
applyScene,
|
|
61
|
+
recordSnapshot,
|
|
62
|
+
}) {
|
|
63
|
+
const rootRef = useRef(null);
|
|
64
|
+
// Track the canvas-sized overlay box so the card-unit layer scales onto it.
|
|
65
|
+
const box = useElementSize(rootRef);
|
|
66
|
+
const [camera, setCamera] = useState({ x: 0, y: 0 });
|
|
67
|
+
const [groupFrameRotation, setGroupFrameRotation] = useState(null);
|
|
68
|
+
const [arrangeOpen, setArrangeOpen] = useState(false);
|
|
69
|
+
const selectionKey = [...selectedActorIds].sort().join('|');
|
|
70
|
+
|
|
71
|
+
// Follow the edit camera on a RAF tick so the toolbar pans with the canvas.
|
|
72
|
+
// setState is skipped when the camera is unchanged, so this idles cheaply.
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
let raf = 0;
|
|
75
|
+
const tick = () => {
|
|
76
|
+
const cam = editCameraRef.current ?? { x: 0, y: 0 };
|
|
77
|
+
setCamera((prev) => (prev.x === cam.x && prev.y === cam.y ? prev : { x: cam.x, y: cam.y }));
|
|
78
|
+
raf = requestAnimationFrame(tick);
|
|
79
|
+
};
|
|
80
|
+
raf = requestAnimationFrame(tick);
|
|
81
|
+
return () => cancelAnimationFrame(raf);
|
|
82
|
+
}, [editCameraRef]);
|
|
83
|
+
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
setGroupFrameRotation(null);
|
|
86
|
+
setArrangeOpen(false);
|
|
87
|
+
}, [selectionKey]);
|
|
88
|
+
|
|
89
|
+
const frame = getSelectionFrame(sceneData, selectedActorIds, groupFrameRotation);
|
|
90
|
+
|
|
91
|
+
const onMoveDown = usePointerDragHandle((event) => {
|
|
92
|
+
const canvas = canvasRef.current;
|
|
93
|
+
if (!canvas || !sceneData || selectedActorIds.length === 0) return null;
|
|
94
|
+
const startPoint = screenToCard(canvas, event.clientX, event.clientY);
|
|
95
|
+
const starts = collectLayoutStarts(sceneData, selectedActorIds);
|
|
96
|
+
const state = { recorded: false };
|
|
97
|
+
return {
|
|
98
|
+
onMove: (moveEvent) => {
|
|
99
|
+
const point = screenToCard(canvas, moveEvent.clientX, moveEvent.clientY);
|
|
100
|
+
const dx = snapDelta(point.x - startPoint.x, snap);
|
|
101
|
+
const dy = snapDelta(point.y - startPoint.y, snap);
|
|
102
|
+
const next = moveSelected(sceneData, selectedActorIds, starts, dx, dy);
|
|
103
|
+
applyDragResult(next, sceneData, state, recordSnapshot, applyScene);
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const onRotateDown = usePointerDragHandle((event) => {
|
|
109
|
+
const canvas = canvasRef.current;
|
|
110
|
+
if (!canvas || !sceneData || !frame || selectedActorIds.length === 0) return null;
|
|
111
|
+
const center = { x: frame.x + frame.width / 2, y: frame.y + frame.height / 2 };
|
|
112
|
+
const startAngle = pointerAngle(canvas, editCameraRef, event, center);
|
|
113
|
+
let lastAngle = startAngle;
|
|
114
|
+
let totalDelta = 0;
|
|
115
|
+
const startFrameRotation = frame.rotation;
|
|
116
|
+
const starts = collectLayoutStarts(sceneData, selectedActorIds);
|
|
117
|
+
const state = { recorded: false, lastDelta: 0 };
|
|
118
|
+
return {
|
|
119
|
+
onMove: (moveEvent) => {
|
|
120
|
+
const angle = pointerAngle(canvas, editCameraRef, moveEvent, center);
|
|
121
|
+
totalDelta += shortestAngleDelta(lastAngle, angle);
|
|
122
|
+
lastAngle = angle;
|
|
123
|
+
const delta = snapRotationDelta(totalDelta, snap?.enabled ?? true);
|
|
124
|
+
// Key the commit on the snapped delta itself, not on a diff against the
|
|
125
|
+
// pointer-down snapshot. `onMove` closes over the `sceneData` captured at
|
|
126
|
+
// drag-start, where the actor still sits at its original angle, so when
|
|
127
|
+
// the magnet pulls `delta` back to 0 `rotateSelected` returns that
|
|
128
|
+
// unchanged snapshot. `applyDragResult`'s `next === sceneData` guard
|
|
129
|
+
// would treat that as a no-op and skip it, stranding the actor on the
|
|
130
|
+
// last grid step -- the reason returning to the original angle felt
|
|
131
|
+
// impossible. Re-applying whenever the delta changes lets delta 0
|
|
132
|
+
// actually restore the start angle.
|
|
133
|
+
if (delta === state.lastDelta) return;
|
|
134
|
+
state.lastDelta = delta;
|
|
135
|
+
setGroupFrameRotation(normalizeAngle(startFrameRotation + delta));
|
|
136
|
+
const next = rotateSelected(sceneData, selectedActorIds, starts, center, delta);
|
|
137
|
+
if (!state.recorded) {
|
|
138
|
+
recordSnapshot();
|
|
139
|
+
state.recorded = true;
|
|
140
|
+
}
|
|
141
|
+
applyScene(next);
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const onScaleDown = usePointerDragHandle((event) => {
|
|
147
|
+
const handleId = event.currentTarget.dataset.handle;
|
|
148
|
+
const handle = SCALE_HANDLES.find((candidate) => candidate.id === handleId);
|
|
149
|
+
const canvas = canvasRef.current;
|
|
150
|
+
if (!handle || !canvas || !sceneData || !frame || selectedActorIds.length === 0) return null;
|
|
151
|
+
const startTransform = makeBoundsTransform(frame, frame.rotation);
|
|
152
|
+
const startPointer = pointerLocal(canvas, editCameraRef, event, startTransform);
|
|
153
|
+
const starts = collectLayoutStarts(sceneData, selectedActorIds);
|
|
154
|
+
const state = { recorded: false };
|
|
155
|
+
return {
|
|
156
|
+
onMove: (moveEvent) => {
|
|
157
|
+
const point = pointerLocal(canvas, editCameraRef, moveEvent, startTransform);
|
|
158
|
+
const dx = snapDelta(point.x - startPointer.x, snap);
|
|
159
|
+
const dy = snapDelta(point.y - startPointer.y, snap);
|
|
160
|
+
const nextBounds = scaleBoundsFromHandle(
|
|
161
|
+
startTransform.localBounds,
|
|
162
|
+
handle,
|
|
163
|
+
dx,
|
|
164
|
+
dy,
|
|
165
|
+
moveEvent.shiftKey
|
|
166
|
+
);
|
|
167
|
+
const next = scaleSelected(sceneData, selectedActorIds, starts, startTransform, nextBounds);
|
|
168
|
+
applyDragResult(next, sceneData, state, recordSnapshot, applyScene);
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
if (!frame || box.width === 0 || box.height === 0) {
|
|
174
|
+
// Keep the root mounted so the ResizeObserver always has a node to measure.
|
|
175
|
+
return <div ref={rootRef} className={styles.selOverlayRoot} aria-hidden="true" />;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const geometry = getOverlayGeometry(frame, box, camera);
|
|
179
|
+
const colliderFrames = getSelectedColliderFrames(sceneData, selectedActorIds);
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<div ref={rootRef} className={styles.selOverlayRoot}>
|
|
183
|
+
<div
|
|
184
|
+
className={styles.selChromeLayer}
|
|
185
|
+
style={{
|
|
186
|
+
width: cardSize.width,
|
|
187
|
+
height: cardSize.height,
|
|
188
|
+
transform: `scale(${geometry.sx}, ${geometry.sy})`,
|
|
189
|
+
}}>
|
|
190
|
+
<div
|
|
191
|
+
className={styles.selChromeWorld}
|
|
192
|
+
style={{ transform: `translate(${-geometry.camX}px, ${-geometry.camY}px)` }}>
|
|
193
|
+
{colliderFrames.map((collider) => (
|
|
194
|
+
<div
|
|
195
|
+
key={collider.actorId}
|
|
196
|
+
className={cx(
|
|
197
|
+
styles.selColliderBox,
|
|
198
|
+
collider.kind === 'pickup' && styles.selColliderPickup
|
|
199
|
+
)}
|
|
200
|
+
style={{
|
|
201
|
+
left: collider.x,
|
|
202
|
+
top: collider.y,
|
|
203
|
+
width: collider.width,
|
|
204
|
+
height: collider.height,
|
|
205
|
+
transformOrigin: `${collider.originX}px ${collider.originY}px`,
|
|
206
|
+
transform: `rotate(${collider.rotation}deg)`,
|
|
207
|
+
}}
|
|
208
|
+
/>
|
|
209
|
+
))}
|
|
210
|
+
{/* Rotate about the actor center, in card units, exactly like the
|
|
211
|
+
canvas. Sits between the scale + camera-translate layers and the
|
|
212
|
+
card-unit chrome. */}
|
|
213
|
+
<div
|
|
214
|
+
className={styles.selChromeRotate}
|
|
215
|
+
style={{
|
|
216
|
+
transformOrigin: `${geometry.centerCardX}px ${geometry.centerCardY}px`,
|
|
217
|
+
transform: `rotate(${geometry.rotation}deg)`,
|
|
218
|
+
}}>
|
|
219
|
+
<SelectionChrome frame={frame} stemLength={geometry.stemLength} onScaleDown={onScaleDown} />
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
</div>
|
|
223
|
+
|
|
224
|
+
{/* Buttons stay screen-upright (never rotated) so their glyphs are always
|
|
225
|
+
readable. The rotate handle orbits via its anchor; clone/delete flip
|
|
226
|
+
above/below to stay clear of it. */}
|
|
227
|
+
<div className={styles.selButtonLayer}>
|
|
228
|
+
<Floating x={geometry.cloneAnchor.x} y={geometry.cloneAnchor.y}>
|
|
229
|
+
<div className={styles.selBtnGroup}>
|
|
230
|
+
<div className={styles.selArrangeWrap}>
|
|
231
|
+
<OverlayButton
|
|
232
|
+
label="Arrange"
|
|
233
|
+
icon="layer-group"
|
|
234
|
+
onActivate={() => setArrangeOpen((open) => !open)}
|
|
235
|
+
/>
|
|
236
|
+
{arrangeOpen ? (
|
|
237
|
+
<div
|
|
238
|
+
className={styles.selArrangeMenu}
|
|
239
|
+
role="menu"
|
|
240
|
+
aria-label="Arrange"
|
|
241
|
+
onPointerDown={(event) => event.stopPropagation()}
|
|
242
|
+
onClick={(event) => event.stopPropagation()}>
|
|
243
|
+
{ARRANGE_OPTIONS.map((option) => (
|
|
244
|
+
<button
|
|
245
|
+
key={option.action}
|
|
246
|
+
type="button"
|
|
247
|
+
role="menuitem"
|
|
248
|
+
className={styles.selArrangeItem}
|
|
249
|
+
onClick={() => {
|
|
250
|
+
onArrange?.(option.action);
|
|
251
|
+
setArrangeOpen(false);
|
|
252
|
+
}}>
|
|
253
|
+
{option.label}
|
|
254
|
+
</button>
|
|
255
|
+
))}
|
|
256
|
+
</div>
|
|
257
|
+
) : null}
|
|
258
|
+
</div>
|
|
259
|
+
<OverlayButton label="Clone" icon="clone" onActivate={onClone} />
|
|
260
|
+
<OverlayButton label="Delete" icon="trash" onActivate={onDelete} />
|
|
261
|
+
</div>
|
|
262
|
+
</Floating>
|
|
263
|
+
<Floating x={geometry.rotateAnchor.x} y={geometry.rotateAnchor.y}>
|
|
264
|
+
<OverlayButton label="Rotate" icon="rotate" extraClass={styles.selBtnRotate} onPointerDown={onRotateDown} />
|
|
265
|
+
</Floating>
|
|
266
|
+
{/* Move/grab handle hidden for now; drag wiring (onMoveDown) preserved. */}
|
|
267
|
+
{SHOW_MOVE_HANDLE && (
|
|
268
|
+
<Floating x={geometry.centerX} y={geometry.centerY + geometry.halfH + GAP_PX}>
|
|
269
|
+
<OverlayButton label="Move" icon="move" extraClass={styles.selBtnGrab} onPointerDown={onMoveDown} />
|
|
270
|
+
</Floating>
|
|
271
|
+
)}
|
|
272
|
+
</div>
|
|
273
|
+
</div>
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Chrome in card units: dashed bounding box, scale handles, connector stem, and
|
|
278
|
+
// center pivot dot. The layer itself ignores pointer events; only the handles
|
|
279
|
+
// opt back in so the canvas still receives ordinary selection gestures.
|
|
280
|
+
function SelectionChrome({ frame, stemLength, onScaleDown }) {
|
|
281
|
+
const left = frame.x;
|
|
282
|
+
const top = frame.y;
|
|
283
|
+
const width = frame.width;
|
|
284
|
+
const height = frame.height;
|
|
285
|
+
const centerX = left + width / 2;
|
|
286
|
+
const centerY = top + height / 2;
|
|
287
|
+
const handlePosition = (handle) => ({
|
|
288
|
+
left: centerX + (handle.x * width) / 2,
|
|
289
|
+
top: centerY + (handle.y * height) / 2,
|
|
290
|
+
cursor: handle.cursor,
|
|
291
|
+
});
|
|
292
|
+
return (
|
|
293
|
+
<>
|
|
294
|
+
{/* Connector stem from the right-center edge out to the rotate handle.
|
|
295
|
+
Lives in the rotated chrome, so it swings with the actor. */}
|
|
296
|
+
{stemLength > 0 ? (
|
|
297
|
+
<div className={styles.selStem} style={{ left: left + width, top: centerY, width: stemLength }} />
|
|
298
|
+
) : null}
|
|
299
|
+
<div className={styles.selBox} style={{ left, top, width, height }} />
|
|
300
|
+
{SCALE_HANDLES.map((handle) => (
|
|
301
|
+
<div
|
|
302
|
+
key={handle.id}
|
|
303
|
+
role="button"
|
|
304
|
+
aria-label={`Scale ${handle.id}`}
|
|
305
|
+
title={`Scale ${handle.id}`}
|
|
306
|
+
className={cx(
|
|
307
|
+
styles.selScaleHandle,
|
|
308
|
+
handle.corner ? styles.selScaleCorner : styles.selScaleEdge
|
|
309
|
+
)}
|
|
310
|
+
data-handle={handle.id}
|
|
311
|
+
style={handlePosition(handle)}
|
|
312
|
+
onPointerDown={(event) => {
|
|
313
|
+
event.stopPropagation();
|
|
314
|
+
onScaleDown(event);
|
|
315
|
+
}}
|
|
316
|
+
/>
|
|
317
|
+
))}
|
|
318
|
+
<div className={styles.selPivot} style={{ left: centerX, top: centerY }} />
|
|
319
|
+
</>
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function getSelectionFrame(sceneData, actorIds, preferredRotation = null) {
|
|
324
|
+
if (!sceneData || !actorIds || actorIds.length === 0) return null;
|
|
325
|
+
const wanted = new Set(actorIds);
|
|
326
|
+
const layouts = sceneData.actors
|
|
327
|
+
.filter((actor) => wanted.has(actor.id))
|
|
328
|
+
.map((actor) => actor.components.Layout)
|
|
329
|
+
.filter(Boolean);
|
|
330
|
+
if (layouts.length === 0) return null;
|
|
331
|
+
const sharedRotation = getSharedRotation(layouts);
|
|
332
|
+
const rotation = sharedRotation ?? (layouts.length > 1 ? preferredRotation : null) ?? 0;
|
|
333
|
+
return getOrientedLayoutBounds(layouts, rotation);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function getOverlayGeometry(frame, box, camera) {
|
|
337
|
+
const sx = box.width / cardSize.width;
|
|
338
|
+
const sy = box.height / cardSize.height;
|
|
339
|
+
const camX = Math.round(camera.x);
|
|
340
|
+
const camY = Math.round(camera.y);
|
|
341
|
+
const rotation = frame.rotation;
|
|
342
|
+
const centerCardX = frame.x + frame.width / 2;
|
|
343
|
+
const centerCardY = frame.y + frame.height / 2;
|
|
344
|
+
const centerX = (centerCardX - camX) * sx;
|
|
345
|
+
const centerY = (centerCardY - camY) * sy;
|
|
346
|
+
const halfW = (frame.width / 2 + HANDLE_OFFSET) * sx;
|
|
347
|
+
const halfH = (frame.height / 2 + HANDLE_OFFSET) * sy;
|
|
348
|
+
const rotateAnchor = getRotateAnchor({ centerX, centerY, halfW, rotation });
|
|
349
|
+
return {
|
|
350
|
+
sx,
|
|
351
|
+
sy,
|
|
352
|
+
camX,
|
|
353
|
+
camY,
|
|
354
|
+
rotation,
|
|
355
|
+
centerCardX,
|
|
356
|
+
centerCardY,
|
|
357
|
+
centerX,
|
|
358
|
+
centerY,
|
|
359
|
+
halfH,
|
|
360
|
+
rotateAnchor,
|
|
361
|
+
cloneAnchor: getActionAnchor({ centerX, centerY, halfH, rotateAnchor }),
|
|
362
|
+
stemLength: HANDLE_OFFSET + GAP_PX / sx,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function getRotateAnchor({ centerX, centerY, halfW, rotation }) {
|
|
367
|
+
const rad = (rotation * Math.PI) / 180;
|
|
368
|
+
const cosR = Math.cos(rad);
|
|
369
|
+
const sinR = Math.sin(rad);
|
|
370
|
+
const lx = halfW + GAP_PX;
|
|
371
|
+
return {
|
|
372
|
+
x: centerX + lx * cosR,
|
|
373
|
+
y: centerY + lx * sinR,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function getActionAnchor({ centerX, centerY, halfH, rotateAnchor }) {
|
|
378
|
+
const topY = centerY - halfH - ACTION_GAP_PX;
|
|
379
|
+
const bottomY = centerY + halfH + ACTION_GAP_PX;
|
|
380
|
+
const overlapsTop =
|
|
381
|
+
Math.abs(rotateAnchor.x - centerX) < GAP_PX * 1.9 &&
|
|
382
|
+
Math.abs(rotateAnchor.y - topY) < GAP_PX * 1.4;
|
|
383
|
+
return {
|
|
384
|
+
x: centerX,
|
|
385
|
+
y: overlapsTop ? bottomY : topY,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function getSelectedColliderFrames(sceneData, actorIds) {
|
|
390
|
+
if (!sceneData || !actorIds || actorIds.length === 0) return [];
|
|
391
|
+
const wanted = new Set(actorIds);
|
|
392
|
+
return sceneData.actors
|
|
393
|
+
.filter((actor) => wanted.has(actor.id))
|
|
394
|
+
.map((actor) => {
|
|
395
|
+
const layout = actor.components.Layout;
|
|
396
|
+
const collider = actor.components.Collider;
|
|
397
|
+
const rect = getColliderRect(actor);
|
|
398
|
+
if (!layout || !collider || !rect) return null;
|
|
399
|
+
return {
|
|
400
|
+
actorId: actor.id,
|
|
401
|
+
kind: collider.kind,
|
|
402
|
+
x: rect.x,
|
|
403
|
+
y: rect.y,
|
|
404
|
+
width: rect.width,
|
|
405
|
+
height: rect.height,
|
|
406
|
+
rotation: layout.rotation ?? 0,
|
|
407
|
+
originX: layout.x + layout.width / 2 - rect.x,
|
|
408
|
+
originY: layout.y + layout.height / 2 - rect.y,
|
|
409
|
+
};
|
|
410
|
+
})
|
|
411
|
+
.filter(Boolean);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function getSharedRotation(layouts) {
|
|
415
|
+
const first = normalizeAngle(layouts[0].rotation ?? 0);
|
|
416
|
+
return layouts.every((layout) => normalizeAngle(layout.rotation ?? 0) === first) ? first : null;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function getOrientedLayoutBounds(layouts, rotation) {
|
|
420
|
+
let minX = Infinity;
|
|
421
|
+
let minY = Infinity;
|
|
422
|
+
let maxX = -Infinity;
|
|
423
|
+
let maxY = -Infinity;
|
|
424
|
+
for (const layout of layouts) {
|
|
425
|
+
for (const corner of getLayoutWorldCorners(layout)) {
|
|
426
|
+
const local = rotateAroundOrigin(corner, -rotation);
|
|
427
|
+
minX = Math.min(minX, local.x);
|
|
428
|
+
minY = Math.min(minY, local.y);
|
|
429
|
+
maxX = Math.max(maxX, local.x);
|
|
430
|
+
maxY = Math.max(maxY, local.y);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
const width = maxX - minX;
|
|
434
|
+
const height = maxY - minY;
|
|
435
|
+
const center = rotateAroundOrigin({ x: minX + width / 2, y: minY + height / 2 }, rotation);
|
|
436
|
+
return {
|
|
437
|
+
x: center.x - width / 2,
|
|
438
|
+
y: center.y - height / 2,
|
|
439
|
+
width,
|
|
440
|
+
height,
|
|
441
|
+
rotation,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function getLayoutWorldCorners(layout) {
|
|
446
|
+
const center = {
|
|
447
|
+
x: layout.x + layout.width / 2,
|
|
448
|
+
y: layout.y + layout.height / 2,
|
|
449
|
+
};
|
|
450
|
+
const halfW = layout.width / 2;
|
|
451
|
+
const halfH = layout.height / 2;
|
|
452
|
+
const rotation = layout.rotation ?? 0;
|
|
453
|
+
return [
|
|
454
|
+
{ x: -halfW, y: -halfH },
|
|
455
|
+
{ x: halfW, y: -halfH },
|
|
456
|
+
{ x: halfW, y: halfH },
|
|
457
|
+
{ x: -halfW, y: halfH },
|
|
458
|
+
].map((corner) => {
|
|
459
|
+
const rotated = rotateAroundOrigin(corner, rotation);
|
|
460
|
+
return {
|
|
461
|
+
x: center.x + rotated.x,
|
|
462
|
+
y: center.y + rotated.y,
|
|
463
|
+
};
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function rotateAroundOrigin(point, deg) {
|
|
468
|
+
const rad = (deg * Math.PI) / 180;
|
|
469
|
+
const cos = Math.cos(rad);
|
|
470
|
+
const sin = Math.sin(rad);
|
|
471
|
+
return {
|
|
472
|
+
x: point.x * cos - point.y * sin,
|
|
473
|
+
y: point.x * sin + point.y * cos,
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function Floating({ x, y, children }) {
|
|
478
|
+
return (
|
|
479
|
+
<div className={styles.selBtnWrap} style={{ left: x, top: y }}>
|
|
480
|
+
{children}
|
|
481
|
+
</div>
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function OverlayButton({ label, icon, onActivate, onPointerDown, extraClass }) {
|
|
486
|
+
return (
|
|
487
|
+
<button
|
|
488
|
+
type="button"
|
|
489
|
+
aria-label={label}
|
|
490
|
+
title={label}
|
|
491
|
+
className={cx(styles.selBtn, extraClass)}
|
|
492
|
+
onPointerDown={(event) => {
|
|
493
|
+
event.stopPropagation();
|
|
494
|
+
onPointerDown?.(event);
|
|
495
|
+
}}
|
|
496
|
+
onClick={(event) => {
|
|
497
|
+
event.stopPropagation();
|
|
498
|
+
onActivate?.();
|
|
499
|
+
}}>
|
|
500
|
+
<Icon name={icon} />
|
|
501
|
+
</button>
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Shared pointer-drag scaffolding. `begin(downEvent)` runs on pointerdown and
|
|
506
|
+
// returns `{ onMove }` (or null to ignore). Window listeners + pointer capture
|
|
507
|
+
// are wired here so the move/rotate gestures only describe their own math.
|
|
508
|
+
function usePointerDragHandle(begin) {
|
|
509
|
+
const beginRef = useRef(begin);
|
|
510
|
+
beginRef.current = begin;
|
|
511
|
+
return useCallback((event) => {
|
|
512
|
+
const handlers = beginRef.current(event);
|
|
513
|
+
if (!handlers) return;
|
|
514
|
+
event.preventDefault();
|
|
515
|
+
try {
|
|
516
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
517
|
+
} catch {
|
|
518
|
+
// Pointer capture may be unavailable; window listeners still drive it.
|
|
519
|
+
}
|
|
520
|
+
const pointerId = event.pointerId;
|
|
521
|
+
const move = (moveEvent) => {
|
|
522
|
+
if (moveEvent.pointerId !== pointerId) return;
|
|
523
|
+
handlers.onMove(moveEvent);
|
|
524
|
+
};
|
|
525
|
+
const end = (endEvent) => {
|
|
526
|
+
if (endEvent.pointerId !== pointerId) return;
|
|
527
|
+
window.removeEventListener('pointermove', move);
|
|
528
|
+
window.removeEventListener('pointerup', end);
|
|
529
|
+
window.removeEventListener('pointercancel', end);
|
|
530
|
+
};
|
|
531
|
+
window.addEventListener('pointermove', move);
|
|
532
|
+
window.addEventListener('pointerup', end);
|
|
533
|
+
window.addEventListener('pointercancel', end);
|
|
534
|
+
}, []);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function applyDragResult(next, sceneData, state, recordSnapshot, applyScene) {
|
|
538
|
+
if (next === sceneData) return;
|
|
539
|
+
if (!state.recorded) {
|
|
540
|
+
recordSnapshot();
|
|
541
|
+
state.recorded = true;
|
|
542
|
+
}
|
|
543
|
+
applyScene(next);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function collectLayoutStarts(sceneData, actorIds) {
|
|
547
|
+
const starts = {};
|
|
548
|
+
const wanted = new Set(actorIds);
|
|
549
|
+
for (const actor of sceneData.actors) {
|
|
550
|
+
if (!wanted.has(actor.id)) continue;
|
|
551
|
+
const layout = actor.components.Layout;
|
|
552
|
+
if (layout) {
|
|
553
|
+
starts[actor.id] = {
|
|
554
|
+
x: layout.x,
|
|
555
|
+
y: layout.y,
|
|
556
|
+
width: layout.width,
|
|
557
|
+
height: layout.height,
|
|
558
|
+
rotation: layout.rotation ?? 0,
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
return starts;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function snapDelta(delta, snap) {
|
|
566
|
+
if (!snap || !snap.enabled) return delta;
|
|
567
|
+
return Math.round(delta / snap.gridSize) * snap.gridSize;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Clone the scene and patch the Layout of every selected actor. `patch(layout,
|
|
571
|
+
// start)` returns the props to merge into that actor's Layout, or null to leave
|
|
572
|
+
// it untouched. Returns the original `sceneData` when nothing changed so callers
|
|
573
|
+
// can skip a no-op commit.
|
|
574
|
+
function updateSelectedLayouts(sceneData, actorIds, starts, patch) {
|
|
575
|
+
const wanted = new Set(actorIds);
|
|
576
|
+
const next = structuredClone(sceneData);
|
|
577
|
+
let changed = false;
|
|
578
|
+
for (const actor of next.actors) {
|
|
579
|
+
if (!wanted.has(actor.id)) continue;
|
|
580
|
+
const start = starts[actor.id];
|
|
581
|
+
const layout = actor.components.Layout;
|
|
582
|
+
if (!start || !layout) continue;
|
|
583
|
+
const props = patch(layout, start);
|
|
584
|
+
if (!props) continue;
|
|
585
|
+
actor.components.Layout = { ...layout, ...props };
|
|
586
|
+
changed = true;
|
|
587
|
+
}
|
|
588
|
+
return changed ? next : sceneData;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function moveSelected(sceneData, actorIds, starts, dx, dy) {
|
|
592
|
+
return updateSelectedLayouts(sceneData, actorIds, starts, (layout, start) => {
|
|
593
|
+
const x = Math.round(start.x + dx);
|
|
594
|
+
const y = Math.round(start.y + dy);
|
|
595
|
+
return layout.x === x && layout.y === y ? null : { x, y };
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Common-pivot rotation: actor centers orbit the selection pivot while each
|
|
600
|
+
// actor also spins by the same delta, matching a temporary group transform.
|
|
601
|
+
function rotateSelected(sceneData, actorIds, starts, pivot, deltaDeg) {
|
|
602
|
+
return updateSelectedLayouts(sceneData, actorIds, starts, (layout, start) => {
|
|
603
|
+
const center = {
|
|
604
|
+
x: start.x + start.width / 2,
|
|
605
|
+
y: start.y + start.height / 2,
|
|
606
|
+
};
|
|
607
|
+
const nextCenter = rotatePoint(center, pivot, deltaDeg);
|
|
608
|
+
const x = Math.round(nextCenter.x - start.width / 2);
|
|
609
|
+
const y = Math.round(nextCenter.y - start.height / 2);
|
|
610
|
+
const rotation = normalizeAngle((start.rotation ?? 0) + deltaDeg);
|
|
611
|
+
if (layout.x === x && layout.y === y && (layout.rotation ?? 0) === rotation) return null;
|
|
612
|
+
return { x, y, rotation };
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function scaleSelected(sceneData, actorIds, starts, startTransform, nextLocalBounds) {
|
|
617
|
+
const startBounds = startTransform.localBounds;
|
|
618
|
+
const scaleX = startBounds.width === 0 ? 1 : nextLocalBounds.width / startBounds.width;
|
|
619
|
+
const scaleY = startBounds.height === 0 ? 1 : nextLocalBounds.height / startBounds.height;
|
|
620
|
+
return updateSelectedLayouts(sceneData, actorIds, starts, (layout, start) => {
|
|
621
|
+
const startCenter = {
|
|
622
|
+
x: start.x + start.width / 2,
|
|
623
|
+
y: start.y + start.height / 2,
|
|
624
|
+
};
|
|
625
|
+
const localCenter = worldToLocal(startCenter, startTransform);
|
|
626
|
+
const nextLocalCenter = {
|
|
627
|
+
x: nextLocalBounds.left + (localCenter.x - startBounds.left) * scaleX,
|
|
628
|
+
y: nextLocalBounds.top + (localCenter.y - startBounds.top) * scaleY,
|
|
629
|
+
};
|
|
630
|
+
const nextCenter = localToWorld(nextLocalCenter, startTransform);
|
|
631
|
+
const width = Math.max(MIN_LAYOUT_SIZE, Math.round(start.width * scaleX));
|
|
632
|
+
const height = Math.max(MIN_LAYOUT_SIZE, Math.round(start.height * scaleY));
|
|
633
|
+
const x = Math.round(nextCenter.x - width / 2);
|
|
634
|
+
const y = Math.round(nextCenter.y - height / 2);
|
|
635
|
+
if (layout.x === x && layout.y === y && layout.width === width && layout.height === height) {
|
|
636
|
+
return null;
|
|
637
|
+
}
|
|
638
|
+
return { x, y, width, height };
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function scaleBoundsFromHandle(bounds, handle, dx, dy, uniform = false) {
|
|
643
|
+
if (uniform) return uniformScaleBoundsFromHandle(bounds, handle, dx, dy);
|
|
644
|
+
let left = bounds.left;
|
|
645
|
+
let right = bounds.right;
|
|
646
|
+
let top = bounds.top;
|
|
647
|
+
let bottom = bounds.bottom;
|
|
648
|
+
if (handle.x < 0) left = clampMax(bounds.left + dx, right - MIN_LAYOUT_SIZE);
|
|
649
|
+
else if (handle.x > 0) right = clampMin(bounds.right + dx, left + MIN_LAYOUT_SIZE);
|
|
650
|
+
if (handle.y < 0) top = clampMax(bounds.top + dy, bottom - MIN_LAYOUT_SIZE);
|
|
651
|
+
else if (handle.y > 0) bottom = clampMin(bounds.bottom + dy, top + MIN_LAYOUT_SIZE);
|
|
652
|
+
return {
|
|
653
|
+
left,
|
|
654
|
+
right,
|
|
655
|
+
top,
|
|
656
|
+
bottom,
|
|
657
|
+
width: right - left,
|
|
658
|
+
height: bottom - top,
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function uniformScaleBoundsFromHandle(bounds, handle, dx, dy) {
|
|
663
|
+
const raw = scaleBoundsFromHandle(bounds, handle, dx, dy);
|
|
664
|
+
const scale = uniformScaleForHandle(bounds, handle, raw);
|
|
665
|
+
return boundsFromUniformScale(bounds, handle, scale);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function uniformScaleForHandle(bounds, handle, raw) {
|
|
669
|
+
const scaleX = raw.width / bounds.width;
|
|
670
|
+
const scaleY = raw.height / bounds.height;
|
|
671
|
+
const minScale = Math.max(MIN_LAYOUT_SIZE / bounds.width, MIN_LAYOUT_SIZE / bounds.height);
|
|
672
|
+
if (handle.x === 0) return Math.max(scaleY, minScale);
|
|
673
|
+
if (handle.y === 0) return Math.max(scaleX, minScale);
|
|
674
|
+
const xDelta = Math.abs(scaleX - 1);
|
|
675
|
+
const yDelta = Math.abs(scaleY - 1);
|
|
676
|
+
return Math.max(xDelta >= yDelta ? scaleX : scaleY, minScale);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function boundsFromUniformScale(bounds, handle, scale) {
|
|
680
|
+
const width = bounds.width * scale;
|
|
681
|
+
const height = bounds.height * scale;
|
|
682
|
+
const centerX = (bounds.left + bounds.right) / 2;
|
|
683
|
+
const centerY = (bounds.top + bounds.bottom) / 2;
|
|
684
|
+
let left = centerX - width / 2;
|
|
685
|
+
let right = centerX + width / 2;
|
|
686
|
+
let top = centerY - height / 2;
|
|
687
|
+
let bottom = centerY + height / 2;
|
|
688
|
+
if (handle.x < 0) left = bounds.right - width;
|
|
689
|
+
else if (handle.x > 0) right = bounds.left + width;
|
|
690
|
+
if (handle.x !== 0) {
|
|
691
|
+
if (handle.x < 0) right = bounds.right;
|
|
692
|
+
else left = bounds.left;
|
|
693
|
+
}
|
|
694
|
+
if (handle.y < 0) top = bounds.bottom - height;
|
|
695
|
+
else if (handle.y > 0) bottom = bounds.top + height;
|
|
696
|
+
if (handle.y !== 0) {
|
|
697
|
+
if (handle.y < 0) bottom = bounds.bottom;
|
|
698
|
+
else top = bounds.top;
|
|
699
|
+
}
|
|
700
|
+
return {
|
|
701
|
+
left,
|
|
702
|
+
right,
|
|
703
|
+
top,
|
|
704
|
+
bottom,
|
|
705
|
+
width: right - left,
|
|
706
|
+
height: bottom - top,
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function makeBoundsTransform(bounds, rotation) {
|
|
711
|
+
const pivot = {
|
|
712
|
+
x: bounds.x + bounds.width / 2,
|
|
713
|
+
y: bounds.y + bounds.height / 2,
|
|
714
|
+
};
|
|
715
|
+
const left = -bounds.width / 2;
|
|
716
|
+
const right = bounds.width / 2;
|
|
717
|
+
const top = -bounds.height / 2;
|
|
718
|
+
const bottom = bounds.height / 2;
|
|
719
|
+
return {
|
|
720
|
+
pivot,
|
|
721
|
+
rotation,
|
|
722
|
+
localBounds: {
|
|
723
|
+
left,
|
|
724
|
+
right,
|
|
725
|
+
top,
|
|
726
|
+
bottom,
|
|
727
|
+
width: bounds.width,
|
|
728
|
+
height: bounds.height,
|
|
729
|
+
},
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function pointerLocal(canvas, editCameraRef, event, transform) {
|
|
734
|
+
const raw = screenToCard(canvas, event.clientX, event.clientY);
|
|
735
|
+
const cam = editCameraRef.current ?? { x: 0, y: 0 };
|
|
736
|
+
return worldToLocal({ x: raw.x + cam.x, y: raw.y + cam.y }, transform);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function worldToLocal(point, transform) {
|
|
740
|
+
const dx = point.x - transform.pivot.x;
|
|
741
|
+
const dy = point.y - transform.pivot.y;
|
|
742
|
+
const rad = (-transform.rotation * Math.PI) / 180;
|
|
743
|
+
const cos = Math.cos(rad);
|
|
744
|
+
const sin = Math.sin(rad);
|
|
745
|
+
return {
|
|
746
|
+
x: dx * cos - dy * sin,
|
|
747
|
+
y: dx * sin + dy * cos,
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function localToWorld(point, transform) {
|
|
752
|
+
const rad = (transform.rotation * Math.PI) / 180;
|
|
753
|
+
const cos = Math.cos(rad);
|
|
754
|
+
const sin = Math.sin(rad);
|
|
755
|
+
return {
|
|
756
|
+
x: transform.pivot.x + point.x * cos - point.y * sin,
|
|
757
|
+
y: transform.pivot.y + point.x * sin + point.y * cos,
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function rotatePoint(point, pivot, deltaDeg) {
|
|
762
|
+
const rad = (deltaDeg * Math.PI) / 180;
|
|
763
|
+
const cos = Math.cos(rad);
|
|
764
|
+
const sin = Math.sin(rad);
|
|
765
|
+
const dx = point.x - pivot.x;
|
|
766
|
+
const dy = point.y - pivot.y;
|
|
767
|
+
return {
|
|
768
|
+
x: pivot.x + dx * cos - dy * sin,
|
|
769
|
+
y: pivot.y + dx * sin + dy * cos,
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function clampMin(value, min) {
|
|
774
|
+
return value < min ? min : value;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function clampMax(value, max) {
|
|
778
|
+
return value > max ? max : value;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function normalizeAngle(deg) {
|
|
782
|
+
const wrapped = ((deg % 360) + 360) % 360;
|
|
783
|
+
return Math.round(wrapped * 100) / 100;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function shortestAngleDelta(from, to) {
|
|
787
|
+
return ((((to - from) % 360) + 540) % 360) - 180;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// Snap a raw rotation delta (degrees from the angle at drag-start) to a value.
|
|
791
|
+
// The original (pre-drag) angle lives at delta 0, so a magnet band there lets
|
|
792
|
+
// the actor always return to exactly where it began -- independent of the
|
|
793
|
+
// 15-degree grid, which would otherwise skip an off-grid original. Outside the
|
|
794
|
+
// magnet, snap to the grid when enabled, else rotate freely (whole degrees).
|
|
795
|
+
function snapRotationDelta(delta, snapEnabled) {
|
|
796
|
+
if (Math.abs(delta) <= ROTATE_MAGNET) return 0;
|
|
797
|
+
if (!snapEnabled) return Math.round(delta);
|
|
798
|
+
return Math.round(delta / ROTATE_STEP) * ROTATE_STEP;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// Angle (degrees) of the pointer around `center`, in world card units.
|
|
802
|
+
function pointerAngle(canvas, editCameraRef, event, center) {
|
|
803
|
+
const raw = screenToCard(canvas, event.clientX, event.clientY);
|
|
804
|
+
const cam = editCameraRef.current ?? { x: 0, y: 0 };
|
|
805
|
+
const wx = raw.x + cam.x;
|
|
806
|
+
const wy = raw.y + cam.y;
|
|
807
|
+
return (Math.atan2(wy - center.y, wx - center.x) * 180) / Math.PI;
|
|
808
|
+
}
|