// lib/editor/engine/shape_geometry.dart // // Pure geometry for the SHAPE tool. Each shape is generated as a list of // NORMALIZED [PenPoint]s (the same model freehand strokes use), so a shape is // just a [PenStroke] — it reuses stroke rendering, persistence, erase, and undo // with NO new model or storage. Points carry a constant pressure (1.0) so the // brush renders them at a steady width (shapes don't taper with pressure). // // All inputs/outputs are in normalized page coordinates ([0,1] x [0,1]); the // caller wraps the points in a PenStroke with the current brush color/width. import 'dart:math' as math; import '../canvas/editor_tool.dart'; import '../canvas/pen_stroke.dart'; /// Number of points sampled around an ellipse. Kept as a const so tests can pin /// it (spec: "ellipse = sampled points ~48"). The polyline is closed, so the /// last point repeats the first ⇒ [kEllipseSamples] + 1 total points. const int kEllipseSamples = 48; /// Constant pressure baked into every shape point so the brush renders a steady /// width (no pressure taper for geometric shapes). const double _kShapePressure = 1.0; /// Generate the normalized polyline for [kind] spanning [start] → [end]. /// /// * [ShapeKind.line] → 2 points. /// * [ShapeKind.rectangle] → 5 points (closed: 4 corners + repeat of the /// first), an axis-aligned box whose opposite corners are [start]/[end]. /// * [ShapeKind.ellipse] → [kEllipseSamples] + 1 points (closed), inscribed /// in the [start]→[end] bounding box. /// * [ShapeKind.arrow] → shaft (start → end) + two arrowhead segments, /// emitted as a single polyline so it renders as one stroke. List generateShapePoints(ShapeKind kind, PenPoint start, PenPoint end) { switch (kind) { case ShapeKind.line: return [ PenPoint(start.x, start.y, _kShapePressure), PenPoint(end.x, end.y, _kShapePressure), ]; case ShapeKind.rectangle: final l = math.min(start.x, end.x); final r = math.max(start.x, end.x); final t = math.min(start.y, end.y); final b = math.max(start.y, end.y); return [ PenPoint(l, t, _kShapePressure), PenPoint(r, t, _kShapePressure), PenPoint(r, b, _kShapePressure), PenPoint(l, b, _kShapePressure), PenPoint(l, t, _kShapePressure), // close ]; case ShapeKind.ellipse: final cx = (start.x + end.x) / 2; final cy = (start.y + end.y) / 2; final rx = (end.x - start.x).abs() / 2; final ry = (end.y - start.y).abs() / 2; final pts = []; for (var i = 0; i <= kEllipseSamples; i++) { final a = (i / kEllipseSamples) * 2 * math.pi; pts.add(PenPoint( cx + rx * math.cos(a), cy + ry * math.sin(a), _kShapePressure, )); } return pts; case ShapeKind.arrow: // Shaft start→end, then back up the shaft to draw the two head barbs so // the whole arrow is one continuous polyline (no pen lifts). final dx = end.x - start.x; final dy = end.y - start.y; final len = math.sqrt(dx * dx + dy * dy); final pts = [ PenPoint(start.x, start.y, _kShapePressure), PenPoint(end.x, end.y, _kShapePressure), ]; if (len <= 1e-6) return pts; // degenerate: just the (near-zero) shaft // Arrowhead: barbs at ±[_kArrowAngle] from the reversed shaft direction, // [_kArrowHead] of the shaft length (capped) long. final ang = math.atan2(dy, dx); final head = math.min(len * _kArrowHeadFraction, _kArrowHeadMax); for (final sign in const [1.0, -1.0]) { final a = ang + math.pi + sign * _kArrowAngle; pts.add(PenPoint( end.x + head * math.cos(a), end.y + head * math.sin(a), _kShapePressure, )); pts.add(PenPoint(end.x, end.y, _kShapePressure)); // back to the tip } return pts; } } /// Arrowhead barb length as a fraction of the shaft length. const double _kArrowHeadFraction = 0.25; /// Hard cap on the barb length (normalized) so a long arrow's head stays sane. const double _kArrowHeadMax = 0.06; /// Half-angle of the arrowhead barbs from the shaft (radians ≈ 28°). const double _kArrowAngle = 0.5; /// Return a copy of [points] translated by ([dx],[dy]) in normalized coords, /// preserving pressure/tilt. Used by the SELECT tool to drag a stroke. List translatePoints(List points, double dx, double dy) => [for (final p in points) PenPoint(p.x + dx, p.y + dy, p.pressure, tilt: p.tilt)]; /// A translated copy of [stroke] (its points shifted by [dx],[dy]); color, /// width, kind, and brush are preserved. PenStroke translateStroke(PenStroke stroke, double dx, double dy) => PenStroke( points: translatePoints(stroke.points, dx, dy), color: stroke.color, width: stroke.width, kind: stroke.kind, brush: stroke.brush, ); /// Tight normalized bounds of [stroke]'s points, or null when it has no points. /// Used by the SELECT tool to draw the selection bounding box. ({double left, double top, double right, double bottom})? penStrokeBounds( PenStroke stroke) { if (stroke.points.isEmpty) return null; var l = double.infinity, t = double.infinity; var r = double.negativeInfinity, b = double.negativeInfinity; for (final p in stroke.points) { if (p.x < l) l = p.x; if (p.y < t) t = p.y; if (p.x > r) r = p.x; if (p.y > b) b = p.y; } return (left: l, top: t, right: r, bottom: b); }