Refactoring round 6: shared sheet canvas, fewer copies (#85)
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>
This commit is contained in:
1 parent
d7c0b7cfdb
commit
056a622524
51 files changed
+393
-525
No files matched your search
@@ -8,6 +8,7 @@ import { instanceChildId, resolveObjects } from './instance';
|
||||
import { findAsset, type Project } from './Project';
|
||||
import { colorOf, type PropertyBag, type PropertyValue, textOf } from './Properties';
|
||||
import type { SceneObject } from './SceneObject';
|
||||
import { mixColor } from '../util/color';
|
||||
|
||||
/** `once` holds the last value when it ends, `once-reset` puts back what was there before it started. */
|
||||
export const PLAYBACKS = ['loop', 'once', 'once-reset', 'pingpong'] as const;
|
||||
@@ -103,15 +104,6 @@ export function blends(kind: string): boolean {
|
||||
return kind === 'number' || kind === 'color';
|
||||
}
|
||||
|
||||
function lerpColor(a: number, b: number, t: number): number {
|
||||
const channel = (shift: number) => {
|
||||
const from = (a >> shift) & 0xff;
|
||||
const to = (b >> shift) & 0xff;
|
||||
return Math.round(from + (to - from) * t) << shift;
|
||||
};
|
||||
return channel(16) | channel(8) | channel(0);
|
||||
}
|
||||
|
||||
/** In the order the inspector offers them; `linear` first, since it is what an absent field means. */
|
||||
export const KEY_INTERPOLATIONS: readonly { value: KeyInterpolation; label: string }[] = [
|
||||
{ value: 'linear', label: 'Linear' },
|
||||
@@ -155,7 +147,7 @@ export function blendValue(kind: string, a: PropertyValue, b: PropertyValue, t:
|
||||
const to = Number(b);
|
||||
return Number.isFinite(from) && Number.isFinite(to) ? from + (to - from) * t : a;
|
||||
}
|
||||
if (kind === 'color') return lerpColor(colorOf(a, 0), colorOf(b, 0), t);
|
||||
if (kind === 'color') return mixColor(colorOf(a, 0), colorOf(b, 0), t);
|
||||
return a;
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,16 @@ export function generatedPictureOf(asset: Asset, project?: Project): GeneratedPi
|
||||
return picture.width > 0 && picture.height > 0 ? picture : null;
|
||||
}
|
||||
|
||||
/** FNV-1a over the text, in base 36: short, stable across sessions, and free of `@`. */
|
||||
export function fingerprint(text: string): string {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
hash ^= text.charCodeAt(i);
|
||||
hash = Math.imul(hash, 0x01000193) >>> 0;
|
||||
}
|
||||
return hash.toString(36);
|
||||
}
|
||||
|
||||
export function generatedKey(assetId: string, revision: string | number): string {
|
||||
return `${PREFIX}${assetId}@${revision}`;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
const MAGIC = [0x47, 0x4d, 0x50, 0x58];
|
||||
const VERSION = 1;
|
||||
export const CEL_HEADER_BYTES = 10;
|
||||
const CEL_HEADER_BYTES = 10;
|
||||
/** The largest side a canvas may have (answer 3). */
|
||||
export const PIXEL_CANVAS_MAX = 512;
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ export function pixelArtState(asset: PixelArtAsset): PixelArtState {
|
||||
return cloneState(asset);
|
||||
}
|
||||
|
||||
export function cloneState(state: PixelArtState): PixelArtState {
|
||||
function cloneState(state: PixelArtState): PixelArtState {
|
||||
return {
|
||||
width: state.width,
|
||||
height: state.height,
|
||||
@@ -52,7 +52,7 @@ function frameIndex(state: PixelArtState, frameId: string): number {
|
||||
}
|
||||
|
||||
/** "Layer n" with the first number no layer uses yet. */
|
||||
export function nextLayerName(state: PixelArtState): string {
|
||||
function nextLayerName(state: PixelArtState): string {
|
||||
const taken = new Set(state.layers.map((layer) => layer.name));
|
||||
for (let n = state.layers.length + 1; ; n++) if (!taken.has(`Layer ${n}`)) return `Layer ${n}`;
|
||||
}
|
||||
@@ -229,6 +229,6 @@ export function withCelsReplaced(
|
||||
return next;
|
||||
}
|
||||
|
||||
export function isCanvasSide(side: number): boolean {
|
||||
function isCanvasSide(side: number): boolean {
|
||||
return Number.isInteger(side) && side >= 1 && side <= PIXEL_CANVAS_MAX;
|
||||
}
|
||||
@@ -5,9 +5,9 @@
|
||||
* files (see `pixelCel.ts`); whoever composes a frame is handed a way to read them.
|
||||
*/
|
||||
import type { Asset, PaletteAsset, PixelArtAsset, PixelFrame, PixelLayer } from './Asset';
|
||||
import type { GeneratedPicture } from './generated';
|
||||
import { fingerprint, type GeneratedPicture } from './generated';
|
||||
import { type CelPixels, PIXEL_CANVAS_MAX } from './pixelCel';
|
||||
import { findAsset, findAssetOf, type Project } from './Project';
|
||||
import { findAssetOf, type Project } from './Project';
|
||||
import { textOf } from './Properties';
|
||||
|
||||
export const PALETTE_KEY = 'palette';
|
||||
@@ -153,15 +153,6 @@ export function pixelArtRevision(asset: PixelArtAsset, colors: readonly number[]
|
||||
);
|
||||
}
|
||||
|
||||
function fingerprint(text: string): string {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
hash ^= text.charCodeAt(i);
|
||||
hash = Math.imul(hash, 0x01000193) >>> 0;
|
||||
}
|
||||
return hash.toString(36);
|
||||
}
|
||||
|
||||
/** What the picture path is told (#185): the sheet of all frames side by side. */
|
||||
export function pixelArtPicture(asset: Asset, project?: Project): GeneratedPicture | null {
|
||||
if (asset.type !== 'pixelart' || asset.frames.length === 0) return null;
|
||||
@@ -213,8 +204,3 @@ export function pixelArtWornId(asset: PixelArtAsset): string {
|
||||
export function isPixelArt(asset: Asset | undefined | null): asset is PixelArtAsset {
|
||||
return asset?.type === 'pixelart';
|
||||
}
|
||||
|
||||
export function findPixelArt(project: Project, assetId: string): PixelArtAsset | null {
|
||||
const asset = findAsset(project, assetId);
|
||||
return isPixelArt(asset) ? asset : null;
|
||||
}
|
||||
@@ -53,7 +53,7 @@ export interface NineSlice {
|
||||
export const SLICE_SCALE_KEY = 'sliceScale';
|
||||
|
||||
/** The texture's nine-slice scale; anything that is not a size above zero reads as the default 1. */
|
||||
export function sliceScaleOf(asset: TextureAsset): number {
|
||||
function sliceScaleOf(asset: TextureAsset): number {
|
||||
const value = Number(asset.props[SLICE_SCALE_KEY] ?? 1);
|
||||
return Number.isFinite(value) && value > 0 ? value : 1;
|
||||
}
|
||||
@@ -129,11 +129,11 @@ function refFor(asset: TextureAsset, frame: TextureFrame | null): TextureRef | n
|
||||
};
|
||||
}
|
||||
|
||||
export const NO_BORDERS: FrameBorders = { left: 0, top: 0, right: 0, bottom: 0 };
|
||||
const NO_BORDERS: FrameBorders = { left: 0, top: 0, right: 0, bottom: 0 };
|
||||
export const DEFAULT_FILL: FrameFill = 'repeat';
|
||||
|
||||
/** Whole pixels of the frame that must stay between two opposite borders (see `normalizeBorders`). */
|
||||
export const MIN_SLICE_MIDDLE = 1;
|
||||
const MIN_SLICE_MIDDLE = 1;
|
||||
|
||||
/** The frame's collision outlines (#141), a copy; empty when it has none. */
|
||||
export function frameCollision(frame: TextureFrame): Vec2[][] {
|
||||
@@ -158,12 +158,12 @@ export function frameFill(frame: TextureFrame): FrameFill {
|
||||
return frame.fill === 'stretch' ? 'stretch' : DEFAULT_FILL;
|
||||
}
|
||||
|
||||
export function hasBorders(border: FrameBorders): boolean {
|
||||
function hasBorders(border: FrameBorders): boolean {
|
||||
return border.left > 0 || border.top > 0 || border.right > 0 || border.bottom > 0;
|
||||
}
|
||||
|
||||
/** What a frame is sliced into, or null when nobody sliced it – then a sprite draws it as one piece. */
|
||||
export function nineSliceOf(frame: TextureFrame, scale = 1): NineSlice | null {
|
||||
function nineSliceOf(frame: TextureFrame, scale = 1): NineSlice | null {
|
||||
const border = normalizeBorders(frameBorders(frame), frame.w, frame.h);
|
||||
if (!hasBorders(border)) return null;
|
||||
return { border, fill: frameFill(frame), sourceW: frame.w, sourceH: frame.h, scale };
|
||||
|
||||
@@ -72,7 +72,7 @@ export function alphaOfRect(
|
||||
return alpha;
|
||||
}
|
||||
|
||||
export function clampPoints(value: number): number {
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Keeping bezier curves through a boolean operation (#208). Clipping itself only knows straight
|
||||
* pieces, so a curve has to be cut into them – and until now the result was corners only, even where
|
||||
* nothing had crossed the curve at all.
|
||||
* pieces, so a curve has to be cut into them – and on its own the result would be corners only, even
|
||||
* where nothing crossed the curve at all.
|
||||
*
|
||||
* The way back is to remember where every sampled point came from: which cubic, and at which `t` on
|
||||
* it. A run of result points that walks one cubic in one direction is then that cubic again, cut at
|
||||
@@ -222,7 +222,7 @@ function lerp(a: Vec2, b: Vec2, t: number): Vec2 {
|
||||
return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
|
||||
}
|
||||
|
||||
export function cubicAt(segment: { p0: Vec2; p1: Vec2; p2: Vec2; p3: Vec2 }, t: number): Vec2 {
|
||||
function cubicAt(segment: { p0: Vec2; p1: Vec2; p2: Vec2; p3: Vec2 }, t: number): Vec2 {
|
||||
const u = 1 - t;
|
||||
const a = u * u * u;
|
||||
const b = 3 * u * u * t;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { isGeneratedFile } from './generated';
|
||||
import type { Project } from './Project';
|
||||
import type { Vec2 } from './Properties';
|
||||
import { resolvePicture, type TextureRef } from './texture';
|
||||
import { mixColor } from '../util/color';
|
||||
|
||||
/** What the fill of a shape is made of; `solid` is the shape's own `fill` colour. */
|
||||
export type FillKind = 'solid' | 'linear' | 'radial' | 'texture';
|
||||
@@ -120,15 +121,6 @@ export function colorAt(stops: readonly GradientStop[], offset: number): { color
|
||||
return { color: last.color, alpha: last.alpha };
|
||||
}
|
||||
|
||||
function mixColor(a: number, b: number, t: number): number {
|
||||
const channel = (shift: number): number => {
|
||||
const from = (a >> shift) & 0xff;
|
||||
const to = (b >> shift) & 0xff;
|
||||
return Math.round(from + (to - from) * t) << shift;
|
||||
};
|
||||
return channel(16) | channel(8) | channel(0);
|
||||
}
|
||||
|
||||
/** A stop added at `offset`, in the colour the gradient already has there; it goes last in the list. */
|
||||
export function withStopAdded(gradient: VectorGradient, offset: number): VectorGradient {
|
||||
const at = clamp01(offset);
|
||||
|
||||
@@ -97,7 +97,7 @@ export function shapeDrawing(shape: VectorShape, project?: Project): ShapeDrawin
|
||||
* than two stops or no length, a picture that is not there or a tile of no size. A shape that went
|
||||
* blank because its texture was deleted would be lost on the canvas; its colour keeps it findable.
|
||||
*/
|
||||
export function drawPaint(paint: VectorPaint, project?: Project): DrawPaint | null {
|
||||
function drawPaint(paint: VectorPaint, project?: Project): DrawPaint | null {
|
||||
const from = { x: paint.from.x, y: paint.from.y };
|
||||
const to = { x: paint.to.x, y: paint.to.y };
|
||||
if (paint.type === 'texture') {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* lies outside the canvas is cut off (answer 3) – otherwise a sprite's size would be a lie.
|
||||
*/
|
||||
import type { Asset, VectorArtAsset, VectorPoint, VectorRing, VectorShape } from './Asset';
|
||||
import type { GeneratedPicture } from './generated';
|
||||
import { fingerprint, type GeneratedPicture } from './generated';
|
||||
import type { Project } from './Project';
|
||||
import type { PropertyBag, Vec2 } from './Properties';
|
||||
import { paintText, textureFillText } from './vectorPaint';
|
||||
@@ -45,10 +45,6 @@ export function isDrawableRing(ring: VectorRing): boolean {
|
||||
return ring.points.length >= (ring.closed ? 3 : 2);
|
||||
}
|
||||
|
||||
export function isDrawableShape(shape: VectorShape): boolean {
|
||||
return shape.rings.some(isDrawableRing);
|
||||
}
|
||||
|
||||
/** How far a shape reaches, in canvas pixels, handles included; null when it has no point at all. */
|
||||
export function shapeExtent(shape: VectorShape): { x0: number; y0: number; x1: number; y1: number } | null {
|
||||
let x0 = Infinity;
|
||||
@@ -114,16 +110,6 @@ function handleText(handle: Vec2 | undefined): string {
|
||||
return handle ? `${handle.x},${handle.y}` : '-';
|
||||
}
|
||||
|
||||
/** FNV-1a over the text, in base 36: short, stable across sessions, and free of `@` (see #185). */
|
||||
function fingerprint(text: string): string {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
hash ^= text.charCodeAt(i);
|
||||
hash = Math.imul(hash, 0x01000193) >>> 0;
|
||||
}
|
||||
return hash.toString(36);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the picture path is told about a vector art asset (#185). An asset without a drawable shape
|
||||
* still has a canvas: an empty picture is what an empty drawing looks like, and a sprite wearing it
|
||||
|
||||
Reference in new issue
Block a user