diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 55ccb10f0..a1072e995 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -65,6 +65,7 @@ const NavigationKind = @import("webapi/navigation/root.zig").NavigationKind; const PointList = @import("webapi/svg/PointList.zig"); const StringList = @import("webapi/svg/StringList.zig"); const AnimatedLength = @import("webapi/svg/AnimatedLength.zig"); +const AnimatedNumber = @import("webapi/svg/AnimatedNumber.zig"); const AnimatedString = @import("webapi/svg/AnimatedString.zig"); const AnimatedTransformList = @import("webapi/svg/AnimatedTransformList.zig"); const AnimatedPreserveAspectRatio = @import("webapi/svg/AnimatedPreserveAspectRatio.zig"); @@ -150,6 +151,7 @@ _node_owner_documents: Node.OwnerDocumentLookup = .empty, _element_scroll_positions: Element.ScrollPositionLookup = .empty, _element_namespace_uris: Element.NamespaceUriLookup = .empty, _svg_animated_lengths: AnimatedLength.Lookup = .empty, +_svg_animated_numbers: AnimatedNumber.Lookup = .empty, _svg_animated_preserve_aspect_ratios: AnimatedPreserveAspectRatio.Lookup = .empty, _svg_animated_strings: AnimatedString.Lookup = .empty, _svg_animated_transform_lists: AnimatedTransformList.Lookup = .empty, diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 021bdb9e3..b0c13794e 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -1074,6 +1074,7 @@ pub const PageJsApis = flattenTypes(&.{ @import("../webapi/svg/Angle.zig"), @import("../webapi/svg/Transform.zig"), @import("../webapi/svg/AnimatedLength.zig"), + @import("../webapi/svg/AnimatedNumber.zig"), @import("../webapi/svg/PreserveAspectRatio.zig"), @import("../webapi/svg/AnimatedPreserveAspectRatio.zig"), @import("../webapi/svg/PointList.zig"), diff --git a/src/browser/tests/element/svg/geometry.html b/src/browser/tests/element/svg/geometry.html new file mode 100644 index 000000000..0e143b375 --- /dev/null +++ b/src/browser/tests/element/svg/geometry.html @@ -0,0 +1,173 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/tests/element/svg/hierarchy.html b/src/browser/tests/element/svg/hierarchy.html index dac451853..441b94b4b 100644 --- a/src/browser/tests/element/svg/hierarchy.html +++ b/src/browser/tests/element/svg/hierarchy.html @@ -38,6 +38,7 @@ testing.expectEqual('function', typeof SVGPolylineElement); testing.expectEqual('function', typeof SVGAnimatedString); testing.expectEqual('function', typeof SVGNumber); + testing.expectEqual('function', typeof SVGAnimatedNumber); } diff --git a/src/browser/webapi/element/svg/Circle.zig b/src/browser/webapi/element/svg/Circle.zig index 067f23904..089e44b06 100644 --- a/src/browser/webapi/element/svg/Circle.zig +++ b/src/browser/webapi/element/svg/Circle.zig @@ -17,11 +17,13 @@ // along with this program. If not, see . const js = @import("../../../js/js.zig"); +const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const Geometry = @import("Geometry.zig"); +const AnimatedLength = @import("../../svg/AnimatedLength.zig"); const Circle = @This(); _proto: *Geometry, @@ -41,4 +43,18 @@ pub const JsApi = struct { pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; + + pub const cx = bridge.accessor(Circle.getCx, null, .{}); + pub const cy = bridge.accessor(Circle.getCy, null, .{}); + pub const r = bridge.accessor(Circle.getR, null, .{}); }; + +pub fn getCx(self: *Circle, frame: *Frame) !*AnimatedLength { + return AnimatedLength.getOrCreate(self.asElement(), .cx, frame); +} +pub fn getCy(self: *Circle, frame: *Frame) !*AnimatedLength { + return AnimatedLength.getOrCreate(self.asElement(), .cy, frame); +} +pub fn getR(self: *Circle, frame: *Frame) !*AnimatedLength { + return AnimatedLength.getOrCreate(self.asElement(), .r, frame); +} diff --git a/src/browser/webapi/element/svg/Ellipse.zig b/src/browser/webapi/element/svg/Ellipse.zig index 00a1311b5..c42a46a7c 100644 --- a/src/browser/webapi/element/svg/Ellipse.zig +++ b/src/browser/webapi/element/svg/Ellipse.zig @@ -17,11 +17,13 @@ // along with this program. If not, see . const js = @import("../../../js/js.zig"); +const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const Geometry = @import("Geometry.zig"); +const AnimatedLength = @import("../../svg/AnimatedLength.zig"); const Ellipse = @This(); _proto: *Geometry, @@ -41,4 +43,22 @@ pub const JsApi = struct { pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; + + pub const cx = bridge.accessor(Ellipse.getCx, null, .{}); + pub const cy = bridge.accessor(Ellipse.getCy, null, .{}); + pub const rx = bridge.accessor(Ellipse.getRx, null, .{}); + pub const ry = bridge.accessor(Ellipse.getRy, null, .{}); }; + +pub fn getCx(self: *Ellipse, frame: *Frame) !*AnimatedLength { + return AnimatedLength.getOrCreate(self.asElement(), .cx, frame); +} +pub fn getCy(self: *Ellipse, frame: *Frame) !*AnimatedLength { + return AnimatedLength.getOrCreate(self.asElement(), .cy, frame); +} +pub fn getRx(self: *Ellipse, frame: *Frame) !*AnimatedLength { + return AnimatedLength.getOrCreate(self.asElement(), .rx, frame); +} +pub fn getRy(self: *Ellipse, frame: *Frame) !*AnimatedLength { + return AnimatedLength.getOrCreate(self.asElement(), .ry, frame); +} diff --git a/src/browser/webapi/element/svg/Geometry.zig b/src/browser/webapi/element/svg/Geometry.zig index 280feb818..e1b4df58a 100644 --- a/src/browser/webapi/element/svg/Geometry.zig +++ b/src/browser/webapi/element/svg/Geometry.zig @@ -16,12 +16,19 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const std = @import("std"); + const js = @import("../../../js/js.zig"); +const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); +const DOMPoint = @import("../../DOMPoint.zig"); const Graphics = @import("Graphics.zig"); +const AnimatedNumber = @import("../../svg/AnimatedNumber.zig"); +const AnimatedLength = @import("../../svg/AnimatedLength.zig"); +const PathData = @import("../../svg/PathData.zig"); pub const Rect = @import("Rect.zig"); pub const Circle = @import("Circle.zig"); pub const Ellipse = @import("Ellipse.zig"); @@ -70,4 +77,174 @@ pub const JsApi = struct { pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; + + pub const pathLength = bridge.accessor(Geometry.getPathLength, null, .{}); + pub const getTotalLength = bridge.function(Geometry.getTotalLength, .{}); + pub const getPointAtLength = bridge.function(Geometry.getPointAtLength, .{}); }; + +pub fn getPathLength(self: *Geometry, frame: *Frame) !*AnimatedNumber { + return AnimatedNumber.getOrCreate(self.asElement(), .path_length, frame); +} + +pub fn getTotalLength(self: *Geometry, frame: *Frame) !f64 { + var path = try self.buildPath(frame); + defer path.deinit(frame.local_arena); + return path.totalLength(frame.local_arena); +} + +pub fn getPointAtLength(self: *Geometry, distance: f64, frame: *Frame) !*DOMPoint { + if (!std.math.isFinite(distance)) return error.TypeError; + var path = try self.buildPath(frame); + defer path.deinit(frame.local_arena); + const point = try path.pointAtLength(distance, frame.local_arena); + return DOMPoint.create(point.x, point.y, 0, 1, frame._page); +} + +pub fn buildPath(self: *Geometry, frame: *Frame) !PathData.Path { + return switch (self._type) { + .rect => |rect| buildRect(rect, frame), + .circle => |circle| buildCircle(circle, frame), + .ellipse => |ellipse| buildEllipse(ellipse, frame), + .line => |line| buildLine(line, frame), + .path => |path| PathData.parse( + path.asElement().getAttributeSafe(comptime .wrap("d")) orelse "", + frame.local_arena, + ), + .polygon => |polygon| buildPoints(try polygon.getPoints(frame), true, frame), + .polyline => |polyline| buildPoints(try polyline.getPoints(frame), false, frame), + }; +} + +fn value(length: *AnimatedLength, frame: *Frame) ?f64 { + const base = length.getBaseVal(); + if (base.getUnitType() == 0) return null; + const result = base.getValue(frame); + return if (std.math.isFinite(result)) result else null; +} + +fn buildRect(rect: *Rect, frame: *Frame) !PathData.Path { + var path: PathData.Path = .{}; + errdefer path.deinit(frame.local_arena); + + const x = value(try rect.getX(frame), frame) orelse 0; + const y = value(try rect.getY(frame), frame) orelse 0; + const width = value(try rect.getWidth(frame), frame) orelse 0; + const height = value(try rect.getHeight(frame), frame) orelse 0; + if (width <= 0 or height <= 0) return path; + + const rx_value = optionalRadius(rect.asElement(), "rx", try rect.getRx(frame), frame); + const ry_value = optionalRadius(rect.asElement(), "ry", try rect.getRy(frame), frame); + var rx = rx_value orelse ry_value orelse 0; + var ry = ry_value orelse rx_value orelse 0; + rx = @min(rx, width / 2.0); + ry = @min(ry, height / 2.0); + + const top_left = PathData.Point{ .x = x, .y = y }; + if (rx == 0 or ry == 0) { + const top_right = PathData.Point{ .x = x + width, .y = y }; + const bottom_right = PathData.Point{ .x = x + width, .y = y + height }; + const bottom_left = PathData.Point{ .x = x, .y = y + height }; + try path.appendLine(top_left, top_right, frame.local_arena); + try path.appendLine(top_right, bottom_right, frame.local_arena); + try path.appendLine(bottom_right, bottom_left, frame.local_arena); + try path.appendLine(bottom_left, top_left, frame.local_arena); + return path; + } + + const start = PathData.Point{ .x = x + rx, .y = y }; + const top_end = PathData.Point{ .x = x + width - rx, .y = y }; + const right_start = PathData.Point{ .x = x + width, .y = y + ry }; + const right_end = PathData.Point{ .x = x + width, .y = y + height - ry }; + const bottom_start = PathData.Point{ .x = x + width - rx, .y = y + height }; + const bottom_end = PathData.Point{ .x = x + rx, .y = y + height }; + const left_start = PathData.Point{ .x = x, .y = y + height - ry }; + const left_end = PathData.Point{ .x = x, .y = y + ry }; + + try path.appendLine(start, top_end, frame.local_arena); + try path.appendArc(top_end, rx, ry, 0, false, true, right_start, frame.local_arena); + try path.appendLine(right_start, right_end, frame.local_arena); + try path.appendArc(right_end, rx, ry, 0, false, true, bottom_start, frame.local_arena); + try path.appendLine(bottom_start, bottom_end, frame.local_arena); + try path.appendArc(bottom_end, rx, ry, 0, false, true, left_start, frame.local_arena); + try path.appendLine(left_start, left_end, frame.local_arena); + try path.appendArc(left_end, rx, ry, 0, false, true, start, frame.local_arena); + return path; +} + +fn optionalRadius(element: *Element, comptime name: []const u8, length: *AnimatedLength, frame: *Frame) ?f64 { + const raw = element.getAttributeSafe(comptime .wrap(name)) orelse return null; + if (std.ascii.eqlIgnoreCase(std.mem.trim(u8, raw, " \t\r\n\x0c"), "auto")) return null; + const result = value(length, frame) orelse return null; + return if (result < 0) null else result; +} + +fn buildCircle(circle: *Circle, frame: *Frame) !PathData.Path { + const center = PathData.Point{ + .x = value(try circle.getCx(frame), frame) orelse 0, + .y = value(try circle.getCy(frame), frame) orelse 0, + }; + const radius = value(try circle.getR(frame), frame) orelse 0; + return buildEllipsePath(center, radius, radius, frame); +} + +fn buildEllipse(ellipse: *Ellipse, frame: *Frame) !PathData.Path { + const center = PathData.Point{ + .x = value(try ellipse.getCx(frame), frame) orelse 0, + .y = value(try ellipse.getCy(frame), frame) orelse 0, + }; + const rx = optionalRadius(ellipse.asElement(), "rx", try ellipse.getRx(frame), frame); + const ry = optionalRadius(ellipse.asElement(), "ry", try ellipse.getRy(frame), frame); + return buildEllipsePath(center, rx orelse ry orelse 0, ry orelse rx orelse 0, frame); +} + +fn buildEllipsePath(center: PathData.Point, rx: f64, ry: f64, frame: *Frame) !PathData.Path { + var path: PathData.Path = .{}; + errdefer path.deinit(frame.local_arena); + if (rx <= 0 or ry <= 0) return path; + + const right = PathData.Point{ .x = center.x + rx, .y = center.y }; + const bottom = PathData.Point{ .x = center.x, .y = center.y + ry }; + const left = PathData.Point{ .x = center.x - rx, .y = center.y }; + const top = PathData.Point{ .x = center.x, .y = center.y - ry }; + try path.appendArc(right, rx, ry, 0, false, true, bottom, frame.local_arena); + try path.appendArc(bottom, rx, ry, 0, false, true, left, frame.local_arena); + try path.appendArc(left, rx, ry, 0, false, true, top, frame.local_arena); + try path.appendArc(top, rx, ry, 0, false, true, right, frame.local_arena); + return path; +} + +fn buildLine(line: *Line, frame: *Frame) !PathData.Path { + var path: PathData.Path = .{}; + errdefer path.deinit(frame.local_arena); + const start = PathData.Point{ + .x = value(try line.getX1(frame), frame) orelse 0, + .y = value(try line.getY1(frame), frame) orelse 0, + }; + const end = PathData.Point{ + .x = value(try line.getX2(frame), frame) orelse 0, + .y = value(try line.getY2(frame), frame) orelse 0, + }; + try path.appendLine(start, end, frame.local_arena); + return path; +} + +fn buildPoints(list: anytype, close: bool, frame: *Frame) !PathData.Path { + var path: PathData.Path = .{}; + errdefer path.deinit(frame.local_arena); + const count = try list.getNumberOfItems(frame); + if (count == 0) return path; + + const first_item = try list.getItem(0, frame); + const first = PathData.Point{ .x = first_item.getX(), .y = first_item.getY() }; + path.first_point = first; + var previous = first; + for (1..count) |index| { + const item = try list.getItem(@intCast(index), frame); + const next = PathData.Point{ .x = item.getX(), .y = item.getY() }; + try path.appendLine(previous, next, frame.local_arena); + previous = next; + } + if (close and count > 1) try path.appendLine(previous, first, frame.local_arena); + return path; +} diff --git a/src/browser/webapi/element/svg/Graphics.zig b/src/browser/webapi/element/svg/Graphics.zig index 93d10c762..70a6e7b27 100644 --- a/src/browser/webapi/element/svg/Graphics.zig +++ b/src/browser/webapi/element/svg/Graphics.zig @@ -16,12 +16,17 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const std = @import("std"); + const js = @import("../../../js/js.zig"); const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); +const DOMRect = @import("../../DOMRect.zig"); +const DOMMatrixReadOnly = @import("../../DOMMatrixReadOnly.zig"); const SvgElement = @import("../Svg.zig"); const AnimatedTransformList = @import("../../svg/AnimatedTransformList.zig"); +const PathData = @import("../../svg/PathData.zig"); const StringList = @import("../../svg/StringList.zig"); pub const Svg = @import("Svg.zig"); @@ -79,8 +84,83 @@ pub const JsApi = struct { pub const transform = bridge.accessor(Graphics.getTransform, null, .{}); pub const requiredExtensions = bridge.accessor(Graphics.getRequiredExtensions, null, .{}); pub const systemLanguage = bridge.accessor(Graphics.getSystemLanguage, null, .{}); + pub const getBBox = bridge.function(Graphics.getBBox, .{}); }; +const BoundingBoxOptions = struct { + fill: bool = true, + stroke: bool = false, + markers: bool = false, + clipped: bool = false, +}; + +pub fn getBBox(self: *Graphics, options_: ?BoundingBoxOptions, frame: *Frame) !*DOMRect { + const options = options_ orelse BoundingBoxOptions{}; + if (options.stroke or options.markers or options.clipped) return error.NotSupported; + if (!options.fill) return DOMRect.create(.{}, frame._factory); + + var bounds: PathData.Bounds = .{}; + switch (self._type) { + .geometry => |geometry| { + var path = try geometry.buildPath(frame); + defer path.deinit(frame.local_arena); + bounds = path.bounds(.{}); + }, + .g, .a, .svg => try accumulateChildren(self, .{}, &bounds, frame), + .defs, .use, .image => return error.InvalidStateError, + } + if (bounds.isEmpty()) return DOMRect.create(.{}, frame._factory); + return DOMRect.create(.{ + .x = bounds.min_x, + .y = bounds.min_y, + .width = bounds.width(), + .height = bounds.height(), + }, frame._factory); +} + +fn accumulateChildren(parent: *Graphics, matrix: PathData.Matrix, bounds: *PathData.Bounds, frame: *Frame) !void { + var child = parent.asNode().firstChild(); + while (child) |node| : (child = node.nextSibling()) { + const element = node.is(Element) orelse continue; + if (element._namespace != .svg) continue; + const svg = element.as(SvgElement); + const graphics = svg.is(Graphics) orelse continue; + const child_matrix = matrix.multiply(transformMatrix(element)); + + switch (graphics._type) { + .geometry => |geometry| { + var path = try geometry.buildPath(frame); + defer path.deinit(frame.local_arena); + bounds.merge(path.bounds(child_matrix)); + }, + .g, .a => try accumulateChildren(graphics, child_matrix, bounds, frame), + .defs => {}, + .svg, .use, .image => return error.InvalidStateError, + } + } +} + +fn transformMatrix(element: *Element) PathData.Matrix { + const raw = element.getAttributeSafe(comptime .wrap("transform")) orelse return .{}; + const trimmed = std.mem.trim(u8, raw, " \t\r\n"); + if (trimmed.len == 0 or std.mem.eql(u8, trimmed, "none")) return .{}; + + var matrix = DOMMatrixReadOnly.identity(); + var iterator = DOMMatrixReadOnly.TransformFunctionIterator{ .input = trimmed, .allow_comma = true }; + while (iterator.next() catch return .{}) |function| { + const parsed = DOMMatrixReadOnly.parseTransformFunction(function, .svg) catch return .{}; + matrix = DOMMatrixReadOnly.multiplyMatrix(matrix, parsed.matrix); + } + return .{ + .a = matrix[0], + .b = matrix[1], + .c = matrix[4], + .d = matrix[5], + .e = matrix[12], + .f = matrix[13], + }; +} + pub fn getTransform(self: *Graphics, frame: *Frame) !*AnimatedTransformList { return AnimatedTransformList.getOrCreate(self.asElement(), frame); } diff --git a/src/browser/webapi/element/svg/Line.zig b/src/browser/webapi/element/svg/Line.zig index d40e1e305..139b421f1 100644 --- a/src/browser/webapi/element/svg/Line.zig +++ b/src/browser/webapi/element/svg/Line.zig @@ -17,11 +17,13 @@ // along with this program. If not, see . const js = @import("../../../js/js.zig"); +const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const Geometry = @import("Geometry.zig"); +const AnimatedLength = @import("../../svg/AnimatedLength.zig"); const Line = @This(); _proto: *Geometry, @@ -41,4 +43,22 @@ pub const JsApi = struct { pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; + + pub const x1 = bridge.accessor(Line.getX1, null, .{}); + pub const y1 = bridge.accessor(Line.getY1, null, .{}); + pub const x2 = bridge.accessor(Line.getX2, null, .{}); + pub const y2 = bridge.accessor(Line.getY2, null, .{}); }; + +pub fn getX1(self: *Line, frame: *Frame) !*AnimatedLength { + return AnimatedLength.getOrCreate(self.asElement(), .x1, frame); +} +pub fn getY1(self: *Line, frame: *Frame) !*AnimatedLength { + return AnimatedLength.getOrCreate(self.asElement(), .y1, frame); +} +pub fn getX2(self: *Line, frame: *Frame) !*AnimatedLength { + return AnimatedLength.getOrCreate(self.asElement(), .x2, frame); +} +pub fn getY2(self: *Line, frame: *Frame) !*AnimatedLength { + return AnimatedLength.getOrCreate(self.asElement(), .y2, frame); +} diff --git a/src/browser/webapi/element/svg/Rect.zig b/src/browser/webapi/element/svg/Rect.zig index c6dd4f40c..d841e39a0 100644 --- a/src/browser/webapi/element/svg/Rect.zig +++ b/src/browser/webapi/element/svg/Rect.zig @@ -17,9 +17,11 @@ // along with this program. If not, see . const js = @import("../../../js/js.zig"); +const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const Geometry = @import("Geometry.zig"); +const AnimatedLength = @import("../../svg/AnimatedLength.zig"); const Rect = @This(); _proto: *Geometry, @@ -39,4 +41,30 @@ pub const JsApi = struct { pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; }; + + pub const x = bridge.accessor(Rect.getX, null, .{}); + pub const y = bridge.accessor(Rect.getY, null, .{}); + pub const width = bridge.accessor(Rect.getWidth, null, .{}); + pub const height = bridge.accessor(Rect.getHeight, null, .{}); + pub const rx = bridge.accessor(Rect.getRx, null, .{}); + pub const ry = bridge.accessor(Rect.getRy, null, .{}); }; + +pub fn getX(self: *Rect, frame: *Frame) !*AnimatedLength { + return AnimatedLength.getOrCreate(self.asElement(), .x, frame); +} +pub fn getY(self: *Rect, frame: *Frame) !*AnimatedLength { + return AnimatedLength.getOrCreate(self.asElement(), .y, frame); +} +pub fn getWidth(self: *Rect, frame: *Frame) !*AnimatedLength { + return AnimatedLength.getOrCreate(self.asElement(), .width, frame); +} +pub fn getHeight(self: *Rect, frame: *Frame) !*AnimatedLength { + return AnimatedLength.getOrCreate(self.asElement(), .height, frame); +} +pub fn getRx(self: *Rect, frame: *Frame) !*AnimatedLength { + return AnimatedLength.getOrCreate(self.asElement(), .rx, frame); +} +pub fn getRy(self: *Rect, frame: *Frame) !*AnimatedLength { + return AnimatedLength.getOrCreate(self.asElement(), .ry, frame); +} diff --git a/src/browser/webapi/element/svg/Svg.zig b/src/browser/webapi/element/svg/Svg.zig index 42419255d..ab60357f9 100644 --- a/src/browser/webapi/element/svg/Svg.zig +++ b/src/browser/webapi/element/svg/Svg.zig @@ -102,7 +102,7 @@ pub fn createSVGRect(_: *Svg, frame: *Frame) !*DOMRect { } pub fn createSVGNumber(_: *Svg, frame: *Frame) !*SvgNumber { - return frame._factory.create(SvgNumber{}); + return SvgNumber.detached(frame); } pub fn createSVGLength(_: *Svg, frame: *Frame) !*SvgLength { diff --git a/src/browser/webapi/svg/AnimatedLength.zig b/src/browser/webapi/svg/AnimatedLength.zig index 299b344ea..47fb4927a 100644 --- a/src/browser/webapi/svg/AnimatedLength.zig +++ b/src/browser/webapi/svg/AnimatedLength.zig @@ -34,6 +34,15 @@ pub const Kind = enum { y, width, height, + cx, + cy, + r, + rx, + ry, + x1, + y1, + x2, + y2, fn attributeName(self: Kind) lp.String { return switch (self) { @@ -41,13 +50,23 @@ pub const Kind = enum { .y => comptime .wrap("y"), .width => comptime .wrap("width"), .height => comptime .wrap("height"), + .cx => comptime .wrap("cx"), + .cy => comptime .wrap("cy"), + .r => comptime .wrap("r"), + .rx => comptime .wrap("rx"), + .ry => comptime .wrap("ry"), + .x1 => comptime .wrap("x1"), + .y1 => comptime .wrap("y1"), + .x2 => comptime .wrap("x2"), + .y2 => comptime .wrap("y2"), }; } fn direction(self: Kind) Length.Direction { return switch (self) { - .x, .width => .horizontal, - .y, .height => .vertical, + .x, .width, .cx, .rx, .x1, .x2 => .horizontal, + .y, .height, .cy, .ry, .y1, .y2 => .vertical, + .r => .unspecified, }; } }; diff --git a/src/browser/webapi/svg/AnimatedNumber.zig b/src/browser/webapi/svg/AnimatedNumber.zig new file mode 100644 index 000000000..7973f50fe --- /dev/null +++ b/src/browser/webapi/svg/AnimatedNumber.zig @@ -0,0 +1,96 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +const std = @import("std"); +const lp = @import("lightpanda"); + +const js = @import("../../js/js.zig"); +const Frame = @import("../../Frame.zig"); +const Element = @import("../Element.zig"); + +const AnimatedNumber = @This(); + +_element: *Element, +_attr_name: lp.String, +_allow_percentage: bool, + +pub const Kind = enum { + path_length, + offset, + + fn attributeName(self: Kind) lp.String { + return switch (self) { + .path_length => comptime .wrap("pathLength"), + .offset => comptime .wrap("offset"), + }; + } + + fn allowsPercentage(self: Kind) bool { + return switch (self) { + .path_length => false, + .offset => true, + }; + } +}; + +pub const Key = struct { + element: *Element, + kind: Kind, +}; + +pub const Lookup = std.AutoHashMapUnmanaged(Key, *AnimatedNumber); + +pub fn getOrCreate(element: *Element, kind: Kind, frame: *Frame) !*AnimatedNumber { + const key: Key = .{ .element = element, .kind = kind }; + const gop = try frame._svg_animated_numbers.getOrPut(frame.arena, key); + if (!gop.found_existing) { + errdefer _ = frame._svg_animated_numbers.remove(key); + gop.value_ptr.* = try frame._factory.create(AnimatedNumber{ + ._element = element, + ._attr_name = kind.attributeName(), + ._allow_percentage = kind.allowsPercentage(), + }); + } + return gop.value_ptr.*; +} + +pub fn getBaseVal(self: *const AnimatedNumber) f32 { + return self.currentValue(); +} + +pub fn setBaseVal(self: *AnimatedNumber, value: f32, frame: *Frame) !void { + if (!std.math.isFinite(value)) return error.TypeError; + const serialized = try std.fmt.allocPrint(frame.call_arena, "{d}", .{value}); + try self._element.setAttributeSafe(self._attr_name, lp.String.wrap(serialized), frame); +} + +pub fn getAnimVal(self: *const AnimatedNumber) f32 { + return self.currentValue(); +} + +fn currentValue(self: *const AnimatedNumber) f32 { + const raw = self._element.getAttributeSafe(self._attr_name) orelse return 0; + const trimmed = std.mem.trim(u8, raw, " \t\r\n\x0c"); + const value = if (self._allow_percentage and std.mem.endsWith(u8, trimmed, "%")) + (std.fmt.parseFloat(f32, trimmed[0 .. trimmed.len - 1]) catch return 0) / 100 + else + std.fmt.parseFloat(f32, trimmed) catch return 0; + return if (std.math.isFinite(value)) value else 0; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(AnimatedNumber); + + pub const Meta = struct { + pub const name = "SVGAnimatedNumber"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const baseVal = bridge.accessor(AnimatedNumber.getBaseVal, AnimatedNumber.setBaseVal, .{}); + pub const animVal = bridge.accessor(AnimatedNumber.getAnimVal, null, .{}); +}; diff --git a/src/browser/webapi/svg/Number.zig b/src/browser/webapi/svg/Number.zig index d1a29feca..c1c547d26 100644 --- a/src/browser/webapi/svg/Number.zig +++ b/src/browser/webapi/svg/Number.zig @@ -16,17 +16,54 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const std = @import("std"); +const lp = @import("lightpanda"); + const js = @import("../../js/js.zig"); +const Frame = @import("../../Frame.zig"); +const Element = @import("../Element.zig"); const Number = @This(); _value: f32 = 0, +_element: ?*Element = null, +_attr_name: lp.String = .empty, +_read_only: bool = false, -pub fn getValue(self: *const Number) f32 { +pub fn detached(frame: *Frame) !*Number { + return frame._factory.create(Number{}); +} + +pub fn reflected(element: *Element, attr_name: lp.String, read_only: bool, frame: *Frame) !*Number { + return frame._factory.create(Number{ + ._element = element, + ._attr_name = attr_name, + ._read_only = read_only, + }); +} + +pub fn getValue(self: *Number) f32 { + self.syncFromAttribute(); return self._value; } -pub fn setValue(self: *Number, value: f32) void { +pub fn setValue(self: *Number, value: f32, frame: *Frame) !void { + if (self._read_only) return error.NoModificationAllowed; + if (!std.math.isFinite(value)) return error.TypeError; self._value = value; + const element = self._element orelse return; + const serialized = try std.fmt.allocPrint(frame.call_arena, "{d}", .{value}); + try element.setAttributeSafe(self._attr_name, lp.String.wrap(serialized), frame); +} + +fn syncFromAttribute(self: *Number) void { + const element = self._element orelse return; + const raw = element.getAttributeSafe(self._attr_name) orelse { + self._value = 0; + return; + }; + const trimmed = std.mem.trim(u8, raw, " \t\r\n\x0c"); + self._value = std.fmt.parseFloat(f32, trimmed) catch 0; + if (!std.math.isFinite(self._value)) self._value = 0; } pub const JsApi = struct { diff --git a/src/browser/webapi/svg/PathData.zig b/src/browser/webapi/svg/PathData.zig new file mode 100644 index 000000000..69d2b0f88 --- /dev/null +++ b/src/browser/webapi/svg/PathData.zig @@ -0,0 +1,733 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +const std = @import("std"); + +const Allocator = std.mem.Allocator; +const tau = 2.0 * std.math.pi; + +pub const Point = struct { + x: f64, + y: f64, + + fn add(a: Point, b: Point) Point { + return .{ .x = a.x + b.x, .y = a.y + b.y }; + } + + fn midpoint(a: Point, b: Point) Point { + return .{ .x = (a.x + b.x) / 2.0, .y = (a.y + b.y) / 2.0 }; + } +}; + +pub const Matrix = struct { + a: f64 = 1, + b: f64 = 0, + c: f64 = 0, + d: f64 = 1, + e: f64 = 0, + f: f64 = 0, + + pub fn apply(self: Matrix, point: Point) Point { + return .{ + .x = self.a * point.x + self.c * point.y + self.e, + .y = self.b * point.x + self.d * point.y + self.f, + }; + } + + pub fn multiply(self: Matrix, child: Matrix) Matrix { + return .{ + .a = self.a * child.a + self.c * child.b, + .b = self.b * child.a + self.d * child.b, + .c = self.a * child.c + self.c * child.d, + .d = self.b * child.c + self.d * child.d, + .e = self.a * child.e + self.c * child.f + self.e, + .f = self.b * child.e + self.d * child.f + self.f, + }; + } +}; + +pub const Bounds = struct { + min_x: f64 = std.math.inf(f64), + min_y: f64 = std.math.inf(f64), + max_x: f64 = -std.math.inf(f64), + max_y: f64 = -std.math.inf(f64), + + pub fn include(self: *Bounds, point: Point) void { + self.min_x = @min(self.min_x, point.x); + self.min_y = @min(self.min_y, point.y); + self.max_x = @max(self.max_x, point.x); + self.max_y = @max(self.max_y, point.y); + } + + pub fn merge(self: *Bounds, other: Bounds) void { + if (other.isEmpty()) return; + self.include(.{ .x = other.min_x, .y = other.min_y }); + self.include(.{ .x = other.max_x, .y = other.max_y }); + } + + pub fn isEmpty(self: Bounds) bool { + return self.min_x > self.max_x or self.min_y > self.max_y; + } + + pub fn width(self: Bounds) f64 { + return if (self.isEmpty()) 0 else self.max_x - self.min_x; + } + + pub fn height(self: Bounds) f64 { + return if (self.isEmpty()) 0 else self.max_y - self.min_y; + } +}; + +pub const Line = struct { start: Point, end: Point }; +pub const Quadratic = struct { start: Point, control: Point, end: Point }; +pub const Cubic = struct { start: Point, control1: Point, control2: Point, end: Point }; +pub const Arc = struct { + start: Point, + end: Point, + center: Point, + rx: f64, + ry: f64, + rotation: f64, + theta: f64, + delta: f64, +}; + +pub const Segment = union(enum) { + line: Line, + quadratic: Quadratic, + cubic: Cubic, + arc: Arc, + + fn start(self: Segment) Point { + return switch (self) { + inline else => |segment| segment.start, + }; + } + + fn end(self: Segment) Point { + return switch (self) { + inline else => |segment| segment.end, + }; + } +}; + +pub const Path = struct { + segments: std.ArrayList(Segment) = .empty, + first_point: ?Point = null, + + pub fn deinit(self: *Path, allocator: Allocator) void { + self.segments.deinit(allocator); + } + + pub fn appendLine(self: *Path, start: Point, end: Point, allocator: Allocator) !void { + if (self.first_point == null) self.first_point = start; + try self.segments.append(allocator, .{ .line = .{ .start = start, .end = end } }); + } + + pub fn appendQuadratic(self: *Path, segment: Quadratic, allocator: Allocator) !void { + if (self.first_point == null) self.first_point = segment.start; + try self.segments.append(allocator, .{ .quadratic = segment }); + } + + pub fn appendCubic(self: *Path, segment: Cubic, allocator: Allocator) !void { + if (self.first_point == null) self.first_point = segment.start; + try self.segments.append(allocator, .{ .cubic = segment }); + } + + pub fn appendArc(self: *Path, start: Point, rx: f64, ry: f64, rotation: f64, large: bool, sweep: bool, end: Point, allocator: Allocator) !void { + if (self.first_point == null) self.first_point = start; + if (pointsEqual(start, end)) return; + if (rx == 0 or ry == 0) return self.appendLine(start, end, allocator); + const arc = endpointArc(start, rx, ry, rotation, large, sweep, end) orelse + return self.appendLine(start, end, allocator); + try self.segments.append(allocator, .{ .arc = arc }); + } + + pub fn bounds(self: *const Path, matrix: Matrix) Bounds { + var result: Bounds = .{}; + for (self.segments.items) |segment| includeSegmentBounds(&result, segment, matrix); + return result; + } + + pub fn totalLength(self: *const Path, allocator: Allocator) !f64 { + var total: f64 = 0; + for (self.segments.items) |segment| total += try segmentLength(segment, allocator); + return total; + } + + pub fn pointAtLength(self: *const Path, requested: f64, allocator: Allocator) !Point { + if (self.segments.items.len == 0) return self.first_point orelse .{ .x = 0, .y = 0 }; + + var remaining = @max(requested, 0); + for (self.segments.items, 0..) |segment, segment_index| { + var points: std.ArrayList(Point) = .empty; + defer points.deinit(allocator); + try flatten(segment, &points, allocator); + + for (points.items[0 .. points.items.len - 1], points.items[1..]) |a, b| { + const length = distance(a, b); + if (remaining <= length) { + if (length == 0) return b; + const ratio = remaining / length; + return .{ + .x = a.x + (b.x - a.x) * ratio, + .y = a.y + (b.y - a.y) * ratio, + }; + } + remaining -= length; + } + + if (segment_index + 1 == self.segments.items.len) return segment.end(); + } + unreachable; + } +}; + +const Previous = enum { other, cubic, quadratic }; + +pub fn parse(input: []const u8, allocator: Allocator) !Path { + var path: Path = .{}; + errdefer path.deinit(allocator); + + var parser = Parser{ .input = input }; + var command: ?u8 = null; + var command_fresh = false; + var current = Point{ .x = 0, .y = 0 }; + var subpath_start = current; + var last_control = current; + var previous: Previous = .other; + var saw_moveto = false; + + while (true) { + parser.skipWhitespace(); + if (parser.atEnd()) break; + + const byte = parser.peek(); + if (isCommand(byte)) { + parser.index += 1; + command = byte; + command_fresh = true; + + if (upper(byte) == 'Z') { + if (!saw_moveto) break; + try path.appendLine(current, subpath_start, allocator); + current = subpath_start; + previous = .other; + command = null; + continue; + } + } else if (std.ascii.isAlphabetic(byte) or command == null) { + break; + } + + const cmd = command.?; + const kind = upper(cmd); + if (!saw_moveto and kind != 'M') break; + const relative = std.ascii.isLower(cmd); + const allow_first_comma = !command_fresh; + + switch (kind) { + 'M', 'L' => { + const x = parser.number(allow_first_comma) orelse break; + const y = parser.number(true) orelse break; + const end = absolutePoint(current, x, y, relative); + if (kind == 'M') { + current = end; + subpath_start = end; + if (path.first_point == null) path.first_point = end; + saw_moveto = true; + command = if (relative) 'l' else 'L'; + } else { + try path.appendLine(current, end, allocator); + current = end; + } + previous = .other; + }, + 'H' => { + const x = parser.number(allow_first_comma) orelse break; + const end = Point{ .x = if (relative) current.x + x else x, .y = current.y }; + try path.appendLine(current, end, allocator); + current = end; + previous = .other; + }, + 'V' => { + const y = parser.number(allow_first_comma) orelse break; + const end = Point{ .x = current.x, .y = if (relative) current.y + y else y }; + try path.appendLine(current, end, allocator); + current = end; + previous = .other; + }, + 'C' => { + const x1 = parser.number(allow_first_comma) orelse break; + const y1 = parser.number(true) orelse break; + const x2 = parser.number(true) orelse break; + const y2 = parser.number(true) orelse break; + const x = parser.number(true) orelse break; + const y = parser.number(true) orelse break; + const control1 = absolutePoint(current, x1, y1, relative); + const control2 = absolutePoint(current, x2, y2, relative); + const end = absolutePoint(current, x, y, relative); + try path.appendCubic(.{ .start = current, .control1 = control1, .control2 = control2, .end = end }, allocator); + current = end; + last_control = control2; + previous = .cubic; + }, + 'S' => { + const x2 = parser.number(allow_first_comma) orelse break; + const y2 = parser.number(true) orelse break; + const x = parser.number(true) orelse break; + const y = parser.number(true) orelse break; + const control1 = if (previous == .cubic) reflect(last_control, current) else current; + const control2 = absolutePoint(current, x2, y2, relative); + const end = absolutePoint(current, x, y, relative); + try path.appendCubic(.{ .start = current, .control1 = control1, .control2 = control2, .end = end }, allocator); + current = end; + last_control = control2; + previous = .cubic; + }, + 'Q' => { + const x1 = parser.number(allow_first_comma) orelse break; + const y1 = parser.number(true) orelse break; + const x = parser.number(true) orelse break; + const y = parser.number(true) orelse break; + const control = absolutePoint(current, x1, y1, relative); + const end = absolutePoint(current, x, y, relative); + try path.appendQuadratic(.{ .start = current, .control = control, .end = end }, allocator); + current = end; + last_control = control; + previous = .quadratic; + }, + 'T' => { + const x = parser.number(allow_first_comma) orelse break; + const y = parser.number(true) orelse break; + const control = if (previous == .quadratic) reflect(last_control, current) else current; + const end = absolutePoint(current, x, y, relative); + try path.appendQuadratic(.{ .start = current, .control = control, .end = end }, allocator); + current = end; + last_control = control; + previous = .quadratic; + }, + 'A' => { + const rx = parser.number(allow_first_comma) orelse break; + const ry = parser.number(true) orelse break; + const rotation = parser.number(true) orelse break; + const large = parser.flag(true) orelse break; + const sweep = parser.flag(true) orelse break; + const x = parser.number(true) orelse break; + const y = parser.number(true) orelse break; + const end = absolutePoint(current, x, y, relative); + try path.appendArc(current, rx, ry, rotation, large, sweep, end, allocator); + current = end; + previous = .other; + }, + else => break, + } + command_fresh = false; + } + + return path; +} + +const Parser = struct { + input: []const u8, + index: usize = 0, + + fn atEnd(self: Parser) bool { + return self.index >= self.input.len; + } + + fn peek(self: Parser) u8 { + return self.input[self.index]; + } + + fn skipWhitespace(self: *Parser) void { + while (!self.atEnd() and isWhitespace(self.peek())) self.index += 1; + } + + fn separator(self: *Parser, allow_comma: bool) bool { + self.skipWhitespace(); + if (!self.atEnd() and self.peek() == ',') { + if (!allow_comma) return false; + self.index += 1; + self.skipWhitespace(); + if (self.atEnd() or self.peek() == ',') return false; + } + return true; + } + + fn number(self: *Parser, allow_comma: bool) ?f64 { + if (!self.separator(allow_comma)) return null; + const start = self.index; + if (self.atEnd()) return null; + + if (self.peek() == '+' or self.peek() == '-') self.index += 1; + var digits: usize = 0; + while (!self.atEnd() and std.ascii.isDigit(self.peek())) : (self.index += 1) digits += 1; + if (!self.atEnd() and self.peek() == '.') { + self.index += 1; + while (!self.atEnd() and std.ascii.isDigit(self.peek())) : (self.index += 1) digits += 1; + } + if (digits == 0) { + self.index = start; + return null; + } + + if (!self.atEnd() and (self.peek() == 'e' or self.peek() == 'E')) { + self.index += 1; + if (!self.atEnd() and (self.peek() == '+' or self.peek() == '-')) self.index += 1; + const exponent_start = self.index; + while (!self.atEnd() and std.ascii.isDigit(self.peek())) self.index += 1; + if (self.index == exponent_start) { + self.index = start; + return null; + } + } + + const value = std.fmt.parseFloat(f64, self.input[start..self.index]) catch { + self.index = start; + return null; + }; + if (!std.math.isFinite(value)) { + self.index = start; + return null; + } + return value; + } + + fn flag(self: *Parser, allow_comma: bool) ?bool { + if (!self.separator(allow_comma) or self.atEnd()) return null; + return switch (self.peek()) { + '0' => blk: { + self.index += 1; + break :blk false; + }, + '1' => blk: { + self.index += 1; + break :blk true; + }, + else => null, + }; + } +}; + +fn isWhitespace(byte: u8) bool { + return byte == ' ' or byte == '\t' or byte == '\r' or byte == '\n' or byte == '\x0c'; +} + +fn isCommand(byte: u8) bool { + return switch (upper(byte)) { + 'M', 'Z', 'L', 'H', 'V', 'C', 'S', 'Q', 'T', 'A' => true, + else => false, + }; +} + +fn upper(byte: u8) u8 { + return if (byte >= 'a' and byte <= 'z') byte - ('a' - 'A') else byte; +} + +fn absolutePoint(origin: Point, x: f64, y: f64, relative: bool) Point { + return if (relative) Point.add(origin, .{ .x = x, .y = y }) else .{ .x = x, .y = y }; +} + +fn reflect(control: Point, around: Point) Point { + return .{ .x = 2.0 * around.x - control.x, .y = 2.0 * around.y - control.y }; +} + +fn pointsEqual(a: Point, b: Point) bool { + return a.x == b.x and a.y == b.y; +} + +fn distance(a: Point, b: Point) f64 { + return std.math.hypot(b.x - a.x, b.y - a.y); +} + +fn endpointArc(start: Point, rx_input: f64, ry_input: f64, rotation_degrees: f64, large: bool, sweep: bool, end: Point) ?Arc { + var rx = @abs(rx_input); + var ry = @abs(ry_input); + if (rx == 0 or ry == 0 or pointsEqual(start, end)) return null; + + const rotation = @mod(rotation_degrees, 360.0) * std.math.pi / 180.0; + const cosine = @cos(rotation); + const sine = @sin(rotation); + const dx = (start.x - end.x) / 2.0; + const dy = (start.y - end.y) / 2.0; + const x_prime = cosine * dx + sine * dy; + const y_prime = -sine * dx + cosine * dy; + + const radii_scale = x_prime * x_prime / (rx * rx) + y_prime * y_prime / (ry * ry); + if (radii_scale > 1) { + const scale = @sqrt(radii_scale); + rx *= scale; + ry *= scale; + } + + const rx2 = rx * rx; + const ry2 = ry * ry; + const numerator = @max(0, rx2 * ry2 - rx2 * y_prime * y_prime - ry2 * x_prime * x_prime); + const denominator = rx2 * y_prime * y_prime + ry2 * x_prime * x_prime; + if (denominator == 0) return null; + var coefficient = @sqrt(numerator / denominator); + if (large == sweep) coefficient = -coefficient; + + const center_prime = Point{ + .x = coefficient * rx * y_prime / ry, + .y = -coefficient * ry * x_prime / rx, + }; + const center = Point{ + .x = cosine * center_prime.x - sine * center_prime.y + (start.x + end.x) / 2.0, + .y = sine * center_prime.x + cosine * center_prime.y + (start.y + end.y) / 2.0, + }; + + const first = Point{ + .x = (x_prime - center_prime.x) / rx, + .y = (y_prime - center_prime.y) / ry, + }; + const second = Point{ + .x = (-x_prime - center_prime.x) / rx, + .y = (-y_prime - center_prime.y) / ry, + }; + const theta = std.math.atan2(first.y, first.x); + var delta = std.math.atan2(first.x * second.y - first.y * second.x, first.x * second.x + first.y * second.y); + if (sweep and delta < 0) delta += tau; + if (!sweep and delta > 0) delta -= tau; + + return .{ + .start = start, + .end = end, + .center = center, + .rx = rx, + .ry = ry, + .rotation = rotation, + .theta = theta, + .delta = delta, + }; +} + +fn includeSegmentBounds(bounds: *Bounds, segment: Segment, matrix: Matrix) void { + bounds.include(matrix.apply(segment.start())); + bounds.include(matrix.apply(segment.end())); + + switch (segment) { + .line => {}, + .quadratic => |quadratic| { + includeQuadraticExtremum(bounds, quadratic.start.x, quadratic.control.x, quadratic.end.x, quadratic, matrix); + includeQuadraticExtremum(bounds, quadratic.start.y, quadratic.control.y, quadratic.end.y, quadratic, matrix); + includeTransformedQuadraticExtrema(bounds, quadratic, matrix); + }, + .cubic => |cubic| includeTransformedCubicExtrema(bounds, cubic, matrix), + .arc => |arc| includeArcExtrema(bounds, arc, matrix), + } +} + +// The direct-axis roots help the identity case and are harmless duplicates for +// transformed paths. The transformed roots below are authoritative. +fn includeQuadraticExtremum(bounds: *Bounds, p0: f64, p1: f64, p2: f64, quadratic: Quadratic, matrix: Matrix) void { + const denominator = p0 - 2.0 * p1 + p2; + if (@abs(denominator) < 1e-14) return; + const t = (p0 - p1) / denominator; + if (t > 0 and t < 1) bounds.include(matrix.apply(evalQuadratic(quadratic, t))); +} + +fn includeTransformedQuadraticExtrema(bounds: *Bounds, quadratic: Quadratic, matrix: Matrix) void { + const p0 = matrix.apply(quadratic.start); + const p1 = matrix.apply(quadratic.control); + const p2 = matrix.apply(quadratic.end); + for ([_][3]f64{ .{ p0.x, p1.x, p2.x }, .{ p0.y, p1.y, p2.y } }) |values| { + const denominator = values[0] - 2.0 * values[1] + values[2]; + if (@abs(denominator) < 1e-14) continue; + const t = (values[0] - values[1]) / denominator; + if (t > 0 and t < 1) bounds.include(matrix.apply(evalQuadratic(quadratic, t))); + } +} + +fn includeTransformedCubicExtrema(bounds: *Bounds, cubic: Cubic, matrix: Matrix) void { + const p0 = matrix.apply(cubic.start); + const p1 = matrix.apply(cubic.control1); + const p2 = matrix.apply(cubic.control2); + const p3 = matrix.apply(cubic.end); + for ([_][4]f64{ .{ p0.x, p1.x, p2.x, p3.x }, .{ p0.y, p1.y, p2.y, p3.y } }) |values| { + const a = -values[0] + 3.0 * values[1] - 3.0 * values[2] + values[3]; + const b = 2.0 * (values[0] - 2.0 * values[1] + values[2]); + const c = values[1] - values[0]; + for (quadraticRoots(a, b, c)) |root| { + const t = root orelse continue; + if (t > 0 and t < 1) bounds.include(matrix.apply(evalCubic(cubic, t))); + } + } +} + +fn includeArcExtrema(bounds: *Bounds, arc: Arc, matrix: Matrix) void { + const cosine = @cos(arc.rotation); + const sine = @sin(arc.rotation); + + const x_cos = arc.rx * cosine; + const x_sin = -arc.ry * sine; + const y_cos = arc.rx * sine; + const y_sin = arc.ry * cosine; + + const transformed = [_][2]f64{ + .{ matrix.a * x_cos + matrix.c * y_cos, matrix.a * x_sin + matrix.c * y_sin }, + .{ matrix.b * x_cos + matrix.d * y_cos, matrix.b * x_sin + matrix.d * y_sin }, + }; + for (transformed) |coefficients| { + const candidate = std.math.atan2(coefficients[1], coefficients[0]); + for ([_]f64{ candidate, candidate + std.math.pi }) |angle| { + if (angleInSweep(angle, arc.theta, arc.delta)) bounds.include(matrix.apply(evalArc(arc, angle))); + } + } +} + +fn quadraticRoots(a: f64, b: f64, c: f64) [2]?f64 { + if (@abs(a) < 1e-14) { + if (@abs(b) < 1e-14) return .{ null, null }; + return .{ -c / b, null }; + } + const discriminant = b * b - 4.0 * a * c; + if (discriminant < 0) return .{ null, null }; + const root = @sqrt(discriminant); + return .{ (-b + root) / (2.0 * a), (-b - root) / (2.0 * a) }; +} + +fn angleInSweep(angle: f64, start: f64, delta: f64) bool { + if (delta >= 0) return positiveAngle(angle - start) <= delta + 1e-12; + return positiveAngle(start - angle) <= -delta + 1e-12; +} + +fn positiveAngle(angle: f64) f64 { + var result = @mod(angle, tau); + if (result < 0) result += tau; + return result; +} + +fn evalQuadratic(segment: Quadratic, t: f64) Point { + const inverse = 1.0 - t; + return .{ + .x = inverse * inverse * segment.start.x + 2.0 * inverse * t * segment.control.x + t * t * segment.end.x, + .y = inverse * inverse * segment.start.y + 2.0 * inverse * t * segment.control.y + t * t * segment.end.y, + }; +} + +fn evalCubic(segment: Cubic, t: f64) Point { + const inverse = 1.0 - t; + return .{ + .x = inverse * inverse * inverse * segment.start.x + 3.0 * inverse * inverse * t * segment.control1.x + 3.0 * inverse * t * t * segment.control2.x + t * t * t * segment.end.x, + .y = inverse * inverse * inverse * segment.start.y + 3.0 * inverse * inverse * t * segment.control1.y + 3.0 * inverse * t * t * segment.control2.y + t * t * t * segment.end.y, + }; +} + +fn evalArc(arc: Arc, angle: f64) Point { + const cosine = @cos(arc.rotation); + const sine = @sin(arc.rotation); + const x = arc.rx * @cos(angle); + const y = arc.ry * @sin(angle); + return .{ + .x = arc.center.x + cosine * x - sine * y, + .y = arc.center.y + sine * x + cosine * y, + }; +} + +fn segmentLength(segment: Segment, allocator: Allocator) !f64 { + var points: std.ArrayList(Point) = .empty; + defer points.deinit(allocator); + try flatten(segment, &points, allocator); + + var total: f64 = 0; + for (points.items[0 .. points.items.len - 1], points.items[1..]) |a, b| total += distance(a, b); + return total; +} + +const flatten_tolerance = 0.001; +const flatten_depth = 18; + +fn flatten(segment: Segment, points: *std.ArrayList(Point), allocator: Allocator) !void { + try points.append(allocator, segment.start()); + switch (segment) { + .line => |line| try points.append(allocator, line.end), + .quadratic => |quadratic| try flattenQuadratic(quadratic, points, allocator, 0), + .cubic => |cubic| try flattenCubic(cubic, points, allocator, 0), + .arc => |arc| try flattenArc(arc, arc.theta, arc.theta + arc.delta, arc.start, arc.end, points, allocator, 0), + } +} + +fn flattenQuadratic(segment: Quadratic, points: *std.ArrayList(Point), allocator: Allocator, depth: usize) !void { + if (depth >= flatten_depth or pointLineDistance(segment.control, segment.start, segment.end) <= flatten_tolerance) { + return points.append(allocator, segment.end); + } + const a = Point.midpoint(segment.start, segment.control); + const b = Point.midpoint(segment.control, segment.end); + const middle = Point.midpoint(a, b); + try flattenQuadratic(.{ .start = segment.start, .control = a, .end = middle }, points, allocator, depth + 1); + try flattenQuadratic(.{ .start = middle, .control = b, .end = segment.end }, points, allocator, depth + 1); +} + +fn flattenCubic(segment: Cubic, points: *std.ArrayList(Point), allocator: Allocator, depth: usize) !void { + const flatness = @max( + pointLineDistance(segment.control1, segment.start, segment.end), + pointLineDistance(segment.control2, segment.start, segment.end), + ); + if (depth >= flatten_depth or flatness <= flatten_tolerance) return points.append(allocator, segment.end); + + const a = Point.midpoint(segment.start, segment.control1); + const b = Point.midpoint(segment.control1, segment.control2); + const c = Point.midpoint(segment.control2, segment.end); + const d = Point.midpoint(a, b); + const e = Point.midpoint(b, c); + const middle = Point.midpoint(d, e); + try flattenCubic(.{ .start = segment.start, .control1 = a, .control2 = d, .end = middle }, points, allocator, depth + 1); + try flattenCubic(.{ .start = middle, .control1 = e, .control2 = c, .end = segment.end }, points, allocator, depth + 1); +} + +fn flattenArc(arc: Arc, start_angle: f64, end_angle: f64, start: Point, end: Point, points: *std.ArrayList(Point), allocator: Allocator, depth: usize) !void { + const middle_angle = (start_angle + end_angle) / 2.0; + const middle = evalArc(arc, middle_angle); + if (depth >= flatten_depth or pointLineDistance(middle, start, end) <= flatten_tolerance) { + return points.append(allocator, end); + } + try flattenArc(arc, start_angle, middle_angle, start, middle, points, allocator, depth + 1); + try flattenArc(arc, middle_angle, end_angle, middle, end, points, allocator, depth + 1); +} + +fn pointLineDistance(point: Point, start: Point, end: Point) f64 { + const dx = end.x - start.x; + const dy = end.y - start.y; + const denominator = std.math.hypot(dx, dy); + if (denominator == 0) return distance(point, start); + return @abs(dy * point.x - dx * point.y + end.x * start.y - end.y * start.x) / denominator; +} + +test "path parser preserves complete packs before an error" { + var path = try parse("M10 10 L20 20 30", std.testing.allocator); + defer path.deinit(std.testing.allocator); + try std.testing.expectEqual(@as(usize, 1), path.segments.items.len); + const bounds = path.bounds(.{}); + try std.testing.expectEqual(@as(f64, 10), bounds.min_x); + try std.testing.expectEqual(@as(f64, 20), bounds.max_x); +} + +test "moveto coordinate pairs become lines and malformed exponents stop" { + var path = try parse("M0 0 10 10 20 1e L99 99", std.testing.allocator); + defer path.deinit(std.testing.allocator); + try std.testing.expectEqual(@as(usize, 1), path.segments.items.len); + try std.testing.expectEqual(Point{ .x = 10, .y = 10 }, path.segments.items[0].end()); +} + +test "rotated arc bounds use ellipse derivative extrema" { + var path = try parse("M0 0 A80 20 45 1 1 100 100", std.testing.allocator); + defer path.deinit(std.testing.allocator); + const bounds = path.bounds(.{}); + try std.testing.expect(bounds.min_x < 0); + try std.testing.expect(bounds.max_y >= 100); +} + +test "length and point lookup share adaptive geometry" { + var path = try parse("M0 0 C0 100 100 100 100 0", std.testing.allocator); + defer path.deinit(std.testing.allocator); + const length = try path.totalLength(std.testing.allocator); + const middle = try path.pointAtLength(length / 2.0, std.testing.allocator); + try std.testing.expectApproxEqAbs(@as(f64, 50), middle.x, 0.01); + try std.testing.expectApproxEqAbs(@as(f64, 75), middle.y, 0.01); +}