From 0a36fd6d560ca606e779829d8c446b84da25529b Mon Sep 17 00:00:00 2001 From: Maxim Averyanov Date: Thu, 27 Aug 2026 14:35:29 +0200 Subject: [PATCH 1/6] Draw a heart without finding a photograph of one Seven shapes and any text, from the command line, with nothing to prepare: mthread shape heart --scale 0.6 mthread shape star --points 7 --rotate 20 mthread text "hello" --font arial.ttf --y 0.35 The shapes are parametric curves evaluated straight into points, so they are smooth at any size and cost nothing to produce. The wave needed one correction worth recording: its x span was fixed at one while its height was two, and _fit normalises by whichever side is longer, so three cycles came out as a narrow zigzag rather than a wave. Text takes a different route on purpose. Rather than carry a stroke font around, it renders the words with Pillow and hands the result to the ordinary tracer: every font on the machine becomes available and the letters look like that font. The cost, and it is unavoidable, is that letters arrive as outlines - a filled glyph is a shape with an inside and an outside, and this draws with one finger. **Placement moves into the library**, because placing a drawing is part of drawing it rather than part of any one interface. mthread_draw.geometry re-exports it so existing callers are unaffected, and the command line now places shapes with exactly the code the window places a photograph with. **Flipping is added** while the file was open: --flip-x and --flip-y, and Placement.mirrored, which flips rather than sets, so flipping twice is not flipping. Sixteen tests, and two of them are the interesting ones: that every shape fits the unit square without being stretched, and that a drawing turned to any angle still lands inside the margin - the rotated bounding box being what has to fit, not the upright one. Co-Authored-By: Claude Opus 5 --- mthread/cli.py | 81 +++++++++++++++++ mthread/placement.py | 115 +++++++++++++++++++++++ mthread/shapes.py | 191 +++++++++++++++++++++++++++++++++++++++ mthread_draw/geometry.py | 113 ++--------------------- tests/test_shapes.py | 131 +++++++++++++++++++++++++++ 5 files changed, 524 insertions(+), 107 deletions(-) create mode 100644 mthread/placement.py create mode 100644 mthread/shapes.py create mode 100644 tests/test_shapes.py diff --git a/mthread/cli.py b/mthread/cli.py index 9d84b6e..2a7ebe3 100644 --- a/mthread/cli.py +++ b/mthread/cli.py @@ -88,6 +88,69 @@ def progress(done, total): return 0 +def _placement_from(args): + """The placement options every drawing command shares.""" + from .placement import Placement + return Placement( + centre=(args.x, args.y), + scale=args.scale, + rotation=args.rotate, + flip_x=args.flip_x, + flip_y=args.flip_y, + ) + + +def _add_placement_options(command) -> None: + command.add_argument("--scale", type=float, default=1.0, + help="size, where 1.0 fills the screen with a margin") + command.add_argument("--rotate", type=float, default=0.0, metavar="DEGREES") + command.add_argument("--flip-x", action="store_true", help="mirror left to right") + command.add_argument("--flip-y", action="store_true", help="mirror top to bottom") + command.add_argument("--x", type=float, default=0.5, metavar="0..1", + help="where the middle lands across the screen") + command.add_argument("--y", type=float, default=0.5, metavar="0..1") + command.add_argument("--margin", type=float, default=0.06) + command.add_argument("--speed", type=float, default=1.0) + command.add_argument("--human", type=float, default=0.0, + help="0 draws mechanically; 1 to 3 like a hand") + + +def _draw_unit_paths(args, paths, what: str) -> int: + """Place paths given in a 0..1 square onto the screen, and draw them.""" + from .placement import place_on_screen + + device = Device(args.serial) + width, height = device.screen_size + placed = place_on_screen(paths, width, height, _placement_from(args), + margin=args.margin) + points = sum(len(path) for path in placed) + print(f"{what}: {len(placed)} strokes, {points} points on {width}x{height}") + + drawn = device.draw_paths(placed, speed=args.speed, human=args.human) + print(f"drawn {drawn} strokes") + return 0 + + +def _cmd_shape(args) -> int: + from .shapes import SHAPES + + maker = SHAPES[args.shape] + if args.shape == "star": + paths = maker(points=args.points) + elif args.shape == "polygon": + paths = maker(sides=args.points) + else: + paths = maker() + return _draw_unit_paths(args, paths, args.shape) + + +def _cmd_text(args) -> int: + from .shapes import text + + paths = text(args.words, font=args.font, size=args.size) + return _draw_unit_paths(args, paths, f"text {args.words!r}") + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="mthread", description=__doc__) parser.add_argument("--version", action="version", version=f"mthread {__version__}") @@ -109,6 +172,24 @@ def build_parser() -> argparse.ArgumentParser: play.add_argument("--repeat", type=int, default=1) play.set_defaults(func=_cmd_play) + # Drawing something without preparing a picture first. A heart is a + # parametric curve; there is no reason to make anyone find a JPEG of one. + from .shapes import SHAPES + shape = sub.add_parser("shape", help="draw a shape: " + ", ".join(SHAPES)) + shape.add_argument("shape", choices=sorted(SHAPES)) + shape.add_argument("--points", type=int, default=5, + help="points on a star, or sides on a polygon") + _add_placement_options(shape) + shape.set_defaults(func=_cmd_shape) + + words = sub.add_parser("text", help="draw text in any font on the machine") + words.add_argument("words") + words.add_argument("--font", help="a font file, or a name the system knows") + words.add_argument("--size", type=int, default=220, + help="pixels to render at before tracing; larger is smoother") + _add_placement_options(words) + words.set_defaults(func=_cmd_text) + return parser diff --git a/mthread/placement.py b/mthread/placement.py new file mode 100644 index 0000000..35cc837 --- /dev/null +++ b/mthread/placement.py @@ -0,0 +1,115 @@ +"""Where a drawing goes on a screen, and how big, how tilted, which way round. + +Held in fractions of the screen and in degrees rather than in pixels, for three +reasons: it survives the phone being turned, it means the same thing on a +different device, and a scale of one can mean something recognisable - "as large +as it goes with a margin" - rather than a pixel count nobody can picture. + +This lives in the library rather than in the desktop app because placing a +drawing is part of drawing it. The command line places shapes and text with the +same code the window places a photograph with. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass, replace + +__all__ = ["Placement", "fit_to_screen", "place_on_screen"] + + +@dataclass(frozen=True) +class Placement: + """Attributes: + + centre: Where the middle of the drawing lands, 0 to 1 across the screen. + scale: Multiplier on the size it would have if fitted, so 1.0 is as large + as it goes with a margin. + rotation: Degrees clockwise. + flip_x: Mirror left to right. + flip_y: Mirror top to bottom. + """ + + centre: tuple[float, float] = (0.5, 0.5) + scale: float = 1.0 + rotation: float = 0.0 + flip_x: bool = False + flip_y: bool = False + + def moved(self, dx: float, dy: float) -> "Placement": + return replace(self, centre=(self.centre[0] + dx, self.centre[1] + dy)) + + def zoomed(self, factor: float, *, low: float = 0.05, high: float = 6.0) -> "Placement": + return replace(self, scale=max(low, min(high, self.scale * factor))) + + def turned(self, degrees: float) -> "Placement": + return replace(self, rotation=(self.rotation + degrees) % 360.0) + + def mirrored(self, *, horizontal: bool = False, vertical: bool = False) -> "Placement": + return replace(self, + flip_x=self.flip_x != horizontal, + flip_y=self.flip_y != vertical) + + +def place_on_screen(paths, width: int, height: int, + placement: Placement | None = None, *, margin: float = 0.06): + """Put *paths* on a screen of *width* x *height* as *placement* says. + + The base size is the one :func:`fit_to_screen` would choose, so a scale of + one means the same thing whatever the drawing and whatever the phone. + """ + placement = placement or Placement() + points = [point for path in paths for point in path] + if not points: + return [] + + min_x = min(x for x, _ in points) + max_x = max(x for x, _ in points) + min_y = min(y for _, y in points) + max_y = max(y for _, y in points) + source_width = max(max_x - min_x, 1e-9) + source_height = max(max_y - min_y, 1e-9) + source_centre = ((min_x + max_x) / 2.0, (min_y + max_y) / 2.0) + + mirror_x = -1.0 if placement.flip_x else 1.0 + mirror_y = -1.0 if placement.flip_y else 1.0 + + # Rotation happens about the drawing's own middle, so turning it does not + # also throw it across the screen. + angle = math.radians(placement.rotation) + cos, sin = math.cos(angle), math.sin(angle) + + # Fit the rotated bounding box, not the upright one; a drawing turned + # forty-five degrees needs more room than it did before, and without this it + # would quietly grow past the edges of the screen. + turned_width = abs(source_width * cos) + abs(source_height * sin) + turned_height = abs(source_width * sin) + abs(source_height * cos) + + pad_x, pad_y = width * margin, height * margin + base = min((width - 2 * pad_x) / turned_width, (height - 2 * pad_y) / turned_height) + scale = base * placement.scale + + target_x = placement.centre[0] * width + target_y = placement.centre[1] * height + + placed = [] + for path in paths: + line = [] + for x, y in path: + ox = (x - source_centre[0]) * mirror_x + oy = (y - source_centre[1]) * mirror_y + rx = ox * cos - oy * sin + ry = ox * sin + oy * cos + line.append((round(target_x + rx * scale), round(target_y + ry * scale))) + placed.append(line) + return placed + + +def fit_to_screen(paths, width: int, height: int, *, margin: float = 0.06): + """Scale paths to fill the screen, keeping shape and a clear margin. + + The special case of :func:`place_on_screen` with nothing moved, kept as its + own name because callers that have no interest in placement read better + saying what they mean. + """ + return place_on_screen(paths, width, height, Placement(), margin=margin) diff --git a/mthread/shapes.py b/mthread/shapes.py new file mode 100644 index 0000000..2b46484 --- /dev/null +++ b/mthread/shapes.py @@ -0,0 +1,191 @@ +"""Drawings that need no photograph: shapes from equations, text from a font. + + heart() a heart + star(points=5) a star with as many points as you like + text("hello") words, in whatever font the machine has + +The shapes are parametric curves evaluated straight into points, which is why +they come out smooth at any size and cost nothing to produce. + +Text is different, and deliberately so: rather than carry a stroke font around, +it renders the words with Pillow and hands the result to the ordinary tracer. +The advantage is that every font on the machine is available and the result +looks like that font; the cost is that letters come out as outlines, because +that is what a filled glyph is - a shape with an inside and an outside, and this +program draws with one finger. + +All of them return paths in a 0..1 square, so the caller places them on a screen +the same way it places a traced photograph. +""" + +from __future__ import annotations + +import math + +__all__ = ["SHAPES", "circle", "heart", "polygon", "spiral", "square", "star", "text", "wave"] + + +def _closed(points): + """Repeat the first point at the end, so a shape's outline joins up.""" + return list(points) + [points[0]] + + +def _fit(paths): + """Scale paths into the unit square, keeping their proportions.""" + everything = [point for path in paths for point in path] + min_x = min(x for x, _ in everything) + max_x = max(x for x, _ in everything) + min_y = min(y for _, y in everything) + max_y = max(y for _, y in everything) + span = max(max_x - min_x, max_y - min_y, 1e-9) + dx = (1.0 - (max_x - min_x) / span) / 2.0 + dy = (1.0 - (max_y - min_y) / span) / 2.0 + return [[((x - min_x) / span + dx, (y - min_y) / span + dy) for x, y in path] + for path in paths] + + +def heart(samples: int = 220): + """The usual parametric heart, upright and centred.""" + points = [] + for index in range(samples): + t = index / samples * 2 * math.pi + x = 16 * math.sin(t) ** 3 + # Negated because a screen's y runs downwards, and a heart that points + # the wrong way is not a heart. + y = -(13 * math.cos(t) - 5 * math.cos(2 * t) + - 2 * math.cos(3 * t) - math.cos(4 * t)) + points.append((x, y)) + return _fit([_closed(points)]) + + +def star(points: int = 5, inner: float = 0.42, samples_per_edge: int = 6): + """A star of *points* points; *inner* is how deep the notches cut.""" + if points < 2: + raise ValueError("a star needs at least two points") + corners = [] + for index in range(points * 2): + angle = index * math.pi / points - math.pi / 2 + radius = 1.0 if index % 2 == 0 else inner + corners.append((radius * math.cos(angle), radius * math.sin(angle))) + + # Corners alone would be enough for a straight-edged shape, but a few points + # along each edge keep the pacing even when the drawing is timed like a hand. + line = [] + for first, second in zip(corners, corners[1:] + corners[:1]): + for step in range(samples_per_edge): + fraction = step / samples_per_edge + line.append((first[0] + (second[0] - first[0]) * fraction, + first[1] + (second[1] - first[1]) * fraction)) + return _fit([_closed(line)]) + + +def circle(samples: int = 160): + return _fit([_closed([(math.cos(index / samples * 2 * math.pi), + math.sin(index / samples * 2 * math.pi)) + for index in range(samples)])]) + + +def square(): + return _fit([_closed([(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)])]) + + +def polygon(sides: int = 6): + if sides < 3: + raise ValueError("a polygon needs at least three sides") + return _fit([_closed([(math.cos(index / sides * 2 * math.pi - math.pi / 2), + math.sin(index / sides * 2 * math.pi - math.pi / 2)) + for index in range(sides)])]) + + +def spiral(turns: float = 3.0, samples: int = 400): + points = [] + for index in range(samples + 1): + fraction = index / samples + angle = fraction * turns * 2 * math.pi + radius = fraction + points.append((radius * math.cos(angle), radius * math.sin(angle))) + return _fit([points]) + + +def wave(cycles: float = 3.0, samples: int = 240): + # The x span is the number of cycles rather than one, so three cycles come + # out as a wave rather than as a narrow zigzag: _fit normalises by whichever + # side is longer, and with x fixed at one that side was always the height. + return _fit([[(index / samples * cycles, 0.35 * math.sin(index / samples * cycles * 2 * math.pi)) + for index in range(samples + 1)]]) + + +def text(words: str, *, font: str | None = None, size: int = 220, + sensitivity: float = 6.0, detail: float = 8.0): + """Trace *words* into paths, using a real font. + + Rendering and then tracing rather than carrying a stroke font: every font on + the machine becomes available, and the letters look like that font. Letters + arrive as outlines, an unavoidable consequence of a filled glyph being a + shape with an inside and an outside while this program draws with one finger. + + Args: + font: A font file, or a name Pillow can find. Falls back to whatever the + platform offers, and finally to Pillow's own bitmap font. + size: Pixel height to render at. Larger costs tracing time and buys + smoother letters; it does not change the final size, which the + caller chooses when placing the paths. + """ + from PIL import Image, ImageDraw, ImageFont + + from .vectorize import VectorizeSettings, Vectorizer + + if not words: + raise ValueError("there is nothing to draw") + + candidates = [font] if font else [] + candidates += ["arial.ttf", "segoeui.ttf", "DejaVuSans.ttf", "Helvetica.ttc"] + face = None + for name in candidates: + if not name: + continue + try: + face = ImageFont.truetype(name, size) + break + except OSError: + continue + if face is None: + face = ImageFont.load_default() + + # Measured before drawing, because a glyph may reach above and to the left + # of its own origin and would be clipped by a canvas sized after the fact. + scratch = ImageDraw.Draw(Image.new("L", (1, 1))) + left, top, right, bottom = scratch.textbbox((0, 0), words, font=face) + pad = max(8, size // 10) + canvas = Image.new("L", (right - left + pad * 2, bottom - top + pad * 2), 255) + ImageDraw.Draw(canvas).text((pad - left, pad - top), words, font=face, fill=0) + + settings = VectorizeSettings.from_sliders(sensitivity, detail, + target_width=None, method="canny") + vectorizer = Vectorizer() + vectorizer.load_array(_as_bgr(canvas)) + _, paths = vectorizer.process(settings) + if not paths: + raise ValueError("the text traced to nothing; try a larger size") + return _fit(paths) + + +def _as_bgr(grey): + """A greyscale PIL image as the BGR array the vectoriser expects.""" + import numpy as np + + array = np.asarray(grey.convert("RGB")) + return array[:, :, ::-1].copy() + + +#: The ones the command line offers by name, so `mthread shape --help` can list +#: them without the caller knowing the module. +SHAPES = { + "heart": heart, + "star": star, + "circle": circle, + "square": square, + "polygon": polygon, + "spiral": spiral, + "wave": wave, +} diff --git a/mthread_draw/geometry.py b/mthread_draw/geometry.py index 6ba2bde..8960bf6 100644 --- a/mthread_draw/geometry.py +++ b/mthread_draw/geometry.py @@ -5,10 +5,14 @@ from __future__ import annotations -import math -from dataclasses import dataclass, replace +from dataclasses import dataclass from typing import Sequence +# Placement moved into the library, placing a drawing being part of drawing +# it rather than part of any one interface. Re-exported so callers that +# already import it from here keep working. +from mthread.placement import Placement, fit_to_screen, place_on_screen + __all__ = ["CanvasView", "Placement", "fit_to_screen", "place_on_screen", "place_paths"] @@ -82,108 +86,3 @@ def place_paths( if len(points) >= 2: placed.append(points) return placed - - -@dataclass(frozen=True) -class Placement: - """Where a drawing goes on the screen, and how big and how tilted. - - Held in fractions of the screen rather than pixels so that it survives the - phone being turned, and so the same numbers mean the same thing on a - different device. - - Attributes: - centre: Where the middle of the drawing lands, 0 to 1 across the screen. - scale: Multiplier on the size it would have if fitted to the screen, so - 1.0 is "as large as it goes with a margin". - rotation: Degrees clockwise. - """ - - centre: tuple[float, float] = (0.5, 0.5) - scale: float = 1.0 - rotation: float = 0.0 - - def moved(self, dx: float, dy: float) -> "Placement": - return replace(self, centre=(self.centre[0] + dx, self.centre[1] + dy)) - - def zoomed(self, factor: float, *, low: float = 0.05, high: float = 6.0) -> "Placement": - return replace(self, scale=max(low, min(high, self.scale * factor))) - - def turned(self, degrees: float) -> "Placement": - return replace(self, rotation=(self.rotation + degrees) % 360.0) - - -def place_on_screen(paths, width: int, height: int, placement: Placement, - *, margin: float = 0.06): - """Put *paths* on a screen of *width* x *height* as *placement* says. - - The base size is the one :func:`fit_to_screen` would choose, so a scale of - one means the same thing whatever the drawing and whatever the phone, and - the slider a person turns is a multiplier on something recognisable. - """ - points = [point for path in paths for point in path] - if not points: - return [] - - min_x = min(x for x, _ in points) - max_x = max(x for x, _ in points) - min_y = min(y for _, y in points) - max_y = max(y for _, y in points) - source_width = max(max_x - min_x, 1) - source_height = max(max_y - min_y, 1) - source_centre = ((min_x + max_x) / 2.0, (min_y + max_y) / 2.0) - - # Rotation happens about the drawing's own middle, so turning it does not - # also throw it across the screen. - angle = math.radians(placement.rotation) - cos, sin = math.cos(angle), math.sin(angle) - - # Fit the rotated bounding box, not the upright one; a drawing turned - # forty-five degrees needs more room than it did before, and without this it - # would quietly grow past the edges of the screen. - turned_width = abs(source_width * cos) + abs(source_height * sin) - turned_height = abs(source_width * sin) + abs(source_height * cos) - - pad_x, pad_y = width * margin, height * margin - base = min((width - 2 * pad_x) / turned_width, (height - 2 * pad_y) / turned_height) - scale = base * placement.scale - - target_x = placement.centre[0] * width - target_y = placement.centre[1] * height - - placed = [] - for path in paths: - line = [] - for x, y in path: - ox, oy = x - source_centre[0], y - source_centre[1] - rx = ox * cos - oy * sin - ry = ox * sin + oy * cos - line.append((round(target_x + rx * scale), round(target_y + ry * scale))) - placed.append(line) - return placed - - -def fit_to_screen(paths, width: int, height: int, *, margin: float = 0.06): - """Scale paths to fill the device screen, keeping shape and a clear margin. - - What the desktop app does by dragging, for callers that have no canvas to - drag on - the JSON engine, the command line, a test. - """ - points = [point for path in paths for point in path] - if not points: - return [] - - min_x = min(x for x, _ in points) - max_x = max(x for x, _ in points) - min_y = min(y for _, y in points) - max_y = max(y for _, y in points) - source_width = max(max_x - min_x, 1) - source_height = max(max_y - min_y, 1) - - pad_x, pad_y = width * margin, height * margin - scale = min((width - 2 * pad_x) / source_width, (height - 2 * pad_y) / source_height) - offset_x = pad_x + (width - 2 * pad_x - source_width * scale) / 2 - min_x * scale - offset_y = pad_y + (height - 2 * pad_y - source_height * scale) / 2 - min_y * scale - - return [[(round(x * scale + offset_x), round(y * scale + offset_y)) for x, y in path] - for path in paths] diff --git a/tests/test_shapes.py b/tests/test_shapes.py new file mode 100644 index 0000000..7605b24 --- /dev/null +++ b/tests/test_shapes.py @@ -0,0 +1,131 @@ +"""Shapes from equations, text from a font, and where they land.""" + +import math +import unittest + +from mthread.placement import Placement, fit_to_screen, place_on_screen +from mthread.shapes import SHAPES, heart, star, text + + +def bounds(paths): + points = [point for path in paths for point in path] + return (min(x for x, _ in points), min(y for _, y in points), + max(x for x, _ in points), max(y for _, y in points)) + + +class ShapeTests(unittest.TestCase): + + def test_every_shape_fits_the_unit_square(self): + """Callers place them; they do not each invent their own coordinates.""" + for name, maker in SHAPES.items(): + with self.subTest(shape=name): + left, top, right, bottom = bounds(maker()) + self.assertGreaterEqual(left, -1e-9, name) + self.assertGreaterEqual(top, -1e-9, name) + self.assertLessEqual(right, 1 + 1e-9, name) + self.assertLessEqual(bottom, 1 + 1e-9, name) + + def test_every_shape_keeps_its_proportions(self): + """One side touches both edges; the other is centred, not stretched.""" + for name, maker in SHAPES.items(): + with self.subTest(shape=name): + left, top, right, bottom = bounds(maker()) + longer = max(right - left, bottom - top) + self.assertAlmostEqual(longer, 1.0, places=6, msg=name) + + def test_a_shape_closes(self): + outline = heart()[0] + self.assertEqual(outline[0], outline[-1]) + + def test_a_star_has_the_points_asked_for(self): + """Counted by how many times the radius reaches its maximum.""" + outline = star(points=7)[0][:-1] # the closing repeat is not a corner + centre = (0.5, 0.5) + radii = [math.dist(point, centre) for point in outline] + # Counted around the loop, because the first tip is at index zero and a + # plain sliding window never gets to consider it. + count = len(radii) + peaks = sum(1 for index in range(count) + if radii[index] > 0.45 + and radii[index] >= radii[index - 1] + and radii[index] >= radii[(index + 1) % count]) + self.assertEqual(peaks, 7) + + def test_a_star_needs_at_least_two_points(self): + with self.assertRaises(ValueError): + star(points=1) + + +class TextTests(unittest.TestCase): + + def test_it_traces_to_something(self): + paths = text("Hi", size=140) + self.assertGreater(len(paths), 0) + self.assertGreater(sum(len(path) for path in paths), 20) + left, top, right, bottom = bounds(paths) + self.assertAlmostEqual(max(right - left, bottom - top), 1.0, places=6) + + def test_wider_words_come_out_wider(self): + narrow = bounds(text("l", size=140)) + wide = bounds(text("mmmmmm", size=140)) + self.assertGreater(wide[2] - wide[0], narrow[2] - narrow[0]) + + def test_nothing_to_draw_says_so(self): + with self.assertRaises(ValueError): + text("") + + +class PlacementTests(unittest.TestCase): + + def setUp(self): + self.square = [[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)]] + + def test_the_default_is_what_fitting_does(self): + self.assertEqual(place_on_screen(self.square, 1000, 2000), + fit_to_screen(self.square, 1000, 2000)) + + def test_half_scale_halves_it(self): + full = bounds(place_on_screen(self.square, 1000, 2000)) + half = bounds(place_on_screen(self.square, 1000, 2000, Placement(scale=0.5))) + self.assertAlmostEqual((half[2] - half[0]) / (full[2] - full[0]), 0.5, places=2) + + def test_a_turned_drawing_still_fits(self): + """The rotated box is what has to fit, not the upright one.""" + for degrees in (0, 30, 45, 60, 90, 135): + with self.subTest(degrees=degrees): + left, top, right, bottom = bounds( + place_on_screen(self.square, 1000, 2000, Placement(rotation=degrees))) + self.assertGreaterEqual(left, 55) # a 6% margin of 1000 + self.assertLessEqual(right, 945) + self.assertGreaterEqual(top, 115) + self.assertLessEqual(bottom, 1885) + + def test_flipping_mirrors_rather_than_moves(self): + plain = place_on_screen(self.square, 1000, 2000) + flipped = place_on_screen(self.square, 1000, 2000, Placement(flip_x=True)) + self.assertEqual(bounds(plain), bounds(flipped)) + self.assertEqual(flipped[0][0], plain[0][1]) + + def test_flipping_twice_is_not_flipping(self): + once = Placement().mirrored(horizontal=True) + twice = once.mirrored(horizontal=True) + self.assertTrue(once.flip_x) + self.assertFalse(twice.flip_x) + + def test_the_centre_is_where_the_middle_goes(self): + left, top, right, bottom = bounds( + place_on_screen(self.square, 1000, 2000, Placement(centre=(0.25, 0.75), scale=0.2))) + self.assertAlmostEqual((left + right) / 2, 250, delta=2) + self.assertAlmostEqual((top + bottom) / 2, 1500, delta=2) + + def test_zoom_and_turn_stay_within_their_limits(self): + self.assertEqual(Placement().zoomed(1000.0).scale, 6.0) + self.assertEqual(Placement().zoomed(0.0001).scale, 0.05) + self.assertEqual(Placement(rotation=350).turned(20).rotation, 10.0) + + def test_nothing_in_nothing_out(self): + self.assertEqual(place_on_screen([], 1000, 2000), []) + + +if __name__ == "__main__": + unittest.main() From 84694f2032f66818ddfa67b33f30850ff00dba97 Mon Sep 17 00:00:00 2001 From: Maxim Averyanov Date: Thu, 27 Aug 2026 14:51:42 +0200 Subject: [PATCH 2/6] Layers, an eraser, flipping, and a screenshot when capture will not **Layers.** Several pictures, arranged against each other before anything is drawn, each with its own placement and its own tracer settings. Hide one, reorder them, remove one; what gets drawn is every visible layer in order. Each keeps the paths it traced to, so changing one does not re-trace the others. **An eraser.** Drag across the live view with it on and the strokes under the cursor come out. Which strokes count as near enough is decided after placement, because the cursor is over a picture of the phone and not over the tracer's coordinate space; the reach is a fraction of the screen so it means the same thing whatever size the window has been dragged to. Erasures are recorded as indices and forgotten when a layer is re-traced, because stroke five of one tracing is not stroke five of the next - keeping them would silently take out whatever happened to land in that position. Erasing is one request at a time. A drag reports faster than an overlay can be re-rendered, and queueing every report would leave the eraser running behind the mouse. **Flipping**, and buttons for the transforms that are not gestures: Flip, Fit and Undo erase under the phone. Placement.mirrored flips rather than sets, so flipping twice is not flipping. **A screenshot taken by hand can stand in for ADB capture**, which fails on some devices and in some emulators. A still picture does not update, but its proportions are the ones placement needs. Placement now works with no phone attached at all, which falls out of the same change and is useful on its own: a drawing can be arranged before the cable is anywhere near. The window answers every layer operation by redrawing from the whole state the engine returns rather than from a delta. It is a few hundred bytes and it removes the class of bug where the list and the engine disagree about which layer is selected. Fourteen engine tests, driven the way a front end drives it and with no device attached: layers keep their own placements, a hidden layer is not drawn, erasing takes out what is under the point and nothing else, undo brings it back, and re-tracing forgets erasures. They use small generated fixtures rather than the photographs in examples/ - tracing a real photograph a dozen times over cost the suite most of a minute and told it nothing extra. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 22 +++ mthread_draw/server.py | 310 ++++++++++++++++++++++++++++++++------- tests/test_engine.py | 178 ++++++++++++++++++++++ winui/MainWindow.xaml | 42 +++++- winui/MainWindow.xaml.cs | 281 +++++++++++++++++++++++++++++++---- 5 files changed, 747 insertions(+), 86 deletions(-) create mode 100644 tests/test_engine.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 533e940..88afc09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ ## Unreleased +- **Layers.** Several pictures can be loaded and arranged against each other + before anything is drawn, each with its own placement and its own tracer + settings. Hide one, reorder them, remove one; what is drawn is every visible + layer in order. +- **An eraser.** Drag across the live view with the eraser on and the strokes + under the cursor come out. Which strokes are near enough is decided after + placement, because the cursor is over a picture of the phone rather than over + the tracer's coordinate space. Re-tracing forgets erasures rather than + misapplying them: stroke five of one tracing is not stroke five of the next. +- **Flip, and buttons for the transforms that are not gestures.** `Flip`, + `Fit` and `Undo erase` sit under the phone; `Placement.mirrored` flips rather + than sets, so flipping twice is not flipping. +- **A screenshot taken by hand can stand in for capture.** When ADB capture + fails there is otherwise nothing to place a drawing against. A still picture + does not update, but its proportions are the ones that matter, and placement + now works with no phone attached at all - so a drawing can be arranged before + the cable is anywhere near. +- **Shapes and text from the command line**, with nothing to prepare: + `mthread shape heart`, `mthread shape star --points 7 --rotate 20`, + `mthread text "hello" --font arial.ttf`. Seven shapes, and text in any font + the machine has. + - **Place the drawing by hand.** Drag it over the live view, wheel to resize, Shift and wheel to turn, double-click to fit again. `Placement` holds the position in fractions of the screen and degrees, so it survives the phone diff --git a/mthread_draw/server.py b/mthread_draw/server.py index 54e2536..d76d898 100644 --- a/mthread_draw/server.py +++ b/mthread_draw/server.py @@ -35,6 +35,7 @@ import threading import time import traceback +from dataclasses import dataclass, field from pathlib import Path from PIL import Image, ImageDraw @@ -52,6 +53,49 @@ OVERLAY_WIDTH = 540 +@dataclass +class Layer: + """One picture on the way to the screen, with its own settings and place. + + Several of these compose a drawing. Each keeps the paths it traced to, so + changing one does not re-trace the others, and its own placement, so they + can be arranged against each other before anything is drawn. + """ + + source: str + name: str + paths: list = field(default_factory=list) + #: Indices of strokes the eraser has taken out. Kept rather than deleted so + #: that re-tracing with different settings does not inherit erasures that + #: were aimed at strokes which no longer exist. + erased: set = field(default_factory=set) + placement: Placement = field(default_factory=Placement) + method: str = "canny" + sensitivity: float = 5.0 + detail: float = 7.0 + visible: bool = True + + @property + def kept(self) -> list: + return [path for index, path in enumerate(self.paths) if index not in self.erased] + + def summary(self) -> dict: + return { + "name": self.name, + "source": self.source, + "strokes": len(self.kept), + "erased": len(self.erased), + "points": sum(len(path) for path in self.kept), + "method": self.method, + "detail": self.detail, + "visible": self.visible, + "scale": round(self.placement.scale, 3), + "rotation": round(self.placement.rotation, 1), + "flip_x": self.placement.flip_x, + "flip_y": self.placement.flip_y, + } + + class Engine: """One device, one image, one drawing at a time.""" @@ -59,7 +103,12 @@ def __init__(self, write): self._write = write self.device: Device | None = None self.vectorizer = Vectorizer() - self.paths: list = [] + self.layers: list[Layer] = [] + self.current = 0 + #: Set when the automatic capture failed and a screenshot was supplied + #: by hand instead, so the front end has something to place against. + self.still: str | None = None + self.still_size: tuple[int, int] | None = None self.cancel = threading.Event() self.worker: threading.Thread | None = None self.drawing = threading.Event() @@ -69,9 +118,6 @@ def __init__(self, write): #: The size of the last frame captured, which is the only thing that #: knows which way up the phone is being held. self.frame_size: tuple[int, int] | None = None - #: Where the drawing sits. Starts fitted to the screen; the front end - #: moves, scales and turns it from there. - self.placement = Placement() self.recorder: Recorder | None = None self.session: GestureSession | None = None @@ -162,92 +208,225 @@ def _stop_mirror(self) -> None: self.mirror.close() self.mirror = self.mirror_thread = self.mirror_stop = None - def op_load_image(self, path: str) -> dict: - image = self.vectorizer.load_image(path) - return {"width": int(image.shape[1]), "height": int(image.shape[0])} + # ------------------------------------------------------------------ layers - def op_place(self, dx: float = 0.0, dy: float = 0.0, zoom: float = 1.0, - turn: float = 0.0, reset: bool = False) -> dict: - """Nudge the drawing about, and redraw the overlay that shows where. + @property + def layer(self) -> Layer: + if not self.layers: + raise MThreadError("Load an image first.") + self.current = max(0, min(self.current, len(self.layers) - 1)) + return self.layers[self.current] - Relative rather than absolute, because the front end is describing a - gesture - a drag of so far, a wheel notch - and relative keeps the two - ends from disagreeing about what the current position is. + def op_load_image(self, path: str, replace: bool = False) -> dict: + """Add a picture, or replace the one selected. + + Adding rather than replacing by default: several pictures arranged + against each other is the point of having layers at all, and a caller + that wants the old single-image behaviour asks for it. """ - if reset: - self.placement = Placement() + image = self.vectorizer.load_image(path) + layer = Layer(source=path, name=Path(path).name) + if replace and self.layers: + layer.placement = self.layer.placement + self.layers[self.current] = layer else: - if dx or dy: - self.placement = self.placement.moved(dx, dy) - if zoom and zoom != 1.0: - self.placement = self.placement.zoomed(zoom) - if turn: - self.placement = self.placement.turned(turn) + self.layers.append(layer) + self.current = len(self.layers) - 1 + self._trace(layer) return { + "width": int(image.shape[1]), + "height": int(image.shape[0]), + **self._layers_summary(), + } + + def op_layers(self) -> dict: + return self._layers_summary() + + def op_layer_select(self, index: int) -> dict: + if not 0 <= index < len(self.layers): + raise MThreadError(f"There is no layer {index}.") + self.current = index + return self._layers_summary() + + def op_layer_remove(self, index: int | None = None) -> dict: + if not self.layers: + raise MThreadError("There is nothing to remove.") + index = self.current if index is None else index + if not 0 <= index < len(self.layers): + raise MThreadError(f"There is no layer {index}.") + self.layers.pop(index) + self.current = min(self.current, max(0, len(self.layers) - 1)) + return self._layers_summary() + + def op_layer_visible(self, visible: bool, index: int | None = None) -> dict: + layer = self.layers[self.current if index is None else index] + layer.visible = bool(visible) + return self._layers_summary() + + def op_layer_raise(self, index: int | None = None) -> dict: + """Move a layer later in the order, so it is drawn on top.""" + index = self.current if index is None else index + if index + 1 < len(self.layers): + self.layers[index], self.layers[index + 1] = (self.layers[index + 1], + self.layers[index]) + if self.current == index: + self.current = index + 1 + return self._layers_summary() + + def _layers_summary(self) -> dict: + return { + "layers": [layer.summary() for layer in self.layers], + "current": self.current, "overlay": self._overlay(), - "centre": list(self.placement.centre), - "scale": round(self.placement.scale, 3), - "rotation": round(self.placement.rotation, 1), + "strokes": sum(len(layer.kept) for layer in self.layers if layer.visible), + "points": sum(len(path) for layer in self.layers if layer.visible + for path in layer.kept), } - def op_preview(self, sensitivity: float = 5, detail: float = 7, - method: str = "canny", margin: float = 0.06) -> dict: + # ----------------------------------------------------------------- tracing + + def _trace(self, layer: Layer) -> float: + """Re-run the tracer for one layer, from its own file and settings.""" started = time.perf_counter() - settings = VectorizeSettings.from_sliders(sensitivity, detail, method=method) + # The vectoriser holds one image, so a layer that is not the one it last + # loaded has to be read again. Reading a JPEG costs a few milliseconds; + # keeping a vectoriser per layer would cost a copy of every image. + self.vectorizer.load_image(layer.source) + settings = VectorizeSettings.from_sliders( + layer.sensitivity, layer.detail, method=layer.method) preview, paths = self.vectorizer.process(settings) - self.paths = paths + layer.paths = paths + # Erasures were aimed at strokes that may no longer exist. + layer.erased = set() path = Path(tempfile.gettempdir()) / "mthread_draw_preview.png" preview.save(path) + self._preview_path = str(path) + return time.perf_counter() - started + + def op_preview(self, sensitivity: float = 5, detail: float = 7, + method: str = "canny") -> dict: + """Re-trace the selected layer with new settings, without reloading it.""" + layer = self.layer + layer.sensitivity, layer.detail, layer.method = sensitivity, detail, method + seconds = self._trace(layer) return { - "path": str(path), - "overlay": self._overlay(), - "strokes": len(paths), - "points": sum(len(p) for p in paths), - "seconds": round(time.perf_counter() - started, 2), + "path": self._preview_path, + "seconds": round(seconds, 2), + **self._layers_summary(), } + # --------------------------------------------------------------- placement + + def op_place(self, dx: float = 0.0, dy: float = 0.0, zoom: float = 1.0, + turn: float = 0.0, flip_x: bool = False, flip_y: bool = False, + reset: bool = False) -> dict: + """Nudge the selected layer about, and redraw the overlay. + + Relative rather than absolute, because the front end is describing a + gesture - a drag of so far, a wheel notch - and relative keeps the two + ends from disagreeing about what the current position is. + """ + layer = self.layer + if reset: + layer.placement = Placement() + else: + if dx or dy: + layer.placement = layer.placement.moved(dx, dy) + if zoom and zoom != 1.0: + layer.placement = layer.placement.zoomed(zoom) + if turn: + layer.placement = layer.placement.turned(turn) + if flip_x or flip_y: + layer.placement = layer.placement.mirrored(horizontal=flip_x, + vertical=flip_y) + return self._layers_summary() + + # ------------------------------------------------------------------ eraser + + def op_erase(self, x: float = 0.0, y: float = 0.0, radius: float = 0.02, + undo: bool = False) -> dict: + """Take out the strokes of the selected layer near a point. + + The point and the radius are fractions of the screen, because that is + what the front end knows: it has a picture of the phone under the + cursor, not the tracer's coordinate space. Which stroke is near enough + is therefore decided after placement, not before it. + """ + layer = self.layer + if undo: + layer.erased = set() + return self._layers_summary() + + width, height = self.screen_now() + placed = place_on_screen(layer.paths, width, height, layer.placement) + reach = radius * max(width, height) + target_x, target_y = x * width, y * height + + for index, stroke in enumerate(placed): + if index in layer.erased: + continue + for px, py in stroke: + if (px - target_x) ** 2 + (py - target_y) ** 2 <= reach * reach: + layer.erased.add(index) + break + return self._layers_summary() + + # ---------------------------------------------------------------- overlays + def _overlay(self) -> str | None: - """The strokes where they will actually land, on a transparent ground. + """Every visible layer where it will land, on a transparent ground. A preview beside the phone tells you what will be drawn; a preview lying - on top of the phone tells you where. The second is the one people - actually want, and it costs nothing extra - the placement is the same - call the drawing itself makes. + on top of the phone tells you where. The selected layer is drawn bright + and the rest dim, so it is clear which one the mouse is about to move. """ - if self.device is None or not self.paths: + if not self.layers: return None width, height = self.screen_now() - placed = place_on_screen(self.paths, width, height, self.placement) - scale = OVERLAY_WIDTH / width image = Image.new("RGBA", (OVERLAY_WIDTH, round(height * scale)), (0, 0, 0, 0)) draw = ImageDraw.Draw(image) - for stroke in placed: - if len(stroke) >= 2: - draw.line([(x * scale, y * scale) for x, y in stroke], - fill=(255, 64, 58, 235), width=2, joint="curve") + + for index, layer in enumerate(self.layers): + if not layer.visible or not layer.kept: + continue + colour = (255, 64, 58, 235) if index == self.current else (110, 190, 255, 150) + for stroke in place_on_screen(layer.kept, width, height, layer.placement): + if len(stroke) >= 2: + draw.line([(x * scale, y * scale) for x, y in stroke], + fill=colour, width=2, joint="curve") path = Path(tempfile.gettempdir()) / "mthread_draw_overlay.png" image.save(path) return str(path) + # ----------------------------------------------------------------- drawing + + def _all_placed(self) -> list: + """Every visible layer's strokes, in device pixels, in drawing order.""" + width, height = self.screen_now() + placed = [] + for layer in self.layers: + if layer.visible and layer.kept: + placed.extend(place_on_screen(layer.kept, width, height, layer.placement)) + return placed + def op_estimate(self, speed: float = 1.0, human: float = 0.0) -> dict: self._require_device() - if not self.paths: + placed = self._all_placed() + if not placed: return {"seconds": 0.0} - paths = simulate(self.paths, human, seed=0) if human > 0 else self.paths + paths = simulate(placed, human, seed=0) if human > 0 else placed return {"seconds": round(self.device.estimate_duration(paths, speed=speed), 1)} - def op_draw(self, speed: float = 1.0, human: float = 0.0, - margin: float = 0.06) -> dict: + def op_draw(self, speed: float = 1.0, human: float = 0.0) -> dict: self._require_device() - if not self.paths: - raise MThreadError("Load an image and take a preview first.") + placed = self._all_placed() + if not placed: + raise MThreadError("Load an image first.") - width, height = self.screen_now() - placed = place_on_screen(self.paths, width, height, self.placement) self.cancel.clear() self.drawing.set() self.status(f"Drawing {len(placed)} strokes") @@ -264,6 +443,7 @@ def op_draw(self, speed: float = 1.0, human: float = 0.0, self.drawing.clear() return {"strokes": drawn, "stopped": self.cancel.is_set()} + # ---------------------------------------------------- recording gestures def op_record_start(self) -> dict: @@ -335,6 +515,21 @@ def op_stop(self) -> dict: self.cancel.set() return {"stopping": True} + def op_still(self, path: str) -> dict: + """Use a screenshot taken by hand instead of one captured over ADB. + + Capture fails on some devices and in some emulators, and when it does + there is nothing to place a drawing against. A screenshot taken on the + phone and copied across is a picture of the same screen; it does not + update, but it is enough to arrange a drawing on, and its proportions + are the ones that matter for placement. + """ + with Image.open(path) as image: + self.still = path + self.still_size = image.size + return {"path": path, "width": self.still_size[0], "height": self.still_size[1], + **self._layers_summary()} + def screen_now(self) -> tuple[int, int]: """The screen as it is being held, not as it was manufactured. @@ -344,6 +539,11 @@ def screen_now(self) -> tuple[int, int]: captured frame is the one thing that knows: if it is the other way round from the natural size, so is the phone. """ + if self.device is None: + # No phone attached: a screenshot supplied by hand is the only thing + # that knows the shape of the screen, and failing that a common one + # keeps placement working so a drawing can be arranged in advance. + return self.still_size or (1080, 1920) width, height = self.device.screen_size if self.frame_size: frame_width, frame_height = self.frame_size @@ -357,8 +557,10 @@ def _require_device(self) -> None: #: Operations that may take a while, and so must not block the reader. -SLOW = {"preview", "draw", "screenshot", "connect", "mirror", "place", - "play", "record_stop", "session_open", "session_save"} +SLOW = {"preview", "draw", "screenshot", "connect", "mirror", "place", "erase", + "load_image", "still", "layer_select", "layer_remove", "layer_visible", + "layer_raise", "layers", "play", "record_stop", "session_open", + "session_save"} def serve(stdin=None, stdout=None) -> int: diff --git a/tests/test_engine.py b/tests/test_engine.py new file mode 100644 index 0000000..1d84cc6 --- /dev/null +++ b/tests/test_engine.py @@ -0,0 +1,178 @@ +"""The engine's layers, eraser and placement, driven the way a front end does. + +No device is attached in any of this. Placement and erasing are decided against +a screen size, and the engine knows one whether or not a phone is plugged in - +which is also what makes a drawing arrangeable in advance, and what makes a +screenshot supplied by hand a workable substitute when capture fails. +""" + +import tempfile +import unittest +from pathlib import Path + +from mthread_draw.server import Engine + +#: Tiny drawings rather than the photographs in examples/. The pipeline being +#: exercised is the same one; tracing a real photograph a dozen times over cost +#: the suite most of a minute and told it nothing extra. +_FIXTURES = tempfile.TemporaryDirectory() + + +def _shape(name: str, kind: str) -> Path: + from PIL import Image, ImageDraw + + path = Path(_FIXTURES.name) / name + if not path.exists(): + image = Image.new("RGB", (200, 300), (255, 255, 255)) + draw = ImageDraw.Draw(image) + if kind == "bars": + for index in range(6): + draw.rectangle([(20 + index * 28, 40), (36 + index * 28, 260)], fill=(20, 20, 20)) + else: + for index in range(4): + draw.ellipse([(30 + index * 8, 40 + index * 8), + (170 - index * 8, 260 - index * 8)], outline=(20, 20, 20), width=6) + image.save(path) + return path + + +GUITAR = _shape("bars.png", "bars") +CAT = _shape("rings.png", "rings") + + +class LayerTests(unittest.TestCase): + + def setUp(self): + self.sent = [] + self.engine = Engine(self.sent.append) + + def load(self, image=GUITAR, **fields): + return self.engine.op_load_image(str(image), **fields) + + def test_loading_adds_a_layer_rather_than_replacing_one(self): + self.load(GUITAR) + result = self.load(CAT) + self.assertEqual(len(result["layers"]), 2) + self.assertEqual(result["current"], 1) + self.assertEqual(result["layers"][0]["name"], "bars.png") + self.assertEqual(result["layers"][1]["name"], "rings.png") + + def test_replacing_is_available_and_keeps_the_placement(self): + self.load(GUITAR) + self.engine.op_place(dx=0.2, zoom=0.5) + result = self.load(CAT, replace=True) + self.assertEqual(len(result["layers"]), 1) + self.assertAlmostEqual(result["layers"][0]["scale"], 0.5, places=3) + + def test_each_layer_keeps_its_own_placement(self): + self.load(GUITAR) + self.engine.op_place(zoom=0.25) + self.load(CAT) + self.engine.op_place(turn=90) + + layers = self.engine.op_layers()["layers"] + self.assertAlmostEqual(layers[0]["scale"], 0.25, places=3) + self.assertEqual(layers[0]["rotation"], 0.0) + self.assertAlmostEqual(layers[1]["scale"], 1.0, places=3) + self.assertEqual(layers[1]["rotation"], 90.0) + + def test_a_hidden_layer_is_not_drawn(self): + self.load(GUITAR) + self.load(CAT) + both = self.engine.op_layers()["strokes"] + self.engine.op_layer_visible(visible=False, index=0) + one = self.engine.op_layers()["strokes"] + self.assertLess(one, both) + self.assertEqual(len(self.engine._all_placed()), one) + + def test_removing_a_layer_keeps_the_selection_in_range(self): + self.load(GUITAR) + self.load(CAT) + result = self.engine.op_layer_remove() + self.assertEqual(len(result["layers"]), 1) + self.assertEqual(result["current"], 0) + + def test_raising_a_layer_changes_the_order_things_are_drawn_in(self): + self.load(GUITAR) + self.load(CAT) + self.engine.op_layer_select(index=0) + result = self.engine.op_layer_raise() + self.assertEqual([layer["name"] for layer in result["layers"]], + ["rings.png", "bars.png"]) + self.assertEqual(result["current"], 1) + + def test_selecting_a_layer_that_is_not_there_says_so(self): + self.load(GUITAR) + with self.assertRaises(Exception): + self.engine.op_layer_select(index=7) + + def test_placing_before_anything_is_loaded_says_what_to_do(self): + with self.assertRaises(Exception) as caught: + self.engine.op_place(dx=0.1) + self.assertIn("load an image", str(caught.exception).lower()) + + +class EraserTests(unittest.TestCase): + + def setUp(self): + self.engine = Engine(lambda message: None) + self.engine.op_load_image(str(GUITAR)) + + def centre_of_a_stroke(self): + """A point that is certainly on the drawing, in screen fractions.""" + from mthread.placement import place_on_screen + + width, height = self.engine.screen_now() + placed = place_on_screen(self.engine.layer.paths, width, height, + self.engine.layer.placement) + longest = max(placed, key=len) + x, y = longest[len(longest) // 2] + return x / width, y / height + + def test_erasing_removes_strokes_under_the_point(self): + before = self.engine.op_layers()["strokes"] + x, y = self.centre_of_a_stroke() + after = self.engine.op_erase(x=x, y=y, radius=0.03)["strokes"] + self.assertLess(after, before) + + def test_erasing_nowhere_near_anything_removes_nothing(self): + before = self.engine.op_layers()["strokes"] + after = self.engine.op_erase(x=0.0, y=0.0, radius=0.001)["strokes"] + self.assertEqual(after, before) + + def test_undo_brings_everything_back(self): + before = self.engine.op_layers()["strokes"] + x, y = self.centre_of_a_stroke() + self.engine.op_erase(x=x, y=y, radius=0.05) + self.assertLess(self.engine.op_layers()["strokes"], before) + self.assertEqual(self.engine.op_erase(undo=True)["strokes"], before) + + def test_erased_strokes_are_not_drawn(self): + x, y = self.centre_of_a_stroke() + kept = self.engine.op_erase(x=x, y=y, radius=0.05)["strokes"] + self.assertEqual(len(self.engine._all_placed()), kept) + + def test_re_tracing_forgets_erasures_rather_than_misapplying_them(self): + """Stroke five of one tracing is not stroke five of the next.""" + x, y = self.centre_of_a_stroke() + self.engine.op_erase(x=x, y=y, radius=0.05) + self.assertGreater(self.engine.layer.erased, set()) + self.engine.op_preview(sensitivity=5, detail=4, method="canny") + self.assertEqual(self.engine.layer.erased, set()) + + +class ReprocessTests(unittest.TestCase): + + def test_settings_change_without_the_file_being_loaded_again(self): + engine = Engine(lambda message: None) + engine.op_load_image(str(CAT)) + # Counted in points rather than strokes: detail sets how finely a line is + # followed, and on a simple shape the number of lines does not move. + coarse = engine.op_preview(sensitivity=5, detail=1, method="canny")["points"] + fine = engine.op_preview(sensitivity=5, detail=10, method="canny")["points"] + self.assertGreater(fine, coarse) + self.assertEqual(len(engine.layers), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/winui/MainWindow.xaml b/winui/MainWindow.xaml index 79fd174..b661d87 100644 --- a/winui/MainWindow.xaml +++ b/winui/MainWindow.xaml @@ -55,9 +55,28 @@