diff --git a/mathics/builtin/drawing/graphics3d.py b/mathics/builtin/drawing/graphics3d.py index efbf2c4cbf..a9df10705f 100644 --- a/mathics/builtin/drawing/graphics3d.py +++ b/mathics/builtin/drawing/graphics3d.py @@ -4,34 +4,36 @@ Three-Dimensional Graphics """ +from mathics.version import __version__ # noqa used in loading to check consistency. +import html +import json import numbers -from mathics.version import __version__ # noqa used in loading to check consistency. + from mathics.core.expression import ( Expression, from_python, system_symbols_dict, SymbolList, ) +from mathics.core.formatter import lookup_method + from mathics.builtin.base import BoxConstructError, Builtin, InstanceableBuiltin from mathics.builtin.graphics import ( Graphics, GraphicsBox, PolygonBox, - create_pens, _Color, LineBox, PointBox, Style, RGBColor, get_class, - asy_number, CoordinatesError, _GraphicsElements, ) -import json -import html +from mathics.formatter.asy_fns import asy_create_pens, asy_number def coords3D(value): @@ -443,7 +445,11 @@ def boxes_to_tex(self, leaves=None, **options): elements._apply_boxscaling(boxscale) - asy = elements.to_asy() + format_fn = lookup_method(elements, "asy") + if format_fn is not None: + asy = format_fn(elements) + else: + asy = elements.to_asy() xmin, xmax, ymin, ymax, zmin, zmax, boxscale = calc_dimensions() @@ -466,11 +472,11 @@ def boxes_to_tex(self, leaves=None, **options): for i, line in enumerate(boundbox_lines): if i in axes_indices: - pen = create_pens( + pen = asy_create_pens( edge_color=RGBColor(components=(0, 0, 0, 1)), stroke_width=1.5 ) else: - pen = create_pens( + pen = asy_create_pens( edge_color=RGBColor(components=(0.4, 0.4, 0.4, 1)), stroke_width=1 ) @@ -483,7 +489,7 @@ def boxes_to_tex(self, leaves=None, **options): # Draw axes ticks ticklength = 0.05 * max([xmax - xmin, ymax - ymin, zmax - zmin]) - pen = create_pens( + pen = asy_create_pens( edge_color=RGBColor(components=(0, 0, 0, 1)), stroke_width=1.2 ) for xi in axes_indices: @@ -778,9 +784,6 @@ def __init__(self, content, evaluation, neg_y=False): def extent(self, completely_visible_only=False): return total_extent_3d([element.extent() for element in self.elements]) - def to_asy(self): - return "\n".join([element.to_asy() for element in self.elements]) - def _apply_boxscaling(self, boxscale): for element in self.elements: element._apply_boxscaling(boxscale) @@ -821,22 +824,6 @@ def to_json(self): ) return data - def to_asy(self): - face_color = self.face_color - - # Tempoary bug fix: default Point color should be black not white - if list(face_color.to_rgba()[:3]) == [1, 1, 1]: - face_color = RGBColor(components=(0, 0, 0, face_color.to_rgba()[3])) - - pen = create_pens(face_color=face_color, is_face_element=False) - - return "".join( - "path3 g={0}--cycle;dot(g, {1});".format( - "--".join("(%.5g,%.5g,%.5g)" % coords.pos()[0] for coords in line), pen - ) - for line in self.lines - ) - def extent(self): result = [] for line in self.lines: @@ -871,18 +858,6 @@ def to_json(self): ) return data - def to_asy(self): - # l = self.style.get_line_width(face_element=False) - pen = create_pens(edge_color=self.edge_color, stroke_width=1) - - return "".join( - "draw({0}, {1});".format( - "--".join("({0},{1},{2})".format(*coords.pos()[0]) for coords in line), - pen, - ) - for line in self.lines - ) - def extent(self): result = [] for line in self.lines: @@ -930,29 +905,6 @@ def to_json(self): ) return data - def to_asy(self): - l = self.style.get_line_width(face_element=True) - if self.vertex_colors is None: - face_color = self.face_color - else: - face_color = None - pen = create_pens( - edge_color=self.edge_color, - face_color=face_color, - stroke_width=l, - is_face_element=True, - ) - - asy = "" - for line in self.lines: - asy += ( - "path3 g=" - + "--".join(["(%.5g,%.5g,%.5g)" % coords.pos()[0] for coords in line]) - + "--cycle;" - ) - asy += "draw(surface(g), %s);" % (pen) - return asy - def extent(self): result = [] for line in self.lines: @@ -1146,21 +1098,6 @@ def init(self, graphics, style, item): self.points = [Coords3D(graphics, pos=point) for point in points] self.radius = item.leaves[1].to_python() - def to_asy(self): - # l = self.style.get_line_width(face_element=True) - - if self.face_color is None: - face_color = (1, 1, 1) - else: - face_color = self.face_color.to_js() - - return "".join( - "draw(surface(sphere({0}, {1})), rgb({2},{3},{4}));".format( - tuple(coord.pos()[0]), self.radius, *face_color[:3] - ) - for coord in self.points - ) - def to_json(self): face_color = self.face_color if face_color is not None: diff --git a/mathics/builtin/graphics.py b/mathics/builtin/graphics.py index cef531b68f..d1911f70c4 100644 --- a/mathics/builtin/graphics.py +++ b/mathics/builtin/graphics.py @@ -8,7 +8,6 @@ from math import floor, ceil, log10, sin, cos, pi, sqrt, atan2, degrees, radians, exp import base64 -from itertools import chain from mathics.version import __version__ # noqa used in loading to check consistency. from mathics.builtin.base import ( @@ -32,10 +31,12 @@ system_symbols_dict, from_python, ) -from mathics.core.formatter import lookup_method from mathics.builtin.drawing.colors import convert as convert_color +from mathics.core.formatter import lookup_method from mathics.core.numbers import machine_epsilon +from mathics.formatter.asy_fns import asy_bezier, asy_color, asy_number + GRAPHICS_OPTIONS = { "AspectRatio": "Automatic", @@ -50,7 +51,6 @@ "$OptionSyntax": "Ignore", } - class CoordinatesError(BoxConstructError): pass @@ -144,10 +144,6 @@ def create_css( return "; ".join(css) -def asy_number(value): - return "%.5g" % value - - def _to_float(x): x = x.round_to_float() if x is None: @@ -155,29 +151,6 @@ def _to_float(x): return x -def create_pens( - edge_color=None, face_color=None, stroke_width=None, is_face_element=False -): - result = [] - if face_color is not None: - brush, opacity = face_color.to_asy() - if opacity != 1: - brush += "+opacity(%s)" % asy_number(opacity) - result.append(brush) - elif is_face_element: - result.append("nullpen") - if edge_color is not None: - pen, opacity = edge_color.to_asy() - if opacity != 1: - pen += "+opacity(%s)" % asy_number(opacity) - if stroke_width is not None: - pen += "+linewidth(%s)" % asy_number(stroke_width) - result.append(pen) - elif is_face_element: - result.append("nullpen") - return ", ".join(result) - - def _data_and_options(leaves, defined_options): data = [] options = defined_options.copy() @@ -321,55 +294,15 @@ def _extract_graphics(graphics, format, evaluation): # generate code for svg or asy. - if format == "asy": - code = "\n".join(element.to_asy() for element in elements.elements) - elif format == "svg": - format_fn = lookup_method(elements, "svg") - if format_fn is not None: - code = format_fn(elements) - else: - code = elements.to_svg() + if format in ("asy", "svg"): + format_fn = lookup_method(elements, format) + code = format_fn(elements) else: raise NotImplementedError return xmin, xmax, ymin, ymax, ox, oy, ex, ey, code -class _ASYTransform: - _template = """ - add(%s * (new picture() { - picture saved = currentpicture; - picture transformed = new picture; - currentpicture = transformed; - %s - currentpicture = saved; - return transformed; - })()); - """ - - def __init__(self): - self.transforms = [] - - def matrix(self, a, b, c, d, e, f): - # a c e - # b d f - # 0 0 1 - # see http://asymptote.sourceforge.net/doc/Transforms.html#Transforms - self.transforms.append("(%f, %f, %f, %f, %f, %f)" % (e, f, a, c, b, d)) - - def translate(self, x, y): - self.transforms.append("shift(%f, %f)" % (x, y)) - - def scale(self, x, y): - self.transforms.append("scale(%f, %f)" % (x, y)) - - def rotate(self, x): - self.transforms.append("rotate(%f)" % x) - - def apply(self, asy): - return self._template % (" * ".join(self.transforms), asy) - - class Show(Builtin): """
@@ -596,15 +529,6 @@ def to_css(self): alpha, ) - def to_asy(self): - rgba = self.to_rgba() - alpha = rgba[3] if len(rgba) > 3 else 1.0 - return ( - r"rgb(%s, %s, %s)" - % (asy_number(rgba[0]), asy_number(rgba[1]), asy_number(rgba[2])), - alpha, - ) - def to_js(self): return self.to_rgba() @@ -845,12 +769,18 @@ class ColorDistance(Builtin): options = {"DistanceFunction": "Automatic"} + requires = ("numpy",) + messages = { "invdist": "`1` is not Automatic or a valid distance specification.", "invarg": "`1` and `2` should be two colors or a color and a lists of colors or " + "two lists of colors of the same length.", } + # If numpy is not installed, 100 * c1.to_color_space returns + # a list of 100 x 3 elements, instead of doing elementwise multiplication + requires = ("numpy",) + # the docs say LABColor's colorspace corresponds to the CIE 1976 L^* a^* b^* color space # with {l,a,b}={L^*,a^*,b^*}/100. Corrections factors are put accordingly. @@ -887,13 +817,6 @@ class ColorDistance(Builtin): def apply(self, c1, c2, evaluation, options): "ColorDistance[c1_, c2_, OptionsPattern[ColorDistance]]" - # If numpy is not installed, 100 * c1.to_color_space returns - # a list of 100 x 3 elements, instead of doing elementwise multiplication - try: - import numpy as np - except: - raise RuntimeError("NumPy needs to be installed for ColorDistance") - distance_function = options.get("System`DistanceFunction") compute = None if isinstance(distance_function, String): @@ -1219,24 +1142,6 @@ def extent(self): ) return result - def to_asy(self): - l = self.style.get_line_width(face_element=True) - x1, y1 = self.p1.pos() - x2, y2 = self.p2.pos() - pens = create_pens(self.edge_color, self.face_color, l, is_face_element=True) - x1, x2, y1, y2 = asy_number(x1), asy_number(x2), asy_number(y1), asy_number(y2) - return "filldraw((%s,%s)--(%s,%s)--(%s,%s)--(%s,%s)--cycle, %s);" % ( - x1, - y1, - x2, - y1, - x2, - y2, - x1, - y2, - pens, - ) - class _RoundBox(_GraphicsElement): face_element = None @@ -1270,28 +1175,6 @@ def extent(self): ry += l return [(x - rx, y - ry), (x - rx, y + ry), (x + rx, y - ry), (x + rx, y + ry)] - def to_asy(self): - x, y = self.c.pos() - rx, ry = self.r.pos() - rx -= x - ry -= y - l = self.style.get_line_width(face_element=self.face_element) - pen = create_pens( - edge_color=self.edge_color, - face_color=self.face_color, - stroke_width=l, - is_face_element=self.face_element, - ) - cmd = "filldraw" if self.face_element else "draw" - return "%s(ellipse((%s,%s),%s,%s), %s);" % ( - cmd, - asy_number(x), - asy_number(y), - asy_number(rx), - asy_number(ry), - pen, - ) - class _ArcBox(_RoundBox): def init(self, graphics, style, item): @@ -1342,6 +1225,7 @@ def _arc_params(self): return x, y, abs(rx), abs(ry), sx, sy, ex, ey, large_arc + class DiskBox(_ArcBox): face_element = True @@ -1423,16 +1307,6 @@ def init(self, graphics, style, item=None): else: raise BoxConstructError - def to_asy(self): - pen = create_pens(face_color=self.face_color, is_face_element=False) - - asy = "" - for line in self.lines: - for coords in line: - asy += "dot(%s, %s);" % (coords.pos(), pen) - - return asy - class Line(Builtin): """ @@ -1467,15 +1341,6 @@ def init(self, graphics, style, item=None, lines=None): else: raise BoxConstructError - def to_asy(self): - l = self.style.get_line_width(face_element=False) - pen = create_pens(edge_color=self.edge_color, stroke_width=l) - asy = "" - for line in self.lines: - path = "--".join(["(%.5g,%5g)" % coords.pos() for coords in line]) - asy += "draw(%s, %s);" % (path, pen) - return asy - def _svg_bezier(*segments): # see https://www.w3.org/TR/SVG/paths.html#PathDataCubicBezierCommands @@ -1509,65 +1374,6 @@ def path(max_degree, p): yield s -def _asy_bezier(*segments): - # see http://asymptote.sourceforge.net/doc/Bezier-curves.html#Bezier-curves - - while segments and not segments[0][1]: - segments = segments[1:] - - if not segments: - return - - def cubic(p0, p1, p2, p3): - return "..controls(%.5g,%.5g) and (%.5g,%.5g)..(%.5g,%.5g)" % tuple( - list(chain(p1, p2, p3)) - ) - - def quadratric(qp0, qp1, qp2): - # asymptote only supports cubic beziers, so we convert this quadratic - # bezier to a cubic bezier, see http://fontforge.github.io/bezier.html - - # CP0 = QP0 - # CP3 = QP2 - # CP1 = QP0 + 2 / 3 * (QP1 - QP0) - # CP2 = QP2 + 2 / 3 * (QP1 - QP2) - - qp0x, qp0y = qp0 - qp1x, qp1y = qp1 - qp2x, qp2y = qp2 - - t = 2.0 / 3.0 - cp0 = qp0 - cp1 = (qp0x + t * (qp1x - qp0x), qp0y + t * (qp1y - qp0y)) - cp2 = (qp2x + t * (qp1x - qp2x), qp2y + t * (qp1y - qp2y)) - cp3 = qp2 - - return cubic(cp0, cp1, cp2, cp3) - - def linear(p0, p1): - return "--(%.5g,%.5g)" % p1 - - forms = (linear, quadratric, cubic) - - def path(max_degree, p): - max_degree = min(max_degree, len(forms)) - while p: - n = min(max_degree, len(p) - 1) # 1, 2, or 3 - if n < 1: - break - yield forms[n - 1](*p[: n + 1]) - p = p[n:] - - k, p = segments[0] - yield "(%.5g,%.5g)" % p[0] - - connect = [] - for k, p in segments: - for s in path(k, list(chain(connect, p))): - yield s - connect = p[-1:] - - class BernsteinBasis(Builtin): attributes = ("Listable", "NumericFunction", "Protected") rules = { @@ -1611,18 +1417,6 @@ def init(self, graphics, style, item, options): raise BoxConstructError self.spline_degree = spline_degree.get_int_value() - def to_asy(self): - l = self.style.get_line_width(face_element=False) - pen = create_pens(edge_color=self.edge_color, stroke_width=l) - - asy = "" - for line in self.lines: - for path in _asy_bezier((self.spline_degree, [xy.pos() for xy in line])): - if path[:2] == "..": - path = "(0.,0.)" + path - asy += "draw(%s, %s);" % (path, pen) - return asy - class FilledCurve(Builtin): """ @@ -1688,20 +1482,6 @@ def parse_component(segments): else: raise BoxConstructError - def to_asy(self): - l = self.style.get_line_width(face_element=False) - pen = create_pens(edge_color=self.edge_color, stroke_width=l) - - if not pen: - pen = "currentpen" - - def components(): - for component in self.components: - transformed = [(k, [xy.pos() for xy in p]) for k, p in component] - yield "fill(%s--cycle, %s);" % ("".join(_asy_bezier(*transformed)), pen) - - return "".join(components()) - def extent(self): l = self.style.get_line_width(face_element=False) result = [] @@ -1777,52 +1557,6 @@ def process_option(self, name, value): else: raise BoxConstructError - def to_asy(self): - l = self.style.get_line_width(face_element=True) - if self.vertex_colors is None: - face_color = self.face_color - else: - face_color = None - pens = create_pens( - edge_color=self.edge_color, - face_color=face_color, - stroke_width=l, - is_face_element=True, - ) - asy = "" - if self.vertex_colors is not None: - paths = [] - colors = [] - edges = [] - for index, line in enumerate(self.lines): - paths.append( - "--".join(["(%.5g,%.5g)" % coords.pos() for coords in line]) - + "--cycle" - ) - - # ignore opacity - colors.append( - ",".join([color.to_asy()[0] for color in self.vertex_colors[index]]) - ) - - edges.append( - ",".join(["0"] + ["1"] * (len(self.vertex_colors[index]) - 1)) - ) - - asy += "gouraudshade(%s, new pen[] {%s}, new int[] {%s});" % ( - "^^".join(paths), - ",".join(colors), - ",".join(edges), - ) - if pens and pens != "nullpen": - for line in self.lines: - path = ( - "--".join(["(%.5g,%.5g)" % coords.pos() for coords in line]) - + "--cycle" - ) - asy += "filldraw(%s, %s);" % (path, pens) - return asy - class RegularPolygon(Builtin): """ @@ -2129,7 +1863,7 @@ def draw(points): def make_draw_asy(self, pen): def draw(points): - for path in _asy_bezier((self.spline_degree, points)): + for path in asy_bezier((self.spline_degree, points)): yield "draw(%s, %s);" % (path, pen) return draw @@ -2332,23 +2066,6 @@ def draw(px, py, vx, vy, t1, s): return make - def to_asy(self): - width = self.style.get_line_width(face_element=False) - pen = create_pens(edge_color=self.edge_color, stroke_width=width) - polyline = self.curve.make_draw_asy(pen) - - arrow_pen = create_pens(face_color=self.edge_color, stroke_width=width) - - def polygon(points): - yield "filldraw(" - yield "--".join(["(%.5g,%5g)" % xy for xy in points]) - yield "--cycle, % s);" % arrow_pen - - extent = self.graphics.view_width or 0 - default_arrow = self._default_arrow(polygon) - custom_arrow = self._custom_arrow("asy", _ASYTransform) - return "".join(self._draw(polyline, default_arrow, custom_arrow, extent)) - def extent(self): width = self.style.get_line_width(face_element=False) @@ -2418,20 +2135,6 @@ def extent(self): y = p[1] - h / 2.0 + opos[1] * h / 2.0 return [(x, y), (x + w, y + h)] - def to_asy(self): - x, y = self.pos.pos() - content = self.content.boxes_to_tex(evaluation=self.graphics.evaluation) - pen = create_pens(edge_color=self.color) - asy = 'label("$%s$", (%s,%s), (%s,%s), %s);' % ( - content, - x, - y, - -self.opos[0], - -self.opos[1], - pen, - ) - return asy - def total_extent(extents): xmin = xmax = ymin = ymax = None @@ -2731,9 +2434,6 @@ def extent(self, completely_visible_only=False): ymax *= 2 return xmin, xmax, ymin, ymax - def to_asy(self): - return "\n".join(element.to_asy() for element in self.elements) - def set_size( self, xmin, ymin, extent_width, extent_height, pixel_width, pixel_height ): @@ -2974,13 +2674,13 @@ def boxes_to_tex(self, leaves=None, **options): elements.view_width = w asy_completely_visible = "\n".join( - element.to_asy() + lookup_method(element, "asy")(element) for element in elements.elements if element.is_completely_visible ) asy_regular = "\n".join( - element.to_asy() + lookup_method(element, "asy")(element) for element in elements.elements if not element.is_completely_visible ) @@ -2993,7 +2693,7 @@ def boxes_to_tex(self, leaves=None, **options): ) if self.background_color is not None: - color, opacity = self.background_color.to_asy() + color, opacity = asy_color(self.background_color) asy_background = "filldraw(%s, %s);" % (asy_box, color) else: asy_background = "" diff --git a/mathics/core/definitions.py b/mathics/core/definitions.py index 57d0228be8..75cb5adb58 100644 --- a/mathics/core/definitions.py +++ b/mathics/core/definitions.py @@ -125,7 +125,8 @@ def __init__( self.builtin.update(self.user) self.user = {} self.clear_cache() - import mathics.formatter.svg + import mathics.formatter.svg # noqa + import mathics.formatter.asy # noqa def load_pymathics_module(self, module, remove_on_quit=True): """ diff --git a/mathics/core/formatter.py b/mathics/core/formatter.py index c39b72f69d..b9514b52db 100644 --- a/mathics/core/formatter.py +++ b/mathics/core/formatter.py @@ -5,7 +5,7 @@ format2fn = {} -def lookup_method(self, format: str) -> Callable: +def lookup_method(self, format: str, module_fn_name=None) -> Callable: """ Find a conversion method for `format` in self's class method resolution order. """ @@ -19,7 +19,7 @@ def lookup_method(self, format: str) -> Callable: ) -def add_conversion_fn(cls) -> None: +def add_conversion_fn(cls, module_fn_name=None) -> None: """Add to `format2fn` a mapping from a conversion type and builtin-class to a conversion method. @@ -41,8 +41,13 @@ def add_conversion_fn(cls) -> None: # The last part of the module name is expected to be the conversion routine. conversion_type = module_dict["__name__"].split(".")[-1] - # Derive the conversion function from the passed-in class argument. - module_fn_name = cls.__name__.lower() + # Derive the conversion function from the passed-in class argument, + # unless it is already set. + if module_fn_name is None: + module_fn_name = cls.__name__.lower() + elif hasattr(module_fn_name, "__name__"): + module_fn_name = module_fn_name.__name__ + # Finally register the mapping: (Builtin-class, conversion name) -> conversion_function. format2fn[(conversion_type, cls)] = module_dict[module_fn_name] diff --git a/mathics/formatter/asy.py b/mathics/formatter/asy.py new file mode 100644 index 0000000000..a2cffe1992 --- /dev/null +++ b/mathics/formatter/asy.py @@ -0,0 +1,374 @@ +# -*- coding: utf-8 -*- + +""" +Format a Mathics object as an Aymptote string +""" + +from mathics.builtin.graphics import ( + _Color, + _ArcBox, + ArrowBox, + BezierCurveBox, + FilledCurveBox, + GraphicsBox, + GraphicsElements, + InsetBox, + LineBox, + PointBox, + PolygonBox, + RGBColor, + RectangleBox, + _RoundBox, +) + +from mathics.builtin.drawing.graphics3d import ( + Graphics3DElements, + Line3DBox, + Point3DBox, + Polygon3DBox, + Sphere3DBox, +) + +from mathics.core.formatter import lookup_method, add_conversion_fn +from mathics.formatter.asy_fns import asy_bezier, asy_color as _color, asy_create_pens, asy_number + + +class _ASYTransform: + _template = """ + add(%s * (new picture() { + picture saved = currentpicture; + picture transformed = new picture; + currentpicture = transformed; + %s + currentpicture = saved; + return transformed; + })()); + """ + + def __init__(self): + self.transforms = [] + + def matrix(self, a, b, c, d, e, f): + # a c e + # b d f + # 0 0 1 + # see http://asymptote.sourceforge.net/doc/Transforms.html#Transforms + self.transforms.append("(%f, %f, %f, %f, %f, %f)" % (e, f, a, c, b, d)) + + def translate(self, x, y): + self.transforms.append("shift(%f, %f)" % (x, y)) + + def scale(self, x, y): + self.transforms.append("scale(%f, %f)" % (x, y)) + + def rotate(self, x): + self.transforms.append("rotate(%f)" % x) + + def apply(self, asy): + return self._template % (" * ".join(self.transforms), asy) + + +def arrow_box(self) -> str: + width = self.style.get_line_width(face_element=False) + pen = asy_create_pens(edge_color=self.edge_color, stroke_width=width) + polyline = self.curve.make_draw_asy(pen) + + arrow_pen = asy_create_pens(face_color=self.edge_color, stroke_width=width) + + def polygon(points): + yield "filldraw(" + yield "--".join(["(%.5g,%5g)" % xy for xy in points]) + yield "--cycle, % s);" % arrow_pen + + extent = self.graphics.view_width or 0 + default_arrow = self._default_arrow(polygon) + custom_arrow = self._custom_arrow("asy", _ASYTransform) + return "".join(self._draw(polyline, default_arrow, custom_arrow, extent)) + + +add_conversion_fn(ArrowBox, arrow_box) + + +def bezier_curve_box(self) -> str: + line_width = self.style.get_line_width(face_element=False) + pen = asy_create_pens(edge_color=self.edge_color, stroke_width=line_width) + + asy = "" + for line in self.lines: + for path in asy_bezier((self.spline_degree, [xy.pos() for xy in line])): + if path[:2] == "..": + path = "(0.,0.)" + path + asy += "draw(%s, %s);" % (path, pen) + return asy + if self.arc is None: + return _roundbox(self) + + x, y, rx, ry, sx, sy, ex, ey, large_arc = self._arc_params() + +add_conversion_fn(BezierCurveBox, bezier_curve_box) + + +def filled_curve_box(self) -> str: + line_width = self.style.get_line_width(face_element=False) + pen = asy_create_pens(edge_color=self.edge_color, stroke_width=line_width) + + if not pen: + pen = "currentpen" + + def components(): + for component in self.components: + transformed = [(k, [xy.pos() for xy in p]) for k, p in component] + yield "fill(%s--cycle, %s);" % ("".join(asy_bezier(*transformed)), pen) + + return "".join(components()) + + +add_conversion_fn(FilledCurveBox, filled_curve_box) + + +def graphics_elements(self) -> str: + result = [] + for element in self.elements: + format_fn = lookup_method(element, "asy") + if format_fn is None: + result.append(element.to_asy(offset)) + else: + result.append(format_fn(element)) + + return "\n".join(result) + + +add_conversion_fn(GraphicsElements, graphics_elements) +graphics3delements = graphics_elements + + +add_conversion_fn(Graphics3DElements) + + +def insetbox(self) -> str: + x, y = self.pos.pos() + content = self.content.boxes_to_tex(evaluation=self.graphics.evaluation) + pen = asy_create_pens(edge_color=self.color) + asy = 'label("$%s$", (%s,%s), (%s,%s), %s);' % ( + content, + x, + y, + -self.opos[0], + -self.opos[1], + pen, + ) + return asy + + +add_conversion_fn(InsetBox) + + +def line3dbox(self): + # l = self.style.get_line_width(face_element=False) + pen = asy_create_pens(edge_color=self.edge_color, stroke_width=1) + + return "".join( + "draw({0}, {1});".format( + "--".join("({0},{1},{2})".format(*coords.pos()[0]) for coords in line), + pen, + ) + for line in self.lines + ) + + +add_conversion_fn(Line3DBox) + + +def linebox(self) -> str: + line_width = self.style.get_line_width(face_element=False) + pen = asy_create_pens(edge_color=self.edge_color, stroke_width=line_width) + asy = "" + for line in self.lines: + path = "--".join(["(%.5g,%5g)" % coords.pos() for coords in line]) + asy += "draw(%s, %s);" % (path, pen) + # print("### linebox", asy) + return asy + + +add_conversion_fn(LineBox) + + +def point3dbox(self) -> str: + face_color = self.face_color + + # Tempoary bug fix: default Point color should be black not white + if list(face_color.to_rgba()[:3]) == [1, 1, 1]: + face_color = RGBColor(components=(0, 0, 0, face_color.to_rgba()[3])) + + pen = asy_create_pens(face_color=face_color, is_face_element=False) + + return "".join( + "path3 g={0}--cycle;dot(g, {1});".format( + "--".join("(%.5g,%.5g,%.5g)" % coords.pos()[0] for coords in line), pen + ) + for line in self.lines + ) + + +add_conversion_fn(Point3DBox) + + +def pointbox(self) -> str: + pen = asy_create_pens(face_color=self.face_color, is_face_element=False) + + asy = "" + for line in self.lines: + for coords in line: + asy += "dot(%s, %s);" % (coords.pos(), pen) + + # print("### pointbox", asy) + return asy + + +add_conversion_fn(PointBox) + + +def polygon3dbox(self) -> str: + l = self.style.get_line_width(face_element=True) + if self.vertex_colors is None: + face_color = self.face_color + else: + face_color = None + pen = asy_create_pens( + edge_color=self.edge_color, + face_color=face_color, + stroke_width=l, + is_face_element=True, + ) + + asy = "" + for line in self.lines: + asy += ( + "path3 g=" + + "--".join(["(%.5g,%.5g,%.5g)" % coords.pos()[0] for coords in line]) + + "--cycle;" + ) + asy += "draw(surface(g), %s);" % (pen) + + # print("### polygon3dbox", asy) + return asy + + +add_conversion_fn(Polygon3DBox) + + +def polygonbox(self): + line_width = self.style.get_line_width(face_element=True) + if self.vertex_colors is None: + face_color = self.face_color + else: + face_color = None + pens = asy_create_pens( + edge_color=self.edge_color, + face_color=face_color, + stroke_width=line_width, + is_face_element=True, + ) + asy = "" + if self.vertex_colors is not None: + paths = [] + colors = [] + edges = [] + for index, line in enumerate(self.lines): + paths.append( + "--".join(["(%.5g,%.5g)" % coords.pos() for coords in line]) + "--cycle" + ) + + # ignore opacity + colors.append( + ",".join([_color(color)[0] for color in self.vertex_colors[index]]) + ) + + edges.append(",".join(["0"] + ["1"] * (len(self.vertex_colors[index]) - 1))) + + asy += "gouraudshade(%s, new pen[] {%s}, new int[] {%s});" % ( + "^^".join(paths), + ",".join(colors), + ",".join(edges), + ) + if pens and pens != "nullpen": + for line in self.lines: + path = ( + "--".join(["(%.5g,%.5g)" % coords.pos() for coords in line]) + "--cycle" + ) + asy += "filldraw(%s, %s);" % (path, pens) + + # print("### polygonbox", asy) + return asy + + +add_conversion_fn(PolygonBox) + + +def rectanglebox(self) -> str: + line_width = self.style.get_line_width(face_element=True) + x1, y1 = self.p1.pos() + x2, y2 = self.p2.pos() + pens = asy_create_pens( + self.edge_color, self.face_color, line_width, is_face_element=True + ) + x1, x2, y1, y2 = asy_number(x1), asy_number(x2), asy_number(y1), asy_number(y2) + return "filldraw((%s,%s)--(%s,%s)--(%s,%s)--(%s,%s)--cycle, %s);" % ( + x1, + y1, + x2, + y1, + x2, + y2, + x1, + y2, + pens, + ) + + +add_conversion_fn(RectangleBox) + + +def _roundbox(self): + x, y = self.c.pos() + rx, ry = self.r.pos() + rx -= x + ry -= y + line_width = self.style.get_line_width(face_element=self.face_element) + pen = asy_create_pens( + edge_color=self.edge_color, + face_color=self.face_color, + stroke_width=line_width, + is_face_element=self.face_element, + ) + cmd = "filldraw" if self.face_element else "draw" + return "%s(ellipse((%s,%s),%s,%s), %s);" % ( + cmd, + asy_number(x), + asy_number(y), + asy_number(rx), + asy_number(ry), + pen, + ) + + +add_conversion_fn(_RoundBox) + + +def sphere3dbox(self) -> str: + # l = self.style.get_line_width(face_element=True) + + if self.face_color is None: + face_color = (1, 1, 1) + else: + face_color = self.face_color.to_js() + + return "".join( + "draw(surface(sphere({0}, {1})), rgb({2},{3},{4}));".format( + tuple(coord.pos()[0]), self.radius, *face_color[:3] + ) + for coord in self.points + ) + + +add_conversion_fn(Sphere3DBox) diff --git a/mathics/formatter/asy_fns.py b/mathics/formatter/asy_fns.py new file mode 100644 index 0000000000..ca319d0e1e --- /dev/null +++ b/mathics/formatter/asy_fns.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- + +""" Asymptote-related functions""" + +from itertools import chain + +def asy_bezier(*segments): + # see http://asymptote.sourceforge.net/doc/Bezier-curves.html#Bezier-curves + + while segments and not segments[0][1]: + segments = segments[1:] + + if not segments: + return + + def cubic(p0, p1, p2, p3): + return "..controls(%.5g,%.5g) and (%.5g,%.5g)..(%.5g,%.5g)" % tuple( + list(chain(p1, p2, p3)) + ) + + def quadratric(qp0, qp1, qp2): + # asymptote only supports cubic beziers, so we convert this quadratic + # bezier to a cubic bezier, see http://fontforge.github.io/bezier.html + + # CP0 = QP0 + # CP3 = QP2 + # CP1 = QP0 + 2 / 3 * (QP1 - QP0) + # CP2 = QP2 + 2 / 3 * (QP1 - QP2) + + qp0x, qp0y = qp0 + qp1x, qp1y = qp1 + qp2x, qp2y = qp2 + + t = 2.0 / 3.0 + cp0 = qp0 + cp1 = (qp0x + t * (qp1x - qp0x), qp0y + t * (qp1y - qp0y)) + cp2 = (qp2x + t * (qp1x - qp2x), qp2y + t * (qp1y - qp2y)) + cp3 = qp2 + + return cubic(cp0, cp1, cp2, cp3) + + def linear(p0, p1): + return "--(%.5g,%.5g)" % p1 + + forms = (linear, quadratric, cubic) + + def path(max_degree, p): + max_degree = min(max_degree, len(forms)) + while p: + n = min(max_degree, len(p) - 1) # 1, 2, or 3 + if n < 1: + break + yield forms[n - 1](*p[: n + 1]) + p = p[n:] + + k, p = segments[0] + yield "(%.5g,%.5g)" % p[0] + + connect = [] + for k, p in segments: + for s in path(k, list(chain(connect, p))): + yield s + connect = p[-1:] + + +def asy_color(self) -> str: + """Return an asymptote string fragment for object's RGB or RGBA value""" + rgba = self.to_rgba() + alpha = rgba[3] if len(rgba) > 3 else 1.0 + return ( + r"rgb(%s, %s, %s)" + % (asy_number(rgba[0]), asy_number(rgba[1]), asy_number(rgba[2])), + alpha, + ) + + +def asy_create_pens( + edge_color=None, face_color=None, stroke_width=None, is_face_element=False +) -> str: + """ + Return an asymptote string fragment that creates a drawing pen. + """ + result = [] + if face_color is not None: + brush, opacity = asy_color(face_color) + if opacity != 1: + brush += "+opacity(%s)" % asy_number(opacity) + result.append(brush) + elif is_face_element: + result.append("nullpen") + if edge_color is not None: + pen, opacity = asy_color(edge_color) + if opacity != 1: + pen += "+opacity(%s)" % asy_number(opacity) + if stroke_width is not None: + pen += "+linewidth(%s)" % asy_number(stroke_width) + result.append(pen) + elif is_face_element: + result.append("nullpen") + return ", ".join(result) + + +def asy_number(value) -> str: + """Format an asymptote number""" + return "%.5g" % value diff --git a/mathics/formatter/svg.py b/mathics/formatter/svg.py index 7a741efb4b..835a0e234d 100644 --- a/mathics/formatter/svg.py +++ b/mathics/formatter/svg.py @@ -27,7 +27,6 @@ class _SVGTransform: def __init__(self): - from trepan.api import debug; debug() self.transforms = [] def matrix(self, a, b, c, d, e, f): @@ -77,7 +76,7 @@ def create_css( return "; ".join(css) -def arrowbox(self, offset=None): +def arrow_box(self, offset=None): width = self.style.get_line_width(face_element=False) style = create_css(edge_color=self.edge_color, stroke_width=width) polyline = self.curve.make_draw_svg(style) @@ -95,7 +94,7 @@ def polygon(points): return "".join(self._draw(polyline, default_arrow, custom_arrow, extent)) -add_conversion_fn(ArrowBox) +add_conversion_fn(ArrowBox, arrow_box) def beziercurvebox(self, offset=None): @@ -113,7 +112,7 @@ def beziercurvebox(self, offset=None): add_conversion_fn(BezierCurveBox) -def filledcurvebox(self, offset=None): +def filled_curve_box(self, offset=None): line_width = self.style.get_line_width(face_element=False) style = create_css( edge_color=self.edge_color, face_color=self.face_color, stroke_width=line_width @@ -131,9 +130,9 @@ def components(): ) -add_conversion_fn(FilledCurveBox) +add_conversion_fn(FilledCurveBox, filled_curve_box) -def graphicsbox(self, leaves=None, **options) -> str: +def graphics_box(self, leaves=None, **options) -> str: if not leaves: leaves = self._leaves @@ -186,10 +185,10 @@ def graphicsbox(self, leaves=None, **options) -> str: return svg_main # , width, height -add_conversion_fn(GraphicsBox) +add_conversion_fn(GraphicsBox, graphics_box) -def graphicselements(self, offset=None): +def graphics_elements(self, offset=None): result = [] for element in self.elements: format_fn = lookup_method(element, "svg") @@ -201,13 +200,13 @@ def graphicselements(self, offset=None): return "\n".join(result) -add_conversion_fn(GraphicsElements) -graphics3delements = graphicselements +add_conversion_fn(GraphicsElements, graphics_elements) +graphics3delements = graphics_elements add_conversion_fn(Graphics3DElements) -def insetbox(self, offset=None): +def inset_box(self, offset=None): x, y = self.pos.pos() if offset: x = x + offset[0] @@ -238,10 +237,10 @@ def insetbox(self, offset=None): return svg -add_conversion_fn(InsetBox) +add_conversion_fn(InsetBox, inset_box) -def linebox(self, offset=None): +def line_box(self, offset=None): line_width = self.style.get_line_width(face_element=False) style = create_css(edge_color=self.edge_color, stroke_width=line_width) svg = "" @@ -254,7 +253,7 @@ def linebox(self, offset=None): return svg -add_conversion_fn(LineBox) +add_conversion_fn(LineBox, line_box) def pointbox(self, offset=None): diff --git a/test/test_formatter/test_asy.py b/test/test_formatter/test_asy.py new file mode 100644 index 0000000000..a996de1e7c --- /dev/null +++ b/test/test_formatter/test_asy.py @@ -0,0 +1,116 @@ +import re +from mathics.core.expression import Symbol, Integer0, Integer1, Expression +from mathics.core.evaluation import Evaluation +from mathics.session import MathicsSession +from mathics.builtin.inout import MakeBoxes +from mathics.core.formatter import lookup_method + +session = MathicsSession(add_builtin=True, catch_interrupt=False) +evaluation = Evaluation(session.definitions) + +GraphicsSymbol = Symbol("Graphics") +ListSymbol = Symbol("List") + +asy_wrapper_pat = r"""^\s* +\s*\\begin{asy} +\s*usepackage\("amsmath"\); +\s*size\(.+\); +\s* +""" + + +def extract_asy_body(asy): + matches = re.match(asy_wrapper_pat, asy) + body = asy[len(matches.group(0)) :] + assert matches + print(body) + return body + + +def get_asy(expression): + options = {} + boxes = MakeBoxes(expression).evaluate(evaluation) + return boxes.boxes_to_tex() + + +def test_asy_circle(): + expression = Expression( + GraphicsSymbol, + Expression("Circle", Expression(ListSymbol, Integer0, Integer0)), + ) + + asy = get_asy(expression) + inner_asy = extract_asy_body(asy) + + # Circles are implemented as ellipses with equal major and minor axes. + # Check for that. + matches = re.match( + r'^draw\(ellipse\(\((.+),\s*(.+)\),(.*),(.*)\), .*', inner_asy + ) + + assert matches + # Check that center point is centered and + # major and minor axes are the same + assert matches.group(1) == matches.group(2) + assert matches.group(3) == matches.group(4) + + +def test_asy_point(): + expression = Expression( + GraphicsSymbol, + Expression("Point", Expression(ListSymbol, Integer0, Integer0)), + ) + + asy = get_asy(expression) + inner_asy = extract_asy_body(asy) + + matches = re.match(r'^dot\(\((.+), (.+)\), .+\);.*', inner_asy) + assert matches + # Since the x,y pont is the same, we'll check that whatever this + # coordinate mapped to, it is the same. + assert matches.group(1) == matches.group(2) + + +def test_asy_arrowbox(): + expression = Expression( + GraphicsSymbol, + Expression( + "Arrow", + Expression( + ListSymbol, + Expression(ListSymbol, Integer0, Integer0), + Expression(ListSymbol, Integer1, Integer1), + ), + ), + ) + asy = get_asy(expression) + inner_asy = extract_asy_body(asy) + + matches = re.match(r'^draw\(.*\)', inner_asy) + # TODO: Match line and arrowbox + assert matches + + +def test_asy_bezier_curve(): + + expression = Expression( + GraphicsSymbol, + Expression( + "BezierCurve", + Expression( + ListSymbol, + Expression(ListSymbol, Integer0, Integer0), + Expression(ListSymbol, Integer1, Integer1), + ), + ), + ) + asy = get_asy(expression) + inner_asy = extract_asy_body(asy) + + matches = re.match(r'^draw\(.*\)', inner_asy) + # TODO: Match line and arrowbox + assert matches + + +if __name__ == "__main__": + test_asy_bezier_curve() diff --git a/test/test_formatter.py b/test/test_formatter/test_svg.py similarity index 62% rename from test/test_formatter.py rename to test/test_formatter/test_svg.py index 998141a9b0..ed251ce20f 100644 --- a/test/test_formatter.py +++ b/test/test_formatter/test_svg.py @@ -1,5 +1,5 @@ import re -from mathics.core.expression import Symbol, Integer0, Expression +from mathics.core.expression import Symbol, Integer0, Integer1, Expression from mathics.core.evaluation import Evaluation from mathics.session import MathicsSession from mathics.builtin.inout import MakeBoxes @@ -63,6 +63,7 @@ def test_svg_circle(): assert matches assert matches.group(1) == matches.group(2) == matches.group(3) + def test_svg_point(): expression = Expression( GraphicsSymbol, @@ -75,12 +76,53 @@ def test_svg_point(): # Circles are implemented as ellipses with equal major and minor axes. # Check for that. print(inner_svg) - matches = re.match( - r'^', inner_svg - ) + matches = re.match(r'^', inner_svg) assert matches assert matches.group(1) == matches.group(2) +def test_svg_arrowbox(): + expression = Expression( + GraphicsSymbol, + Expression( + "Arrow", + Expression( + ListSymbol, + Expression(ListSymbol, Integer0, Integer0), + Expression(ListSymbol, Integer1, Integer1), + ), + ), + ) + svg = get_svg(expression) + inner_svg = extract_svg_body(svg) + + matches = re.match(r'^<', inner_svg) + # TODO: Could pick endpoint of this line and match with beginnign of arrow polygon below + assert matches + arrow_polygon = inner_svg[len(matches.group(0)) - 1 :] + matches = re.match(r'^', arrow_polygon) + assert matches + + +def test_svg_bezier_curve(): + + expression = Expression( + GraphicsSymbol, + Expression( + "BezierCurve", + Expression( + ListSymbol, + Expression(ListSymbol, Integer0, Integer0), + Expression(ListSymbol, Integer1, Integer1), + ), + ), + ) + svg = get_svg(expression) + inner_svg = extract_svg_body(svg) + + matches = re.match(r'^', inner_svg) + assert matches + + if __name__ == "__main__": - test_svg_point() + test_svg_bezier_curve()