From 8d9e1e499ce5aab1a8f9084149ad1b6f1cde2a62 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 16:06:12 +0000 Subject: [PATCH] perf: Replay mit stabiler Skalierung und wiederverwendeten Pfaden/Fills (#366) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Im Replay kam pro Frame ein neues paths-Array, und die Skalierung wurde aus den gerade sichtbaren Pfaden berechnet (änderte sich pro Frame). Dadurch liefen alle Flood-Fills in jedem Frame neu (web: getImageData/putImageData über die Fläche, native: computeNativeFillLayers). - DrawingCanvas (web + native): neues Prop scaleReferencePaths; Bounding Box und Skalierung kommen aus der gesamten Zeichnung. ResultPhase übergibt drawingPaths -> die Zeichnung erscheint von Anfang an in Endgröße - web: gerenderter Stand aller Pfade außer dem letzten wird in einem Offscreen-Canvas gecacht und wiederverwendet, solange die Pfade referenzgleich und Größe/Skalierung unverändert sind - native: computeNativeFillLayersIncremental (Cache aus Pixelpuffer + Layern, letzter Pfad auf einer Puffer-Kopie); Ergebnis identisch zur Komplettberechnung - Tests: Service-Äquivalenz für wachsende Präfixe/Replay/Skalierungswechsel/ Undo, Web-Tests für Flood-Fill-Anzahl und stabile Skalierung Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_017fxbsNALrmwMj7LGMDPLw2 --- CHANGELOG.md | 1 + components/DrawingCanvas.native.tsx | 33 ++++- components/DrawingCanvas.web.tsx | 137 +++++++++++++----- .../__tests__/DrawingCanvas.web.test.tsx | 106 +++++++++++++- components/game/ResultPhase.tsx | 1 + services/NativeFillLayerService.ts | 109 ++++++++++++-- .../__tests__/NativeFillLayerService.test.ts | 122 +++++++++++++++- 7 files changed, 454 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0daa457..c78a60c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Replay: Die Skalierung der Zeichnung wird aus der **gesamten** Zeichnung berechnet, nicht aus den gerade sichtbaren Pfaden — die Zeichnung erscheint von Anfang an in Endgröße und wandert nicht mehr mit. Dadurch können fertige Pfade und Fills in Web und Android zwischen den Frames wiederverwendet werden (pro Frame höchstens 2 statt N Flood-Fills) (#366) - Web-Zeichenfläche: Beim Malen wird nur noch der Live-Strich über einen gecachten Snapshot der fertigen Zeichnung gelegt. Vorher liefen bei jeder Mausbewegung alle Striche und alle Flood-Fills (volle `getImageData`/`putImageData`-Durchläufe) erneut (#358) ### Fixed diff --git a/components/DrawingCanvas.native.tsx b/components/DrawingCanvas.native.tsx index 0e423e1..eb6b7fb 100644 --- a/components/DrawingCanvas.native.tsx +++ b/components/DrawingCanvas.native.tsx @@ -11,7 +11,11 @@ import { t } from '@services/i18n'; import { styles, DEFAULT_CANVAS_WIDTH } from './DrawingCanvas.shared'; import type { DrawingPath } from './DrawingCanvas.shared'; import { captureException } from '@services/SentryService'; -import { computeNativeFillLayers, type NativeFillLayer } from '@services/NativeFillLayerService'; +import { + computeNativeFillLayersIncremental, + type NativeFillCache, + type NativeFillLayer, +} from '@services/NativeFillLayerService'; // Re-export shared API so '@components/DrawingCanvas' provides a complete module on native export type { DrawingPath } from './DrawingCanvas.shared'; @@ -66,7 +70,15 @@ function tryLoadSkia(): boolean { // Attempt initial load at module level tryLoadSkia(); +const EMPTY_PATHS: DrawingPath[] = []; + interface Props { + /** + * Pfade, aus denen die Anpassung der Zeichnung an die Fläche (Skalierung/Offset) berechnet wird. + * Standard: `paths`. Im Replay die vollständige Zeichnung übergeben, damit die Skalierung über + * alle Frames stabil bleibt und bereits berechnete Fills wiederverwendet werden können (#366). + */ + scaleReferencePaths?: DrawingPath[]; width?: number; height?: number; strokeColor?: string; @@ -194,7 +206,8 @@ export default function DrawingCanvas({ strokeColor = '#000000', strokeWidth = 3, tool = 'brush', - paths = [], + paths = EMPTY_PATHS, + scaleReferencePaths, onDrawingChange, }: Props) { const { width: windowWidth } = useWindowDimensions(); @@ -203,6 +216,7 @@ export default function DrawingCanvas({ const [nativePaths, setNativePaths] = useState(paths); const [currentNativePath, setCurrentNativePath] = useState<{ x: number; y: number }[]>([]); const [fillLayers, setFillLayers] = useState([]); + const fillCacheRef = useRef(null); useEffect(() => { setNativePaths(paths); @@ -253,7 +267,7 @@ export default function DrawingCanvas({ const getScaledPaths = () => { // Exclude fill paths from bounding box – they only have a single click point - const strokePaths = nativePaths.filter(p => p.type !== 'fill'); + const strokePaths = (scaleReferencePaths ?? nativePaths).filter(p => p.type !== 'fill'); if (strokePaths.length === 0) return { scale: 1, offsetX: 0, offsetY: 0 }; let minX = Infinity, @@ -297,13 +311,24 @@ export default function DrawingCanvas({ // Recompute native fill layers whenever fill paths change. useEffect(() => { if (!hasFillPaths) { + fillCacheRef.current = null; setFillLayers([]); return; } try { - const layers = computeNativeFillLayers(nativePaths, width, height, scale, offsetX, offsetY); + const { layers, cache } = computeNativeFillLayersIncremental( + nativePaths, + width, + height, + scale, + offsetX, + offsetY, + fillCacheRef.current, + ); + fillCacheRef.current = cache; setFillLayers(layers); } catch (e) { + fillCacheRef.current = null; captureException(e instanceof Error ? e : new Error(String(e)), { component: 'DrawingCanvas', operation: 'computeNativeFillLayers', diff --git a/components/DrawingCanvas.web.tsx b/components/DrawingCanvas.web.tsx index b9e426b..79bad39 100644 --- a/components/DrawingCanvas.web.tsx +++ b/components/DrawingCanvas.web.tsx @@ -11,6 +11,12 @@ export { useDrawingCanvas } from './DrawingCanvas.hooks'; const EMPTY_PATHS: DrawingPath[] = []; interface Props { + /** + * Pfade, aus denen die Anpassung der Zeichnung an die Fläche (Skalierung/Offset) berechnet wird. + * Standard: `paths`. Im Replay die vollständige Zeichnung übergeben, damit die Skalierung über + * alle Frames stabil bleibt und bereits gerenderte Pfade wiederverwendet werden können (#366). + */ + scaleReferencePaths?: DrawingPath[]; width?: number; height?: number; strokeColor?: string; @@ -20,6 +26,45 @@ interface Props { onDrawingChange?: (paths: DrawingPath[]) => void; } +function drawPath( + ctx: CanvasRenderingContext2D, + path: DrawingPath, + width: number, + height: number, + scale: number, + offsetX: number, + offsetY: number, +) { + if (path.type === 'fill' && path.points.length > 0) { + const point = { + x: path.points[0].x * scale + offsetX, + y: path.points[0].y * scale + offsetY, + }; + const imageData = ctx.getImageData(0, 0, width, height); + const changed = floodFillPixels( + imageData.data, + width, + height, + point.x, + point.y, + hexToRgb(path.color), + ); + if (changed) ctx.putImageData(imageData, 0, 0); + } else if (path.points.length >= 2) { + ctx.strokeStyle = path.color; + ctx.lineWidth = path.strokeWidth * scale; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + + ctx.beginPath(); + ctx.moveTo(path.points[0].x * scale + offsetX, path.points[0].y * scale + offsetY); + for (let i = 1; i < path.points.length; i++) { + ctx.lineTo(path.points[i].x * scale + offsetX, path.points[i].y * scale + offsetY); + } + ctx.stroke(); + } +} + export default function DrawingCanvas({ width: propWidth, height = 400, @@ -27,6 +72,7 @@ export default function DrawingCanvas({ strokeWidth = 3, tool = 'brush', paths = EMPTY_PATHS, + scaleReferencePaths, onDrawingChange, }: Props) { const { width: windowWidth } = useWindowDimensions(); @@ -37,6 +83,13 @@ export default function DrawingCanvas({ const currentPathRef = useRef<{ x: number; y: number }[]>([]); const canvasRef = useRef(null); const snapshotRef = useRef(null); + // Gerenderter Stand aller Pfade außer dem letzten ("committed"). Wächst `paths` nur am Ende + // (Malen, Replay), wird dieser Stand wiederverwendet statt alle Fills neu zu berechnen (#366). + const committedRef = useRef<{ + canvas: HTMLCanvasElement; + paths: DrawingPath[]; + key: string; + } | null>(null); const floodFill = (startX: number, startY: number, fillColor: string) => { if (!canvasRef.current) return; @@ -64,19 +117,16 @@ export default function DrawingCanvas({ const ctx = canvas.getContext('2d', { willReadFrequently: true }); if (!ctx) return; - ctx.clearRect(0, 0, width, height); - ctx.fillStyle = '#FFFFFF'; - ctx.fillRect(0, 0, width, height); - + const referencePaths = scaleReferencePaths ?? paths; let scale = 1, offsetX = 0, offsetY = 0; - if (!onDrawingChange && paths.length > 0) { + if (!onDrawingChange && referencePaths.length > 0) { let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; - paths.forEach(path => { + referencePaths.forEach(path => { if (path.type === 'fill') return; path.points.forEach(point => { minX = Math.min(minX, point.x); @@ -102,36 +152,51 @@ export default function DrawingCanvas({ } } - paths.forEach(path => { - if (path.type === 'fill' && path.points.length > 0) { - const point = { - x: path.points[0].x * scale + offsetX, - y: path.points[0].y * scale + offsetY, - }; - const imageData = ctx.getImageData(0, 0, width, height); - const changed = floodFillPixels( - imageData.data, - width, - height, - point.x, - point.y, - hexToRgb(path.color), - ); - if (changed) ctx.putImageData(imageData, 0, 0); - } else if (path.points.length >= 2) { - ctx.strokeStyle = path.color; - ctx.lineWidth = path.strokeWidth * scale; - ctx.lineCap = 'round'; - ctx.lineJoin = 'round'; - - ctx.beginPath(); - ctx.moveTo(path.points[0].x * scale + offsetX, path.points[0].y * scale + offsetY); - for (let i = 1; i < path.points.length; i++) { - ctx.lineTo(path.points[i].x * scale + offsetX, path.points[i].y * scale + offsetY); - } - ctx.stroke(); + // Der letzte Pfad gilt als "flüchtig" (im Replay ist er der gerade wachsende Strich) und wird + // nicht gecacht; alle davor werden einmal gerendert und wiederverwendet, solange sie + // referenzgleich bleiben und sich Größe/Skalierung nicht ändern. + const key = `${width}x${height}|${scale}|${offsetX}|${offsetY}`; + const committed = paths.slice(0, -1); + const last = paths.length > 0 ? paths[paths.length - 1] : null; + + const cache = committedRef.current; + let start = 0; + let reused = false; + ctx.clearRect(0, 0, width, height); + if ( + cache && + cache.key === key && + cache.paths.length <= committed.length && + cache.paths.every((p, i) => p === committed[i]) + ) { + ctx.drawImage(cache.canvas, 0, 0); + start = cache.paths.length; + reused = true; + } else { + ctx.fillStyle = '#FFFFFF'; + ctx.fillRect(0, 0, width, height); + } + + for (let i = start; i < committed.length; i++) { + drawPath(ctx, committed[i], width, height, scale, offsetX, offsetY); + } + + if (committed.length === 0) { + committedRef.current = null; + } else if (!reused || start !== committed.length) { + // Neuer committed-Stand: Kopie der Zeichenfläche (ohne den flüchtigen letzten Pfad) merken + const store = cache?.canvas ?? document.createElement('canvas'); // platform-safe + if (store.width !== width || store.height !== height) { + store.width = width; + store.height = height; } - }); + const storeCtx = store.getContext('2d'); + storeCtx?.clearRect(0, 0, width, height); + storeCtx?.drawImage(canvas, 0, 0); + committedRef.current = { canvas: store, paths: committed, key }; + } + + if (last) drawPath(ctx, last, width, height, scale, offsetX, offsetY); // Snapshot der fertigen Zeichnung für den Live-Strich-Effekt let snapshot = snapshotRef.current; @@ -142,7 +207,7 @@ export default function DrawingCanvas({ snapshot.width = width; snapshot.height = height; snapshot.getContext('2d')?.drawImage(canvas, 0, 0); - }, [paths, width, height, onDrawingChange]); + }, [paths, scaleReferencePaths, width, height, onDrawingChange]); // Live-Strich: Snapshot zurückkopieren und nur den aktuellen Strich darüberzeichnen. // Läuft nach dem Basis-Effekt (gleiche Render-Runde → Snapshot ist aktuell). diff --git a/components/__tests__/DrawingCanvas.web.test.tsx b/components/__tests__/DrawingCanvas.web.test.tsx index f737611..0615fa9 100644 --- a/components/__tests__/DrawingCanvas.web.test.tsx +++ b/components/__tests__/DrawingCanvas.web.test.tsx @@ -4,11 +4,14 @@ import type { DrawingPath } from '../DrawingCanvas.shared'; // Fake-2D-Kontext, der Aufrufe zählt (jsdom implementiert Canvas nicht). const ctxCalls = { getImageData: 0, putImageData: 0, drawImage: 0, stroke: 0 }; +const moveToCalls: number[][] = []; const fakeCtx = { clearRect: jest.fn(), fillRect: jest.fn(), beginPath: jest.fn(), - moveTo: jest.fn(), + moveTo: jest.fn((x: number, y: number) => { + moveToCalls.push([x, y]); + }), lineTo: jest.fn(), stroke: jest.fn(() => { ctxCalls.stroke++; @@ -36,6 +39,7 @@ describe('DrawingCanvas.web — Live-Strich nutzt Snapshot statt Komplett-Redraw let getContextSpy: jest.SpyInstance; beforeEach(() => { + moveToCalls.length = 0; Object.keys(ctxCalls).forEach(k => ((ctxCalls as Record)[k] = 0)); getContextSpy = jest .spyOn(HTMLCanvasElement.prototype, 'getContext') @@ -117,4 +121,104 @@ describe('DrawingCanvas.web — Live-Strich nutzt Snapshot statt Komplett-Redraw expect(newPaths).toHaveLength(1); expect(newPaths[0].points.length).toBeGreaterThan(1); }); + + describe('Replay (#366)', () => { + const fills = (n: number): DrawingPath[] => + Array.from({ length: n }, (_, i) => ({ + points: [{ x: 5 + i, y: 5 }], + color: '#FF0000', + strokeWidth: 0, + type: 'fill' as const, + })); + + it('berechnet bei wachsenden Pfaden pro Frame höchstens 2 Flood-Fills statt alle neu', () => { + const all = fills(10); + const { rerender } = render( + , + ); + for (let k = 2; k <= all.length; k++) { + rerender( + , + ); + } + // Ohne Cache: 1+2+…+10 = 55 Flood-Fills. Mit Cache: 1 + 2·9 = 19. + expect(ctxCalls.getImageData).toBeLessThanOrEqual(19); + }); + + it('hält die Skalierung über alle Frames stabil (scaleReferencePaths)', () => { + const a: DrawingPath = { + points: [ + { x: 0, y: 0 }, + { x: 50, y: 50 }, + ], + color: '#000', + strokeWidth: 3, + type: 'stroke', + }; + const b: DrawingPath = { + points: [ + { x: 0, y: 0 }, + { x: 400, y: 400 }, + ], + color: '#000', + strokeWidth: 3, + type: 'stroke', + }; + + // Frame 1: nur a sichtbar, Skalierung aus der Gesamtzeichnung [a, b] + render(); + const firstFrameStart = moveToCalls[0]; + + moveToCalls.length = 0; + // Endzustand: beide sichtbar, derselbe Pfad a muss an derselben Stelle starten + render(); + expect(moveToCalls[0]).toEqual(firstFrameStart); + }); + + it('zeichnet bereits gerenderte Striche im Replay nicht jedes Mal erneut', () => { + const stroke = (x: number): DrawingPath => ({ + points: [ + { x, y: 0 }, + { x, y: 90 }, + ], + color: '#000', + strokeWidth: 3, + type: 'stroke', + }); + const all: DrawingPath[] = [stroke(10), stroke(30), stroke(50), stroke(70)]; + + // Inkrementell + const { rerender } = render( + , + ); + for (let k = 2; k <= all.length; k++) { + rerender( + , + ); + } + // Pro Frame wird höchstens der neue Pfad (committed) + der letzte neu gezeichnet: + // insgesamt sehr viel weniger als bei vollständigem Neuzeichnen (1+2+3+4 = 10 Striche). + expect(ctxCalls.stroke).toBeLessThanOrEqual(1 + 2 * 3); + }); + }); }); diff --git a/components/game/ResultPhase.tsx b/components/game/ResultPhase.tsx index 35e37ea..1ca5ba0 100644 --- a/components/game/ResultPhase.tsx +++ b/components/game/ResultPhase.tsx @@ -149,6 +149,7 @@ export default function ResultPhase({ width={imageSize} height={imageSize} paths={isReplaying ? replayPaths : drawingPaths} + scaleReferencePaths={drawingPaths} strokeColor={Colors.drawing.black} strokeWidth={2} /> diff --git a/services/NativeFillLayerService.ts b/services/NativeFillLayerService.ts index ba3509e..0733ab3 100644 --- a/services/NativeFillLayerService.ts +++ b/services/NativeFillLayerService.ts @@ -7,6 +7,35 @@ export interface NativeFillLayer { spans: FloodFillSpan[]; } +/** + * Wendet einen Pfad auf den Pixelpuffer an: Striche werden gerastert, Fills per Flood-Fill + * angewendet (und als Span-Layer zurückgegeben). + */ +function applyPath( + buffer: Uint8ClampedArray, + path: DrawingPath, + w: number, + h: number, + scale: number, + offsetX: number, + offsetY: number, + fillLayers: NativeFillLayer[], +): void { + if (path.type === 'fill' && path.points.length > 0) { + const fillX = path.points[0].x * scale + offsetX; + const fillY = path.points[0].y * scale + offsetY; + const spans: FloodFillSpan[] = []; + const changed = floodFillPixels(buffer, w, h, fillX, fillY, hexToRgb(path.color), spans); + + if (changed && spans.length > 0) { + fillLayers.push({ color: path.color, spans }); + } + return; + } + + rasterizePathIntoBuffer(buffer, path, w, h, scale, offsetX, offsetY); +} + export function computeNativeFillLayers( paths: DrawingPath[], width: number, @@ -23,20 +52,74 @@ export function computeNativeFillLayers( const fillLayers: NativeFillLayer[] = []; for (const path of paths) { - if (path.type === 'fill' && path.points.length > 0) { - const fillX = path.points[0].x * scale + offsetX; - const fillY = path.points[0].y * scale + offsetY; - const spans: FloodFillSpan[] = []; - const changed = floodFillPixels(buffer, w, h, fillX, fillY, hexToRgb(path.color), spans); - - if (changed && spans.length > 0) { - fillLayers.push({ color: path.color, spans }); - } - continue; - } - - rasterizePathIntoBuffer(buffer, path, w, h, scale, offsetX, offsetY); + applyPath(buffer, path, w, h, scale, offsetX, offsetY, fillLayers); } return fillLayers; } + +/** Zwischenstand für {@link computeNativeFillLayersIncremental}. */ +export interface NativeFillCache { + key: string; + /** Bereits angewendete Pfade (alle außer dem jeweils letzten der letzten Berechnung). */ + paths: DrawingPath[]; + /** Pixelpuffer nach Anwendung von `paths`. */ + buffer: Uint8ClampedArray; + /** Fill-Layer, die aus `paths` entstanden sind. */ + layers: NativeFillLayer[]; +} + +/** + * Wie {@link computeNativeFillLayers}, aber mit Wiederverwendung: Der letzte Pfad gilt als + * "flüchtig" (im Replay der gerade wachsende Strich) und wird auf einer Kopie des Puffers + * angewendet. Alle davor bleiben im Cache, solange sie referenzgleich und Größe/Skalierung + * unverändert sind — ein Replay mit N Fills braucht so pro Frame höchstens 2 statt N Flood-Fills. + * Das Ergebnis ist identisch zu `computeNativeFillLayers(paths, ...)` (Issue #366). + */ +export function computeNativeFillLayersIncremental( + paths: DrawingPath[], + width: number, + height: number, + scale: number, + offsetX: number, + offsetY: number, + cache: NativeFillCache | null, +): { layers: NativeFillLayer[]; cache: NativeFillCache } { + const w = Math.floor(width); + const h = Math.floor(height); + const key = `${w}x${h}|${scale}|${offsetX}|${offsetY}`; + const committed = paths.slice(0, -1); + const last = paths.length > 0 ? paths[paths.length - 1] : null; + + let buffer: Uint8ClampedArray; + let layers: NativeFillLayer[]; + let start: number; + if ( + cache && + cache.key === key && + cache.paths.length <= committed.length && + cache.paths.every((p, i) => p === committed[i]) + ) { + buffer = cache.buffer; + layers = cache.layers; + start = cache.paths.length; + } else { + buffer = createWhitePixelBuffer(w, h); + layers = []; + start = 0; + } + + for (let i = start; i < committed.length; i++) { + applyPath(buffer, committed[i], w, h, scale, offsetX, offsetY, layers); + } + + const nextCache: NativeFillCache = { key, paths: committed, buffer, layers }; + + const resultLayers = layers.slice(); + if (last) { + // Flüchtigen letzten Pfad auf einer Kopie anwenden, damit der Cache unberührt bleibt + applyPath(buffer.slice(), last, w, h, scale, offsetX, offsetY, resultLayers); + } + + return { layers: resultLayers, cache: nextCache }; +} diff --git a/services/__tests__/NativeFillLayerService.test.ts b/services/__tests__/NativeFillLayerService.test.ts index 495feff..0211a9f 100644 --- a/services/__tests__/NativeFillLayerService.test.ts +++ b/services/__tests__/NativeFillLayerService.test.ts @@ -1,5 +1,9 @@ import type { DrawingPath } from '../../components/DrawingCanvas.shared'; -import { computeNativeFillLayers } from '../NativeFillLayerService'; +import { + computeNativeFillLayers, + computeNativeFillLayersIncremental, + type NativeFillCache, +} from '../NativeFillLayerService'; describe('computeNativeFillLayers', () => { it('builds rect spans for a bounded fill region', () => { @@ -122,3 +126,119 @@ describe('computeNativeFillLayers', () => { ]); }); }); + +describe('computeNativeFillLayersIncremental (#366)', () => { + const box = (x0: number, y0: number, x1: number, y1: number): DrawingPath => ({ + points: [ + { x: x0, y: y0 }, + { x: x1, y: y0 }, + { x: x1, y: y1 }, + { x: x0, y: y1 }, + { x: x0, y: y0 }, + ], + color: '#000000', + strokeWidth: 1, + type: 'stroke', + }); + const fillAt = (x: number, y: number, color: string): DrawingPath => ({ + points: [{ x, y }], + color, + strokeWidth: 0, + type: 'fill', + }); + + // Zeichnung mit Strichen, Fills und einem Fill, der eine frühere Fläche überstreicht + const drawing: DrawingPath[] = [ + box(1, 1, 8, 8), + fillAt(4, 4, '#FF0000'), + box(12, 1, 18, 8), + fillAt(15, 4, '#00FF00'), + fillAt(4, 4, '#0000FF'), + box(1, 12, 8, 18), + fillAt(4, 15, '#FFFF00'), + ]; + + it('liefert für jeden wachsenden Präfix dasselbe wie die Komplettberechnung', () => { + let cache: NativeFillCache | null = null; + for (let n = 1; n <= drawing.length; n++) { + const prefix = drawing.slice(0, n); + const result = computeNativeFillLayersIncremental(prefix, 20, 20, 1, 0, 0, cache); + cache = result.cache; + expect(result.layers).toEqual(computeNativeFillLayers(prefix, 20, 20, 1, 0, 0)); + } + }); + + it('liefert dasselbe, wenn der letzte Pfad pro Frame ein neues, wachsendes Objekt ist (Replay)', () => { + const stroke = box(1, 1, 8, 8); + let cache: NativeFillCache | null = null; + for (let k = 2; k <= stroke.points.length; k++) { + const partial = { ...stroke, points: stroke.points.slice(0, k) }; + const prefix = [fillAt(15, 15, '#FF0000'), partial]; + const result = computeNativeFillLayersIncremental(prefix, 20, 20, 1, 0, 0, cache); + cache = result.cache; + expect(result.layers).toEqual(computeNativeFillLayers(prefix, 20, 20, 1, 0, 0)); + } + }); + + it('verwendet Puffer und committed-Pfade wieder, solange der Präfix gleich bleibt', () => { + const first = computeNativeFillLayersIncremental(drawing.slice(0, 3), 20, 20, 1, 0, 0, null); + const second = computeNativeFillLayersIncremental( + drawing.slice(0, 4), + 20, + 20, + 1, + 0, + 0, + first.cache, + ); + expect(second.cache.buffer).toBe(first.cache.buffer); + expect(second.cache.paths).toEqual(drawing.slice(0, 3)); + }); + + it('baut neu auf, wenn sich die Skalierung ändert', () => { + const first = computeNativeFillLayersIncremental(drawing.slice(0, 4), 20, 20, 1, 0, 0, null); + const second = computeNativeFillLayersIncremental( + drawing.slice(0, 4), + 20, + 20, + 0.5, + 2, + 2, + first.cache, + ); + expect(second.cache.buffer).not.toBe(first.cache.buffer); + expect(second.layers).toEqual(computeNativeFillLayers(drawing.slice(0, 4), 20, 20, 0.5, 2, 2)); + }); + + it('baut neu auf, wenn Pfade entfernt werden (Undo)', () => { + const first = computeNativeFillLayersIncremental(drawing, 20, 20, 1, 0, 0, null); + const shorter = drawing.slice(0, 3); + const second = computeNativeFillLayersIncremental(shorter, 20, 20, 1, 0, 0, first.cache); + expect(second.layers).toEqual(computeNativeFillLayers(shorter, 20, 20, 1, 0, 0)); + }); + + it('lässt den Cache vom flüchtigen letzten Pfad unberührt', () => { + const base = drawing.slice(0, 3); + const withRed = computeNativeFillLayersIncremental( + [...base, fillAt(4, 4, '#FF0000')], + 20, + 20, + 1, + 0, + 0, + null, + ); + const withBlue = computeNativeFillLayersIncremental( + [...base, fillAt(4, 4, '#0000FF')], + 20, + 20, + 1, + 0, + 0, + null, + ); + // Beide haben denselben committed-Stand (base); der letzte Pfad darf ihn nicht verändern + expect(Array.from(withRed.cache.buffer)).toEqual(Array.from(withBlue.cache.buffer)); + expect(withRed.cache.paths).toHaveLength(3); + }); +});