From 2af96f14ee50ea32537063fb4946ca51a541f80e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 21:01:03 +0000 Subject: [PATCH] refactor: computeFitTransform() statt vierfach kopierter Einpass-Logik (#359) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bounding Box der Striche → Skalierung/Offset stand fast identisch in DrawingCanvas.web.tsx, DrawingCanvas.native.tsx (getScaledPaths), ShareService.ts und ShareService.native.ts (computeScaleOffset); nur das Padding unterschied sich (10 Canvas, 40 Export). - utils/drawingTransform.ts: computeFitTransform(paths, width, height, padding) + IDENTITY_TRANSFORM, ohne React-Native-Abhängigkeit - alle vier Stellen nutzen den Helper (-138 Zeilen) - Tests: Randfälle (leer, nur Fills, Einzelpunkt, Linie, Padding) und ein Äquivalenztest gegen die bisherige Implementierung für 300 zufällige Zeichnungen Verhalten unverändert; im Browser pixelidentisch (Ergebnis-Canvas, Replay-Mitte, Replay-Ende) gegenüber dem Stand davor. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_017fxbsNALrmwMj7LGMDPLw2 --- CHANGELOG.md | 4 + components/DrawingCanvas.native.tsx | 42 +--------- components/DrawingCanvas.web.tsx | 38 ++------- services/ShareService.native.ts | 38 +-------- services/ShareService.ts | 33 +------- utils/drawingTransform.test.ts | 121 ++++++++++++++++++++++++++++ utils/drawingTransform.ts | 64 +++++++++++++++ 7 files changed, 202 insertions(+), 138 deletions(-) create mode 100644 utils/drawingTransform.test.ts create mode 100644 utils/drawingTransform.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b2b2d4b..a576f05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Die Logik „Zeichnung in eine Fläche einpassen" (Bounding Box → Skalierung/Offset) liegt jetzt einmal in `utils/drawingTransform.ts` (`computeFitTransform`) statt viermal kopiert in Web-/Native-Zeichenfläche und ShareService web/native; Verhalten unverändert (#359) + +### 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) diff --git a/components/DrawingCanvas.native.tsx b/components/DrawingCanvas.native.tsx index eb6b7fb..7fa3f43 100644 --- a/components/DrawingCanvas.native.tsx +++ b/components/DrawingCanvas.native.tsx @@ -8,6 +8,7 @@ import { useWindowDimensions, } from 'react-native'; import { t } from '@services/i18n'; +import { computeFitTransform, IDENTITY_TRANSFORM } from '@utils/drawingTransform'; import { styles, DEFAULT_CANVAS_WIDTH } from './DrawingCanvas.shared'; import type { DrawingPath } from './DrawingCanvas.shared'; import { captureException } from '@services/SentryService'; @@ -265,46 +266,9 @@ export default function DrawingCanvas({ setCurrentNativePath([]); }; - const getScaledPaths = () => { - // Exclude fill paths from bounding box – they only have a single click point - const strokePaths = (scaleReferencePaths ?? nativePaths).filter(p => p.type !== 'fill'); - if (strokePaths.length === 0) return { scale: 1, offsetX: 0, offsetY: 0 }; - - let minX = Infinity, - minY = Infinity, - maxX = -Infinity, - maxY = -Infinity; - strokePaths.forEach(path => { - path.points.forEach(point => { - minX = Math.min(minX, point.x); - minY = Math.min(minY, point.y); - maxX = Math.max(maxX, point.x); - maxY = Math.max(maxY, point.y); - }); - }); - - const drawingWidth = maxX - minX; - const drawingHeight = maxY - minY; - - if (drawingWidth <= 0 || drawingHeight <= 0) return { scale: 1, offsetX: 0, offsetY: 0 }; - - const padding = 10; - const scale = Math.min( - (width - 2 * padding) / drawingWidth, - (height - 2 * padding) / drawingHeight, - 1, - ); - const scaledWidth = drawingWidth * scale; - const scaledHeight = drawingHeight * scale; - const offsetX = (width - scaledWidth) / 2 - minX * scale; - const offsetY = (height - scaledHeight) / 2 - minY * scale; - - return { scale, offsetX, offsetY }; - }; - const { scale, offsetX, offsetY } = !onDrawingChange - ? getScaledPaths() - : { scale: 1, offsetX: 0, offsetY: 0 }; + ? computeFitTransform(scaleReferencePaths ?? nativePaths, width, height, 10) + : IDENTITY_TRANSFORM; const hasFillPaths = nativePaths.some(p => p.type === 'fill'); diff --git a/components/DrawingCanvas.web.tsx b/components/DrawingCanvas.web.tsx index 1dab937..189c3c7 100644 --- a/components/DrawingCanvas.web.tsx +++ b/components/DrawingCanvas.web.tsx @@ -1,6 +1,7 @@ import React, { useState, useRef, useEffect } from 'react'; import { View, useWindowDimensions } from 'react-native'; import { floodFillPixels, hexToRgb } from '@services/FloodFillService'; +import { computeFitTransform, IDENTITY_TRANSFORM } from '@utils/drawingTransform'; import { styles, DEFAULT_CANVAS_WIDTH } from './DrawingCanvas.shared'; import type { DrawingPath } from './DrawingCanvas.shared'; @@ -125,39 +126,10 @@ export default function DrawingCanvas({ if (!ctx) return; const referencePaths = scaleReferencePaths ?? paths; - let scale = 1, - offsetX = 0, - offsetY = 0; - if (!onDrawingChange && referencePaths.length > 0) { - let minX = Infinity, - minY = Infinity, - maxX = -Infinity, - maxY = -Infinity; - referencePaths.forEach(path => { - if (path.type === 'fill') return; - path.points.forEach(point => { - minX = Math.min(minX, point.x); - minY = Math.min(minY, point.y); - maxX = Math.max(maxX, point.x); - maxY = Math.max(maxY, point.y); - }); - }); - - const drawingWidth = maxX - minX; - const drawingHeight = maxY - minY; - const padding = 10; - - if (drawingWidth > 0 && drawingHeight > 0) { - const scaleX = (width - 2 * padding) / drawingWidth; - const scaleY = (height - 2 * padding) / drawingHeight; - scale = Math.min(scaleX, scaleY, 1); - - const scaledWidth = drawingWidth * scale; - const scaledHeight = drawingHeight * scale; - offsetX = (width - scaledWidth) / 2 - minX * scale; - offsetY = (height - scaledHeight) / 2 - minY * scale; - } - } + const { scale, offsetX, offsetY } = + !onDrawingChange && referencePaths.length > 0 + ? computeFitTransform(referencePaths, width, height, 10) + : IDENTITY_TRANSFORM; // 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 diff --git a/services/ShareService.native.ts b/services/ShareService.native.ts index cbdf44d..7539a27 100644 --- a/services/ShareService.native.ts +++ b/services/ShareService.native.ts @@ -10,45 +10,11 @@ import type { DrawingPath } from '@components/DrawingCanvas.shared'; import * as FileSystem from 'expo-file-system/legacy'; import * as Sharing from 'expo-sharing'; import { Skia, ImageFormat, PaintStyle, StrokeCap, StrokeJoin } from '@shopify/react-native-skia'; +import { computeFitTransform } from '@utils/drawingTransform'; import { computeNativeFillLayers } from './NativeFillLayerService'; const EXPORT_SIZE = 600; -function computeScaleOffset( - paths: DrawingPath[], - size: number, -): { scale: number; offsetX: number; offsetY: number } { - const strokePaths = paths.filter(p => p.type !== 'fill'); - if (strokePaths.length === 0) return { scale: 1, offsetX: 0, offsetY: 0 }; - - let minX = Infinity, - minY = Infinity, - maxX = -Infinity, - maxY = -Infinity; - for (const path of strokePaths) { - for (const pt of path.points) { - if (pt.x < minX) minX = pt.x; - if (pt.y < minY) minY = pt.y; - if (pt.x > maxX) maxX = pt.x; - if (pt.y > maxY) maxY = pt.y; - } - } - - const dw = maxX - minX; - const dh = maxY - minY; - if (dw <= 0 || dh <= 0) return { scale: 1, offsetX: 0, offsetY: 0 }; - - const padding = 40; - const scale = Math.min((size - 2 * padding) / dw, (size - 2 * padding) / dh, 1); - const sw = dw * scale; - const sh = dh * scale; - return { - scale, - offsetX: (size - sw) / 2 - minX * scale, - offsetY: (size - sh) / 2 - minY * scale, - }; -} - export async function shareDrawing(paths: DrawingPath[], title?: string): Promise { const size = EXPORT_SIZE; const surface = Skia.Surface.Make(size, size); @@ -61,7 +27,7 @@ export async function shareDrawing(paths: DrawingPath[], title?: string): Promis bgPaint.setColor(Skia.Color('#FFFFFF')); canvas.drawRect(Skia.XYWHRect(0, 0, size, size), bgPaint); - const { scale, offsetX, offsetY } = computeScaleOffset(paths, size); + const { scale, offsetX, offsetY } = computeFitTransform(paths, size, size, 40); // Fill regions via NativeFillLayerService (same pipeline as DrawingCanvas.native.tsx) const fillLayers = computeNativeFillLayers(paths, size, size, scale, offsetX, offsetY); diff --git a/services/ShareService.ts b/services/ShareService.ts index f14566d..b735a7e 100644 --- a/services/ShareService.ts +++ b/services/ShareService.ts @@ -4,6 +4,7 @@ * This file is loaded on web; ShareService.native.ts is loaded on native via Metro. */ import type { DrawingPath } from '@components/DrawingCanvas.shared'; +import { computeFitTransform } from '@utils/drawingTransform'; import { floodFillPixels, hexToRgb } from './FloodFillService'; const EXPORT_SIZE = 600; @@ -20,36 +21,8 @@ function buildOffscreenCanvas(paths: DrawingPath[]): HTMLCanvasElement { if (paths.length === 0) return canvas; - // Scale/offset: fit drawing into the export canvas (same logic as DrawingCanvas.web.tsx preview mode) - const strokePaths = paths.filter(p => p.type !== 'fill'); - let scale = 1, - offsetX = 0, - offsetY = 0; - - if (strokePaths.length > 0) { - let minX = Infinity, - minY = Infinity, - maxX = -Infinity, - maxY = -Infinity; - for (const path of strokePaths) { - for (const pt of path.points) { - if (pt.x < minX) minX = pt.x; - if (pt.y < minY) minY = pt.y; - if (pt.x > maxX) maxX = pt.x; - if (pt.y > maxY) maxY = pt.y; - } - } - const dw = maxX - minX; - const dh = maxY - minY; - if (dw > 0 && dh > 0) { - const padding = 40; - scale = Math.min((EXPORT_SIZE - 2 * padding) / dw, (EXPORT_SIZE - 2 * padding) / dh, 1); - const sw = dw * scale; - const sh = dh * scale; - offsetX = (EXPORT_SIZE - sw) / 2 - minX * scale; - offsetY = (EXPORT_SIZE - sh) / 2 - minY * scale; - } - } + // Scale/offset: Zeichnung in die Export-Fläche einpassen (gemeinsame Logik mit der Vorschau) + const { scale, offsetX, offsetY } = computeFitTransform(paths, EXPORT_SIZE, EXPORT_SIZE, 40); for (const path of paths) { if (path.type === 'fill' && path.points.length > 0) { diff --git a/utils/drawingTransform.test.ts b/utils/drawingTransform.test.ts new file mode 100644 index 0000000..6d25d2b --- /dev/null +++ b/utils/drawingTransform.test.ts @@ -0,0 +1,121 @@ +import { computeFitTransform, IDENTITY_TRANSFORM, type FitPath } from './drawingTransform'; + +const stroke = (...pts: [number, number][]): FitPath => ({ + points: pts.map(([x, y]) => ({ x, y })), + type: 'stroke', +}); +const fill = (x: number, y: number): FitPath => ({ points: [{ x, y }], type: 'fill' }); + +/** Referenz: die bisherige, in vier Dateien kopierte Implementierung (Stand vor #359). */ +function legacyFit(paths: FitPath[], width: number, height: number, padding: number) { + let scale = 1, + offsetX = 0, + offsetY = 0; + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity; + paths.forEach(path => { + if (path.type === 'fill') return; + path.points.forEach(point => { + minX = Math.min(minX, point.x); + minY = Math.min(minY, point.y); + maxX = Math.max(maxX, point.x); + maxY = Math.max(maxY, point.y); + }); + }); + const drawingWidth = maxX - minX; + const drawingHeight = maxY - minY; + if (drawingWidth > 0 && drawingHeight > 0) { + scale = Math.min( + (width - 2 * padding) / drawingWidth, + (height - 2 * padding) / drawingHeight, + 1, + ); + offsetX = (width - drawingWidth * scale) / 2 - minX * scale; + offsetY = (height - drawingHeight * scale) / 2 - minY * scale; + } + return { scale, offsetX, offsetY }; +} + +describe('computeFitTransform', () => { + it('liefert Identität ohne Pfade', () => { + expect(computeFitTransform([], 200, 200, 10)).toEqual(IDENTITY_TRANSFORM); + }); + + it('liefert Identität, wenn es nur Fills gibt', () => { + expect(computeFitTransform([fill(5, 5), fill(9, 9)], 200, 200, 10)).toEqual(IDENTITY_TRANSFORM); + }); + + it('liefert Identität bei Einzelpunkt und bei waagerechter/senkrechter Linie', () => { + expect(computeFitTransform([stroke([5, 5])], 200, 200, 10)).toEqual(IDENTITY_TRANSFORM); + expect(computeFitTransform([stroke([0, 5], [50, 5])], 200, 200, 10)).toEqual( + IDENTITY_TRANSFORM, + ); + expect(computeFitTransform([stroke([5, 0], [5, 50])], 200, 200, 10)).toEqual( + IDENTITY_TRANSFORM, + ); + }); + + it('skaliert nie über 1 hinaus und zentriert eine kleine Zeichnung', () => { + const t = computeFitTransform([stroke([0, 0], [20, 10])], 100, 100, 10); + expect(t.scale).toBe(1); + // Zeichnung 20×10 mittig in 100×100: x 40..60, y 45..55 + expect(0 * t.scale + t.offsetX).toBe(40); + expect(20 * t.scale + t.offsetX).toBe(60); + expect(0 * t.scale + t.offsetY).toBe(45); + expect(10 * t.scale + t.offsetY).toBe(55); + }); + + it('verkleinert eine große Zeichnung auf Fläche minus Padding', () => { + const t = computeFitTransform([stroke([0, 0], [400, 200])], 100, 100, 10); + expect(t.scale).toBeCloseTo(80 / 400); + expect(400 * t.scale + t.offsetX).toBeCloseTo(90); + expect(0 * t.scale + t.offsetX).toBeCloseTo(10); + }); + + it('ignoriert Fills bei der Bounding Box', () => { + const withFar = computeFitTransform([stroke([0, 0], [40, 40]), fill(9999, 9999)], 100, 100, 10); + const without = computeFitTransform([stroke([0, 0], [40, 40])], 100, 100, 10); + expect(withFar).toEqual(without); + }); + + it('nutzt das übergebene Padding (Canvas 10, Export 40)', () => { + const paths = [stroke([0, 0], [1000, 1000])]; + expect(computeFitTransform(paths, 600, 600, 10).scale).toBeCloseTo(0.58); + expect(computeFitTransform(paths, 600, 600, 40).scale).toBeCloseTo(0.52); + }); + + it('entspricht der bisherigen Implementierung für zufällige Zeichnungen', () => { + // Deterministischer Pseudozufall (LCG), damit der Test reproduzierbar bleibt + let seed = 12345; + const rnd = () => { + seed = (seed * 1664525 + 1013904223) % 4294967296; + return seed / 4294967296; + }; + for (let i = 0; i < 300; i++) { + const paths: FitPath[] = []; + const count = Math.floor(rnd() * 6); + for (let p = 0; p < count; p++) { + if (rnd() < 0.3) { + paths.push(fill(rnd() * 400, rnd() * 400)); + } else { + const n = 1 + Math.floor(rnd() * 8); + paths.push({ + points: Array.from({ length: n }, () => ({ + x: rnd() * 600 - 100, + y: rnd() * 600 - 100, + })), + type: rnd() < 0.5 ? 'stroke' : undefined, + }); + } + } + const width = 100 + rnd() * 500; + const height = 100 + rnd() * 500; + const padding = rnd() < 0.5 ? 10 : 40; + expect(computeFitTransform(paths, width, height, padding)).toEqual( + legacyFit(paths, width, height, padding), + ); + } + }); +}); diff --git a/utils/drawingTransform.ts b/utils/drawingTransform.ts new file mode 100644 index 0000000..c04d4f8 --- /dev/null +++ b/utils/drawingTransform.ts @@ -0,0 +1,64 @@ +/** + * Passt eine Zeichnung (DrawingPath[]) in eine Fläche ein. + * + * Gemeinsame Quelle für die Vorschau-Skalierung der Web-/Native-Zeichenfläche + * und den PNG-Export (ShareService web/native) — vorher viermal kopiert (#359). + * Rein funktional, ohne React-Native-Abhängigkeit. + */ + +export interface FitPath { + points: { x: number; y: number }[]; + type?: 'stroke' | 'fill'; +} + +export interface FitTransform { + scale: number; + offsetX: number; + offsetY: number; +} + +export const IDENTITY_TRANSFORM: FitTransform = { scale: 1, offsetX: 0, offsetY: 0 }; + +/** + * Bounding Box der Striche (Fills zählen nicht — sie haben nur einen Klickpunkt) → Skalierung + * (nie größer als 1) und Offset, sodass die Zeichnung mit `padding` zentriert in + * `width × height` liegt. Ohne Striche oder bei Breite/Höhe 0 (Einzelpunkt, Linie): Identität. + * Anwendung: `x * scale + offsetX`. + */ +export function computeFitTransform( + paths: FitPath[], + width: number, + height: number, + padding: number, +): FitTransform { + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity; + + for (const path of paths) { + if (path.type === 'fill') continue; + for (const point of path.points) { + if (point.x < minX) minX = point.x; + if (point.y < minY) minY = point.y; + if (point.x > maxX) maxX = point.x; + if (point.y > maxY) maxY = point.y; + } + } + + const drawingWidth = maxX - minX; + const drawingHeight = maxY - minY; + // Auch bei komplett leerer Bounding Box (nur Fills): -Infinity > 0 ist false → Identität + if (!(drawingWidth > 0) || !(drawingHeight > 0)) return IDENTITY_TRANSFORM; + + const scale = Math.min( + (width - 2 * padding) / drawingWidth, + (height - 2 * padding) / drawingHeight, + 1, + ); + return { + scale, + offsetX: (width - drawingWidth * scale) / 2 - minX * scale, + offsetY: (height - drawingHeight * scale) / 2 - minY * scale, + }; +}