feat(control): инструмент «Маркер» на области видимости

Добавлена кисть маркера на пульте и презентации с настройками в панели эффектов; штрихи привязаны к жёлтой рамке, ластик и очистка работают с отдельным сессионным слоем.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-09-02 12:53:06 +08:00
parent 153be98780
commit 8b28a9b11a
18 changed files with 906 additions and 32 deletions
+107
View File
@@ -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;
}