Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
42 changes: 3 additions & 39 deletions components/DrawingCanvas.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');

Expand Down
38 changes: 5 additions & 33 deletions components/DrawingCanvas.web.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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
Expand Down
38 changes: 2 additions & 36 deletions services/ShareService.native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
const size = EXPORT_SIZE;
const surface = Skia.Surface.Make(size, size);
Expand All @@ -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);
Expand Down
33 changes: 3 additions & 30 deletions services/ShareService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand Down
121 changes: 121 additions & 0 deletions utils/drawingTransform.test.ts
Original file line number Diff line number Diff line change
@@ -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),
);
}
});
});
Loading
Loading