feat(editor): LaserPointer based freedraw (#11507)

Introduces constant width freedraw mode, keeping the original variable mode as default.

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
Márk Tolmács
2026-06-24 09:16:22 +02:00
committed by GitHub
parent 0642e72cfa
commit cd514d72d6
48 changed files with 1635 additions and 225 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 Excalidraw
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+23
View File
@@ -0,0 +1,23 @@
# Laser Pointer
## Usage
import { LaserPointer } from '@excalidraw/laser-pointer'
const stroke = new LaserPointer(options)
stroke.addPoint([100, 200, 1])
stroke.close()
const outline = stroke.getStrokeOutline()
## Options
| Property | Type | Default | Description |
| --- | --- | --- | --- |
| `size` | `number` | `2` | Radius of the stroke. |
| `streamline` | `number` | `0.42` | Interpolate input points to reduce jitter. |
| `simplify` | `number` | `0.1` | Reduce stroke size by sacrificing precision. |
| `simplifyPhase` | `"input" \| "output" \| "tail" ` | `"output"` | Decides when the simplification algorithm should be applied. |
| `sizeMapping` | `(details: SizeMappingDetails) => number` | `() => 1` | Maps each point to a value between `0.0` and `1.0`. |
| `keepHead` | `boolean` | `false` | Whether size mapping should influence the head of the stroke. |
View File
+34
View File
@@ -0,0 +1,34 @@
{
"name": "@excalidraw/laser-pointer",
"version": "1.3.1",
"description": "Generate outline for laser pointer tool",
"type": "module",
"types": "./dist/types/index.d.ts",
"main": "./dist/prod/index.js",
"module": "./dist/prod/index.js",
"exports": {
".": {
"types": "./dist/types/index.d.ts",
"development": "./dist/dev/index.js",
"production": "./dist/prod/index.js",
"default": "./dist/prod/index.js"
}
},
"files": [
"dist/*"
],
"keywords": [
"excalidraw",
"laserpointer"
],
"bugs": "https://github.com/excalidraw/excalidraw/issues",
"repository": "https://github.com/excalidraw/excalidraw",
"license": "MIT",
"publishConfig": {
"access": "public"
},
"scripts": {
"gen:types": "rimraf types && tsc",
"build:esm": "rimraf dist && node ../../scripts/buildBase.js && yarn gen:types"
}
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./state";
export type { Point } from "./math";
+105
View File
@@ -0,0 +1,105 @@
export type Point = [x: number, y: number, r: number];
export function add([ax, ay, ar]: Point, [bx, by, br]: Point): Point {
return [ax + bx, ay + by, ar + br];
}
export function sub([ax, ay, ar]: Point, [bx, by, br]: Point): Point {
return [ax - bx, ay - by, ar - br];
}
export function smul([x, y, r]: Point, s: number): Point {
return [x * s, y * s, r * s];
}
export function norm([x, y, r]: Point): Point {
return [x / Math.sqrt(x ** 2 + y ** 2), y / Math.sqrt(x ** 2 + y ** 2), r];
}
export function rot([x, y, r]: Point, rad: number): Point {
return [
Math.cos(rad) * x - Math.sin(rad) * y,
Math.sin(rad) * x + Math.cos(rad) * y,
r,
];
}
export function plerp(a: Point, b: Point, t: number): Point {
return add(a, smul(sub(b, a), t));
}
export function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
export function angle(p: Point, p1: Point, p2: Point) {
return (
Math.atan2(p2[1] - p[1], p2[0] - p[0]) -
Math.atan2(p1[1] - p[1], p1[0] - p[0])
);
}
export function normAngle(a: number) {
return Math.atan2(Math.sin(a), Math.cos(a));
}
export function mag([x, y]: Point) {
return Math.sqrt(x ** 2 + y ** 2);
}
export function dist([ax, ay]: Point, [bx, by]: Point): number {
return Math.sqrt((bx - ax) ** 2 + (by - ay) ** 2);
}
export function getCircleAndPerpendicularLineIntersectionsAtPoint(
point: Point,
direction: Point,
radius: number,
): [Point, Point] {
return [
add(point, smul(norm(rot(direction, Math.PI / 2)), radius)),
add(point, smul(norm(rot(direction, -Math.PI / 2)), radius)),
];
}
export function runLength(ps: Point[]): number {
if (ps.length < 2) {
return 0;
}
let len = 0;
for (let i = 1; i <= ps.length - 1; i++) {
len += dist(ps[i - 1], ps[i]);
}
len += dist(ps[ps.length - 2], ps[ps.length - 1]);
return len;
}
export const clamp = (v: number, min: number, max: number) =>
Math.max(min, Math.min(max, v));
export function distancePointToSegment(p3: Point, p1: Point, p2: Point) {
const sMag = dist(p1, p2);
if (sMag === 0) {
return dist(p3, p1);
}
const u = clamp(
((p3[0] - p1[0]) * (p2[0] - p1[0]) + (p3[1] - p1[1]) * (p2[1] - p1[1])) /
sMag ** 2,
0,
1,
);
const pi: Point = [
p1[0] + u * (p2[0] - p1[0]),
p1[1] + u * (p2[1] - p1[1]),
p3[2],
];
return dist(pi, p3);
}
+42
View File
@@ -0,0 +1,42 @@
import { type Point, distancePointToSegment } from "./math";
export function douglasPeucker(points: Point[], epsilon: number): Point[] {
if (epsilon === 0) {
return points;
}
if (points.length <= 2) {
return points;
}
const first = points[0];
const last = points[points.length - 1];
const [maxDistance, maxIndex] = points.reduce(
([maxDistance, maxIndex], point, index) => {
const distance = distancePointToSegment(point, first, last);
return distance > maxDistance
? [distance, index]
: [maxDistance, maxIndex];
},
[0, -1],
);
if (maxDistance >= epsilon) {
const maxIndexPoint = points[maxIndex];
return [
...douglasPeucker(
[first, ...points.slice(1, maxIndex), maxIndexPoint],
epsilon,
).slice(0, -1),
maxIndexPoint,
...douglasPeucker(
[maxIndexPoint, ...points.slice(maxIndex, -1), last],
epsilon,
).slice(1),
];
}
return [first, last];
}
+377
View File
@@ -0,0 +1,377 @@
import * as m from "./math";
import { douglasPeucker } from "./simplify";
import type { Point } from "./math";
export type SizeMappingDetails = {
pressure: number;
runningLength: number;
currentIndex: number;
totalLength: number;
};
export type LaserPointerOptions = {
size: number;
streamline: number;
simplify: number;
simplifyPhase: "tail" | "output" | "input";
keepHead: boolean;
sizeMapping: (details: SizeMappingDetails) => number;
};
export class LaserPointer {
static defaults: LaserPointerOptions = {
size: 2,
streamline: 0.45,
simplify: 0.1,
simplifyPhase: "output",
keepHead: false,
sizeMapping: () => 1,
};
static constants = {
cornerDetectionMaxAngle: 75,
cornerDetectionVariance: (s: number) => (s > 35 ? 0.5 : 1),
maxTailLength: 50,
};
options: LaserPointerOptions;
constructor(options: Partial<LaserPointerOptions>) {
this.options = Object.assign({}, LaserPointer.defaults, options);
}
originalPoints: Point[] = [];
private stablePoints: Point[] = [];
private tailPoints: Point[] = [];
private isFresh = true;
private get lastPoint(): Point {
return (
this.tailPoints[this.tailPoints.length - 1] ??
this.stablePoints[this.stablePoints.length - 1]
);
}
addPoint(point: Point) {
const lastPoint = this.originalPoints[this.originalPoints.length - 1];
if (lastPoint && lastPoint[0] === point[0] && lastPoint[1] === point[1]) {
return;
}
this.originalPoints.push(point);
if (this.isFresh) {
this.isFresh = false;
this.stablePoints.push(point);
return;
}
if (this.options.streamline > 0) {
point = m.plerp(this.lastPoint, point, 1 - this.options.streamline);
}
this.tailPoints.push(point);
if (m.runLength(this.tailPoints) > LaserPointer.constants.maxTailLength) {
this.stabilizeTail();
}
}
close() {
this.stabilizeTail();
}
stabilizeTail() {
if (this.options.simplify > 0 && this.options.simplifyPhase === "tail") {
throw new Error("Not implemented yet");
} else {
this.stablePoints.push(...this.tailPoints);
this.tailPoints = [];
}
}
private getSize(
sizeOverride: number | undefined,
pressure: number,
index: number,
totalLength: number,
runningLength: number,
) {
return (
(sizeOverride ?? this.options.size) *
this.options.sizeMapping({
pressure,
runningLength,
currentIndex: index,
totalLength,
})
);
}
getStrokeOutline(sizeOverride?: number | undefined): Point[] {
if (this.isFresh) {
return [];
}
let points = [...this.stablePoints, ...this.tailPoints];
if (this.options.simplify > 0 && this.options.simplifyPhase === "input") {
points = douglasPeucker(points, this.options.simplify);
}
const len = points.length;
if (len === 0) {
return [];
}
if (len === 1) {
const c = points[0];
const size = this.getSize(sizeOverride, c[2], 0, len, 0);
if (size < 0.5) {
return [];
}
const ps: Point[] = [];
for (let theta = 0; theta <= Math.PI * 2; theta += Math.PI / 16) {
ps.push(m.add(c, m.smul(m.rot([1, 0, 0] as Point, theta), size)));
}
ps.push(
m.add(
c,
m.smul(
[1, 0, 0] as Point,
this.getSize(sizeOverride, c[2], 0, len, 0),
),
),
);
return ps;
}
if (len === 2) {
const c = points[0];
const n = points[1];
const cSize = this.getSize(sizeOverride, c[2], 0, len, 0);
const nSize = this.getSize(sizeOverride, n[2], 0, len, 0);
if (cSize < 0.5 || nSize < 0.5) {
return [];
}
const ps: Point[] = [];
const pAngle = m.angle(c, [c[0], c[1] - 100, c[2]] as Point, n);
for (
let theta = pAngle;
theta <= Math.PI + pAngle;
theta += Math.PI / 16
) {
ps.push(m.add(c, m.smul(m.rot([1, 0, 0] as Point, theta), cSize)));
}
for (
let theta = Math.PI + pAngle;
theta <= Math.PI * 2 + pAngle;
theta += Math.PI / 16
) {
ps.push(m.add(n, m.smul(m.rot([1, 0, 0] as Point, theta), nSize)));
}
ps.push(ps[0]);
return ps;
}
const forwardPoints: Point[] = [];
const backwardPoints: Point[] = [];
let speed = 0;
let prevSpeed = 0;
let visibleStartIndex = 0;
let runningLength = 0;
for (let i = 1; i < len - 1; i++) {
const p = points[i - 1];
const c = points[i];
const n = points[i + 1];
const pressure = c[2];
const d = m.dist(p, c);
runningLength += d;
speed = prevSpeed + (d - prevSpeed) * 0.2;
const cSize = this.getSize(sizeOverride, pressure, i, len, runningLength);
if (cSize === 0) {
visibleStartIndex = i + 1;
continue;
}
const dirPC = m.norm(m.sub(p, c));
const dirNC = m.norm(m.sub(n, c));
const p1dirPC = m.rot(dirPC, Math.PI / 2);
const p2dirPC = m.rot(dirPC, -Math.PI / 2);
const p1dirNC = m.rot(dirNC, Math.PI / 2);
const p2dirNC = m.rot(dirNC, -Math.PI / 2);
const p1PC = m.add(c, m.smul(p1dirPC, cSize));
const p2PC = m.add(c, m.smul(p2dirPC, cSize));
const p1NC = m.add(c, m.smul(p1dirNC, cSize));
const p2NC = m.add(c, m.smul(p2dirNC, cSize));
const ftdir = m.add(p1dirPC, p2dirNC);
const btdir = m.add(p2dirPC, p1dirNC);
const paPC = m.add(
c,
m.smul(m.mag(ftdir) === 0 ? dirPC : m.norm(ftdir), cSize),
);
const paNC = m.add(
c,
m.smul(m.mag(btdir) === 0 ? dirNC : m.norm(btdir), cSize),
);
const cAngle = m.normAngle(m.angle(c, p, n));
const D_ANGLE =
(LaserPointer.constants.cornerDetectionMaxAngle / 180) *
Math.PI *
LaserPointer.constants.cornerDetectionVariance(speed);
if (Math.abs(cAngle) < D_ANGLE) {
const tAngle = Math.abs(m.normAngle(Math.PI - cAngle)); // turn angle
if (tAngle === 0) {
continue;
}
if (cAngle < 0) {
backwardPoints.push(p2PC, paNC);
for (let theta = 0; theta <= tAngle; theta += tAngle / 4) {
forwardPoints.push(m.add(c, m.rot(m.smul(p1dirPC, cSize), theta)));
}
for (let theta = tAngle; theta >= 0; theta -= tAngle / 4) {
backwardPoints.push(m.add(c, m.rot(m.smul(p1dirPC, cSize), theta)));
}
backwardPoints.push(paNC, p1NC);
} else {
forwardPoints.push(p1PC, paPC);
for (let theta = 0; theta <= tAngle; theta += tAngle / 4) {
backwardPoints.push(
m.add(c, m.rot(m.smul(p1dirPC, -cSize), -theta)),
);
}
for (let theta = tAngle; theta >= 0; theta -= tAngle / 4) {
forwardPoints.push(
m.add(c, m.rot(m.smul(p1dirPC, -cSize), -theta)),
);
}
forwardPoints.push(paPC, p2NC);
}
} else {
forwardPoints.push(paPC);
backwardPoints.push(paNC);
}
prevSpeed = speed;
}
if (visibleStartIndex >= len - 2) {
if (this.options.keepHead) {
const c = points[len - 1];
const ps: Point[] = [];
for (let theta = 0; theta <= Math.PI * 2; theta += Math.PI / 16) {
ps.push(
m.add(
c,
m.smul(m.rot([1, 0, 0] as Point, theta), this.options.size),
),
);
}
ps.push(m.add(c, m.smul([1, 0, 0] as Point, this.options.size)));
return ps;
}
return [];
}
const first = points[visibleStartIndex];
const second = points[visibleStartIndex + 1];
const penultimate = points[len - 2];
const ultimate = points[len - 1];
const dirFS = m.norm(m.sub(second, first));
const dirPU = m.norm(m.sub(penultimate, ultimate));
const ppdirFS = m.rot(dirFS, -Math.PI / 2);
const ppdirPU = m.rot(dirPU, Math.PI / 2);
const startCapSize = this.getSize(sizeOverride, first[2], 0, len, 0);
const startCap: Point[] = [];
const endCapSize = this.options.keepHead
? this.options.size
: this.getSize(sizeOverride, penultimate[2], len - 2, len, runningLength);
const endCap: Point[] = [];
// Lowered threshold to 0.1,
// ensuring virtually all strokes get proper rounded caps for visual consistency.
if (startCapSize > 0.1) {
for (let theta = 0; theta <= Math.PI; theta += Math.PI / 16) {
startCap.unshift(
m.add(first, m.rot(m.smul(ppdirFS, startCapSize), -theta)),
);
}
startCap.unshift(m.add(first, m.smul(ppdirFS, -startCapSize)));
} else {
startCap.push(first);
}
for (let theta = 0; theta <= Math.PI * 3; theta += Math.PI / 16) {
endCap.push(m.add(ultimate, m.rot(m.smul(ppdirPU, -endCapSize), -theta)));
}
const strokeOutline = [
...startCap,
...forwardPoints,
...endCap.reverse(),
...backwardPoints.reverse(),
];
if (startCap.length > 0) {
strokeOutline.push(startCap[0]);
}
if (this.options.simplify > 0 && this.options.simplifyPhase === "output") {
return douglasPeucker(strokeOutline, this.options.simplify);
}
return strokeOutline;
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist/types"
},
"include": ["src/**/*", "global.d.ts"],
"exclude": ["**/*.test.*", "tests", "types", "examples", "dist"]
}