Recurring cleanup round. Behaviour is unchanged. Duplicated code in
src went from 27 clones with 299 lines to 6 clones with 56 lines
(jscpd, the two identical OFL licence texts not counted).
- SheetCanvas is the new base of the texture, pixel art and vector art
canvases. It holds background, mask, hint, panning, wheel zoom, fit,
view save and restore and the screen mapping. Pixel art keeps its
whole-pixel panning and fixed zoom steps.
- mixColor in src/core/util/color.ts replaces three copies in
animation, gradient and particles. It clamps t like the particle one.
- fingerprint now lives once in generated.ts, next to the revision
contract from #185.
- MoveCommand next to RenameCommand is the base of the three reorder
commands for tile pictures, tilemap layers and vector shapes.
- The two raycast nodes share inputs, settings and filter via rayOf.
- canvasRect() lives once in AssetTabEditor.
- Removed unused code: findPixelArt, isDrawableShape,
GeneratedPictures.has and SnapToggle.snapping. Tests now check the
same things through equivalent paths.
- 38 values used only in their own file lost their export.
- Three comments that told history now describe the current state.
The bundle stayed md5-identical for those edits.
- README lists vector art, pixel art and palette, and links the two
wiki pages.
Findings:
- PaintTilesCommand.changes is meant to prevent empty undo steps but
has not been called since #128. The rectangle tool therefore records
a no-op undo step when it paints the same tile over itself. Left as
is, because fixing it changes behaviour.
- No test covered middle-click panning in any sheet tab before. 13f
and 48a now check it.
- The default values of the ray inputs were untested. Length 1000
could become 10 without a red test. tests/core/raycast.test.ts now
covers it.
- CI measured 1308 editor checks at d7c0b7c, not the 1307 counted for
#164. The sum is now 1310 (1308 + 2), computed.
Tests:
- ./check.sh: 144 files, 2143 tests, exit 0 (before 2142).
- e2e 1-16,37,39-43,47,48 fresh: ALL PASSED, 729 checks.
- e2e 1-13,47,48 with the new checks: ALL PASSED, 518 checks.
- Counter-checks, each restored via cp and cmp: halved panning fails
both new pan checks; unclamped mix fails 1 test, no blue channel 3;
coarser hash 3; broken reorder undo 2; triggers setting forced on 2;
bent ray length 1.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
205 lines
7.5 KiB
TypeScript
205 lines
7.5 KiB
TypeScript
/**
|
||
* A frame's collision outlines read off its picture (#161): the pixels at least as opaque as the
|
||
* threshold are solid, every island of them is walked round along the pixel edges, and the walk is
|
||
* cut down to a few corners. Holes are not traced – an outline blocks with all it encloses.
|
||
*/
|
||
import { polygonProblem, segmentsMeet } from './polygon';
|
||
import type { Vec2 } from './Properties';
|
||
|
||
export interface TraceOptions {
|
||
/** The most corners one outline keeps. */
|
||
maxPoints: number;
|
||
/** Opacity from which a pixel is solid, 0 … 1. */
|
||
threshold: number;
|
||
}
|
||
|
||
export const DEFAULT_TRACE: TraceOptions = { maxPoints: 8, threshold: 0.5 };
|
||
export const MIN_TRACE_POINTS = 3;
|
||
export const MAX_TRACE_POINTS = 64;
|
||
|
||
/** An island below this share of the frame is a crumb and gets no outline – unless it is the largest. */
|
||
const CRUMB_SHARE = 0.01;
|
||
/** A corner making a triangle this small (px²) with its neighbours is a step of the pixel grid. */
|
||
const STEP_AREA = 1;
|
||
|
||
/**
|
||
* The outlines of the solid islands, in the frame's own pixels. `alpha` holds one opacity 0 … 255
|
||
* per pixel, row by row. Islands are 4-connected: pixels meeting at a corner only are two islands.
|
||
*/
|
||
export function traceOutlines(
|
||
alpha: ArrayLike<number>,
|
||
width: number,
|
||
height: number,
|
||
options: TraceOptions,
|
||
): Vec2[][] {
|
||
const w = Math.floor(width);
|
||
const h = Math.floor(height);
|
||
if (w <= 0 || h <= 0 || alpha.length < w * h) return [];
|
||
const threshold = Math.min(1, Math.max(0, Number.isFinite(options.threshold) ? options.threshold : 0.5));
|
||
const cut = Math.max(1, Math.ceil(threshold * 255 - 1e-9));
|
||
const solid = new Uint8Array(w * h);
|
||
for (let i = 0; i < w * h; i++) solid[i] = alpha[i]! >= cut ? 1 : 0;
|
||
|
||
const islands = islandsOf(solid, w, h);
|
||
if (islands.length === 0) return [];
|
||
const largest = Math.max(...islands.map((island) => island.area));
|
||
const maxPoints = clampPoints(options.maxPoints);
|
||
return islands
|
||
.filter((island) => island.area === largest || island.area >= CRUMB_SHARE * w * h)
|
||
.map((island) => {
|
||
const walk = walkRound(solid, w, h, island.start % w, Math.floor(island.start / w));
|
||
const outline = simplify(walk, maxPoints);
|
||
const kept = outline.length <= maxPoints && !polygonProblem(outline);
|
||
return kept ? outline : simplify(convexHull(walk), maxPoints);
|
||
});
|
||
}
|
||
|
||
/** The opacities of `rect` out of RGBA bytes of a picture `sheetWidth` wide; outside it is clear. */
|
||
export function alphaOfRect(
|
||
rgba: ArrayLike<number>,
|
||
sheetWidth: number,
|
||
sheetHeight: number,
|
||
rect: { x: number; y: number; w: number; h: number },
|
||
): Uint8Array {
|
||
const alpha = new Uint8Array(rect.w * rect.h);
|
||
for (let y = 0; y < rect.h; y++)
|
||
for (let x = 0; x < rect.w; x++) {
|
||
const sx = rect.x + x;
|
||
const sy = rect.y + y;
|
||
if (sx >= 0 && sy >= 0 && sx < sheetWidth && sy < sheetHeight)
|
||
alpha[y * rect.w + x] = rgba[(sy * sheetWidth + sx) * 4 + 3]!;
|
||
}
|
||
return alpha;
|
||
}
|
||
|
||
function clampPoints(value: number): number {
|
||
const n = Math.round(Number.isFinite(value) ? value : DEFAULT_TRACE.maxPoints);
|
||
return Math.min(MAX_TRACE_POINTS, Math.max(MIN_TRACE_POINTS, n));
|
||
}
|
||
|
||
interface Island {
|
||
/** Index of its first pixel in reading order – the top-left corner of that pixel is on its rim. */
|
||
start: number;
|
||
area: number;
|
||
}
|
||
|
||
function islandsOf(solid: Uint8Array, w: number, h: number): Island[] {
|
||
const seen = new Uint8Array(w * h);
|
||
const islands: Island[] = [];
|
||
const stack: number[] = [];
|
||
for (let start = 0; start < w * h; start++) {
|
||
if (!solid[start] || seen[start]) continue;
|
||
seen[start] = 1;
|
||
stack.push(start);
|
||
let area = 0;
|
||
while (stack.length > 0) {
|
||
const i = stack.pop()!;
|
||
area++;
|
||
const x = i % w;
|
||
for (const j of [x > 0 ? i - 1 : -1, x < w - 1 ? i + 1 : -1, i - w, i + w])
|
||
if (j >= 0 && j < w * h && solid[j] && !seen[j]) {
|
||
seen[j] = 1;
|
||
stack.push(j);
|
||
}
|
||
}
|
||
islands.push({ start, area });
|
||
}
|
||
return islands;
|
||
}
|
||
|
||
/**
|
||
* The rim of the island whose first pixel is (sx, sy), as the corners where it turns, walked with
|
||
* the island on the right – clockwise on screen. Where two pixels meet at a corner only, the walk
|
||
* turns away from the diagonal one: it belongs to another island.
|
||
*/
|
||
function walkRound(solid: Uint8Array, w: number, h: number, sx: number, sy: number): Vec2[] {
|
||
const inside = (x: number, y: number) => x >= 0 && y >= 0 && x < w && y < h && solid[y * w + x] === 1;
|
||
const corners: Vec2[] = [];
|
||
let [x, y, dx, dy] = [sx, sy, 1, 0];
|
||
for (let guard = 4 * (w + 1) * (h + 1); guard > 0; guard--) {
|
||
x += dx;
|
||
y += dy;
|
||
const [rx, ry] = [-dy, dx];
|
||
const right = inside(Math.floor(x + (dx + rx) / 2), Math.floor(y + (dy + ry) / 2));
|
||
const left = inside(Math.floor(x + (dx - rx) / 2), Math.floor(y + (dy - ry) / 2));
|
||
if (right && left) [dx, dy] = [dy, -dx];
|
||
else if (!right) [dx, dy] = [-dy, dx];
|
||
if (!right || left) corners.push({ x, y });
|
||
if (x === sx && y === sy && dx === 1 && dy === 0) break;
|
||
}
|
||
return corners;
|
||
}
|
||
|
||
function triangleArea(a: Vec2, b: Vec2, c: Vec2): number {
|
||
return Math.abs((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)) / 2;
|
||
}
|
||
|
||
/**
|
||
* Takes away the corner that changes the outline least (Visvalingam–Whyatt) until at most
|
||
* `maxPoints` are left and none is a mere step of the pixel grid. A corner whose removal would
|
||
* make the outline cross itself stays.
|
||
*/
|
||
function simplify(ring: readonly Vec2[], maxPoints: number): Vec2[] {
|
||
const points = [...ring];
|
||
const areaAt = (i: number) =>
|
||
triangleArea(
|
||
points[(i + points.length - 1) % points.length]!,
|
||
points[i]!,
|
||
points[(i + 1) % points.length]!,
|
||
);
|
||
const areas = points.map((_, i) => areaAt(i));
|
||
while (points.length > MIN_TRACE_POINTS) {
|
||
const refused = new Set<number>();
|
||
let removed = false;
|
||
for (;;) {
|
||
let best = -1;
|
||
for (let i = 0; i < points.length; i++)
|
||
if (!refused.has(i) && (best < 0 || areas[i]! < areas[best]!)) best = i;
|
||
if (best < 0 || (points.length <= maxPoints && areas[best]! >= STEP_AREA)) break;
|
||
if (!removable(points, best)) {
|
||
refused.add(best);
|
||
continue;
|
||
}
|
||
points.splice(best, 1);
|
||
areas.splice(best, 1);
|
||
const before = (best + points.length - 1) % points.length;
|
||
areas[before] = areaAt(before);
|
||
areas[best % points.length] = areaAt(best % points.length);
|
||
removed = true;
|
||
break;
|
||
}
|
||
if (!removed) break;
|
||
}
|
||
return points;
|
||
}
|
||
|
||
/** Whether the edge that replaces corner `i` stays clear of every edge it does not share a corner with. */
|
||
function removable(points: readonly Vec2[], i: number): boolean {
|
||
const n = points.length;
|
||
const a = (i + n - 1) % n;
|
||
const c = (i + 1) % n;
|
||
const from = points[a]!;
|
||
const to = points[c]!;
|
||
for (let j = 0; j < n; j++) {
|
||
if (j === (a + n - 1) % n || j === a || j === i || j === c) continue;
|
||
if (segmentsMeet(from, to, points[j]!, points[(j + 1) % n]!)) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/** The hull of the points – the outline of last resort for a pinched island. */
|
||
function convexHull(points: readonly Vec2[]): Vec2[] {
|
||
const sorted = [...points].sort((a, b) => a.x - b.x || a.y - b.y);
|
||
const turn = (o: Vec2, a: Vec2, b: Vec2) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
|
||
const half = (list: Vec2[]) => {
|
||
const out: Vec2[] = [];
|
||
for (const p of list) {
|
||
while (out.length >= 2 && turn(out[out.length - 2]!, out[out.length - 1]!, p) <= 0) out.pop();
|
||
out.push(p);
|
||
}
|
||
out.pop();
|
||
return out;
|
||
};
|
||
return [...half(sorted), ...half([...sorted].reverse())];
|
||
}
|