feat(control): инструмент «Маркер» на области видимости
Добавлена кисть маркера на пульте и презентации с настройками в панели эффектов; штрихи привязаны к жёлтой рамке, ластик и очистка работают с отдельным сессионным слоем. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -27,6 +27,8 @@ import type {
|
||||
Scene,
|
||||
SceneDarknessEvent,
|
||||
SceneDarknessState,
|
||||
SceneMarkerEvent,
|
||||
SceneMarkerState,
|
||||
SceneGrid,
|
||||
SceneId,
|
||||
SceneNpcToken,
|
||||
@@ -182,6 +184,11 @@ export const ipcChannels = {
|
||||
dispatch: 'sceneDarkness.dispatch',
|
||||
stateChanged: 'sceneDarkness.stateChanged',
|
||||
},
|
||||
sceneMarker: {
|
||||
getState: 'sceneMarker.getState',
|
||||
dispatch: 'sceneMarker.dispatch',
|
||||
stateChanged: 'sceneMarker.stateChanged',
|
||||
},
|
||||
sceneTraps: {
|
||||
getState: 'sceneTraps.getState',
|
||||
dispatch: 'sceneTraps.dispatch',
|
||||
@@ -315,6 +322,7 @@ export type IpcEventMap = {
|
||||
[ipcChannels.materialsOverlay.stateChanged]: { state: MaterialsOverlayState };
|
||||
[ipcChannels.npcsOverlay.stateChanged]: { state: NpcsOverlayState };
|
||||
[ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState };
|
||||
[ipcChannels.sceneMarker.stateChanged]: { state: SceneMarkerState };
|
||||
[ipcChannels.sceneTraps.stateChanged]: { state: SceneTrapsState };
|
||||
[ipcChannels.sceneView.stateChanged]: { state: SceneViewState };
|
||||
[ipcChannels.tokens.stateChanged]: { tokens: AppToken[] };
|
||||
@@ -771,6 +779,14 @@ export type IpcInvokeMap = {
|
||||
req: { event: SceneDarknessEvent };
|
||||
res: { ok: true };
|
||||
};
|
||||
[ipcChannels.sceneMarker.getState]: {
|
||||
req: Record<string, never>;
|
||||
res: { state: SceneMarkerState };
|
||||
};
|
||||
[ipcChannels.sceneMarker.dispatch]: {
|
||||
req: { event: SceneMarkerEvent };
|
||||
res: { ok: true };
|
||||
};
|
||||
[ipcChannels.sceneTraps.getState]: {
|
||||
req: Record<string, never>;
|
||||
res: { state: SceneTrapsState };
|
||||
@@ -952,6 +968,7 @@ export type LegacyIpcEventMap = {
|
||||
[ipcChannels.materialsOverlay.stateChanged]: { state: MaterialsOverlayState };
|
||||
[ipcChannels.npcsOverlay.stateChanged]: { state: NpcsOverlayState };
|
||||
[ipcChannels.sceneDarkness.stateChanged]: { state: SceneDarknessState };
|
||||
[ipcChannels.sceneMarker.stateChanged]: { state: SceneMarkerState };
|
||||
[ipcChannels.sceneTraps.stateChanged]: { state: SceneTrapsState };
|
||||
[ipcChannels.sceneView.stateChanged]: { state: SceneViewState };
|
||||
[ipcChannels.tokens.stateChanged]: { tokens: AppToken[] };
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { applySceneMarkerEraserStroke } from './sceneMarkerEraser';
|
||||
import type { SceneMarkerStroke } from './types/sceneMarker';
|
||||
|
||||
void test('applySceneMarkerEraserStroke: удаляет точки под ластиком', () => {
|
||||
const strokes: SceneMarkerStroke[] = [
|
||||
{
|
||||
id: 'm1',
|
||||
seed: 1,
|
||||
createdAtMs: 100,
|
||||
points: [
|
||||
{ x: 0.1, y: 0.5, tMs: 100 },
|
||||
{ x: 0.5, y: 0.5, tMs: 101 },
|
||||
{ x: 0.9, y: 0.5, tMs: 102 },
|
||||
],
|
||||
radiusN: 0.04,
|
||||
color: '#ff0000',
|
||||
},
|
||||
];
|
||||
const next = applySceneMarkerEraserStroke(
|
||||
strokes,
|
||||
[{ x: 0.5, y: 0.5, tMs: 200 }],
|
||||
0.06,
|
||||
(prefix) => `${prefix}_new`,
|
||||
);
|
||||
assert.ok(next.length >= 1);
|
||||
assert.ok(next.every((s) => s.points.every((p) => Math.abs(p.x - 0.5) > 0.05)));
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { NPoint } from './types/effects';
|
||||
import type { SceneMarkerStroke } from './types/sceneMarker';
|
||||
|
||||
function distSqPointToSegment(
|
||||
px: number,
|
||||
py: number,
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
): number {
|
||||
const dx = x2 - x1;
|
||||
const dy = y2 - y1;
|
||||
const len2 = dx * dx + dy * dy;
|
||||
if (len2 < 1e-18) {
|
||||
const ex = px - x1;
|
||||
const ey = py - y1;
|
||||
return ex * ex + ey * ey;
|
||||
}
|
||||
let t = ((px - x1) * dx + (py - y1) * dy) / len2;
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
const qx = x1 + t * dx;
|
||||
const qy = y1 + t * dy;
|
||||
const ex = px - qx;
|
||||
const ey = py - qy;
|
||||
return ex * ex + ey * ey;
|
||||
}
|
||||
|
||||
function minDistToEraserStroke(
|
||||
p: { x: number; y: number },
|
||||
eraserPoints: readonly { x: number; y: number }[],
|
||||
): number {
|
||||
if (eraserPoints.length === 0) return Number.POSITIVE_INFINITY;
|
||||
let bestSq = Number.POSITIVE_INFINITY;
|
||||
for (const ep of eraserPoints) {
|
||||
const dx = p.x - ep.x;
|
||||
const dy = p.y - ep.y;
|
||||
bestSq = Math.min(bestSq, dx * dx + dy * dy);
|
||||
}
|
||||
for (let i = 1; i < eraserPoints.length; i += 1) {
|
||||
const a = eraserPoints[i - 1];
|
||||
const b = eraserPoints[i];
|
||||
if (!a || !b) continue;
|
||||
bestSq = Math.min(bestSq, distSqPointToSegment(p.x, p.y, a.x, a.y, b.x, b.y));
|
||||
}
|
||||
return Math.sqrt(bestSq);
|
||||
}
|
||||
|
||||
function isPointErased(
|
||||
p: { x: number; y: number },
|
||||
strokeRadiusN: number,
|
||||
eraserPoints: readonly { x: number; y: number }[],
|
||||
eraserRadiusN: number,
|
||||
): boolean {
|
||||
const threshold = eraserRadiusN + strokeRadiusN;
|
||||
return minDistToEraserStroke(p, eraserPoints) <= threshold;
|
||||
}
|
||||
|
||||
function splitKeptPointRuns(
|
||||
points: NPoint[],
|
||||
eraserPoints: readonly { x: number; y: number }[],
|
||||
eraserRadiusN: number,
|
||||
strokeRadiusN: number,
|
||||
): NPoint[][] {
|
||||
const runs: NPoint[][] = [];
|
||||
let current: NPoint[] = [];
|
||||
for (const p of points) {
|
||||
if (isPointErased(p, strokeRadiusN, eraserPoints, eraserRadiusN)) {
|
||||
if (current.length > 0) {
|
||||
runs.push(current);
|
||||
current = [];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
current.push(p);
|
||||
}
|
||||
if (current.length > 0) runs.push(current);
|
||||
return runs;
|
||||
}
|
||||
|
||||
/** Стирает штрихи маркера ластиком (как туман/огонь). */
|
||||
export function applySceneMarkerEraserStroke(
|
||||
strokes: readonly SceneMarkerStroke[],
|
||||
eraserPoints: readonly NPoint[],
|
||||
eraserRadiusN: number,
|
||||
makeId: (prefix: string) => string,
|
||||
): SceneMarkerStroke[] {
|
||||
if (eraserPoints.length === 0) return [...strokes];
|
||||
const out: SceneMarkerStroke[] = [];
|
||||
for (const stroke of strokes) {
|
||||
const runs = splitKeptPointRuns(stroke.points, eraserPoints, eraserRadiusN, stroke.radiusN);
|
||||
if (runs.length === 0) continue;
|
||||
if (runs.length === 1 && runs[0]?.length === stroke.points.length) {
|
||||
out.push(stroke);
|
||||
continue;
|
||||
}
|
||||
for (const run of runs) {
|
||||
if (run.length === 0) continue;
|
||||
out.push({
|
||||
...stroke,
|
||||
id: makeId('mk'),
|
||||
points: run,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export type EffectToolType =
|
||||
| 'freeze'
|
||||
| 'exploreBrush'
|
||||
| 'closeBrush'
|
||||
| 'marker'
|
||||
| 'eraser';
|
||||
|
||||
export type EffectInstanceType =
|
||||
|
||||
@@ -9,6 +9,7 @@ export * from './npcDisposition';
|
||||
export * from './npcExternalUrl';
|
||||
export * from './npcs';
|
||||
export * from './sceneDarkness';
|
||||
export * from './sceneMarker';
|
||||
export * from './sceneGrid';
|
||||
export * from './sceneGridSnap';
|
||||
export * from './tokenPath';
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { NPoint } from './effects';
|
||||
|
||||
export type SceneMarkerBrushShape = 'circle' | 'square';
|
||||
|
||||
export type SceneMarkerStroke = {
|
||||
id: string;
|
||||
seed: number;
|
||||
createdAtMs: number;
|
||||
points: NPoint[];
|
||||
radiusN: number;
|
||||
color: string;
|
||||
shape?: SceneMarkerBrushShape;
|
||||
};
|
||||
|
||||
export type SceneMarkerDraft = {
|
||||
points: NPoint[];
|
||||
radiusN: number;
|
||||
color: string;
|
||||
shape?: SceneMarkerBrushShape;
|
||||
};
|
||||
|
||||
export type SceneMarkerState = {
|
||||
revision: number;
|
||||
cacheKey: string | null;
|
||||
strokes: SceneMarkerStroke[];
|
||||
draft: SceneMarkerDraft | null;
|
||||
};
|
||||
|
||||
export type SceneMarkerEvent =
|
||||
| { kind: 'draft.set'; draft: SceneMarkerDraft | null }
|
||||
| { kind: 'stroke.add'; stroke: SceneMarkerStroke }
|
||||
| { kind: 'strokes.erase'; points: NPoint[]; radiusN: number }
|
||||
| { kind: 'strokes.clear' };
|
||||
|
||||
export function normalizeSceneMarkerBrushShape(raw: unknown): SceneMarkerBrushShape {
|
||||
return raw === 'square' ? 'square' : 'circle';
|
||||
}
|
||||
Reference in New Issue
Block a user