From 2b1f24c9b0222ee8788f30fe3737a6e6900a0c78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Wi=C5=9Bniewski?= Date: Sat, 20 Jan 2024 02:00:42 +0100 Subject: [PATCH 1/8] Add SVG rendering implementation --- poetry.lock | 20 +- pyproject.toml | 2 + src/pygerber/common/rgba.py | 8 + src/pygerber/gerberx3/math/vector_2d.py | 23 + .../gerberx3/parser2/apertures2/aperture2.py | 17 +- .../gerberx3/parser2/apertures2/block2.py | 15 + .../gerberx3/parser2/apertures2/circle2.py | 27 +- .../gerberx3/parser2/apertures2/macro2.py | 15 + .../gerberx3/parser2/apertures2/obround2.py | 12 +- .../gerberx3/parser2/apertures2/polygon2.py | 12 +- .../gerberx3/parser2/apertures2/rectangle2.py | 12 +- .../gerberx3/parser2/command_buffer2.py | 13 + .../gerberx3/parser2/commands2/arc2.py | 10 + .../parser2/commands2/buffer_command2.py | 10 + .../gerberx3/parser2/commands2/command2.py | 6 + .../gerberx3/parser2/commands2/flash2.py | 11 + .../gerberx3/parser2/commands2/line2.py | 9 +- .../gerberx3/parser2/commands2/region2.py | 9 + src/pygerber/gerberx3/parser2/parser2.py | 8 +- src/pygerber/gerberx3/renderer2/__init__.py | 3 + src/pygerber/gerberx3/renderer2/abstract.py | 135 ++++++ src/pygerber/gerberx3/renderer2/svg.py | 425 ++++++++++++++++++ test/gerberx3/test_parser2/test_parser2.py | 8 +- 23 files changed, 792 insertions(+), 18 deletions(-) create mode 100644 src/pygerber/gerberx3/renderer2/__init__.py create mode 100644 src/pygerber/gerberx3/renderer2/abstract.py create mode 100644 src/pygerber/gerberx3/renderer2/svg.py diff --git a/poetry.lock b/poetry.lock index 1d73534e..2fcfbe8b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. [[package]] name = "annotated-types" @@ -623,6 +623,21 @@ files = [ {file = "docutils-0.20.1.tar.gz", hash = "sha256:f08a4e276c3a1583a86dce3e34aba3fe04d02bba2dd51ed16106244e8a923e3b"}, ] +[[package]] +name = "drawsvg" +version = "2.3.0" +description = "A Python 3 library for programmatically generating SVG (vector) images and animations. Drawsvg can also render to PNG, MP4, and display your drawings in Jupyter notebook and Jupyter lab." +optional = true +python-versions = "*" +files = [ + {file = "drawsvg-2.3.0-py3-none-any.whl", hash = "sha256:93ad2b465d0da0b98c4e2cde11b64ce0149e2f15f8cdc0bd8e68f8b30a2e0abf"}, +] + +[package.extras] +all = ["cairoSVG (>=2.3,<3.0)", "imageio (>=2.5,<3.0)", "imageio-ffmpeg (>=0.4,<1.0)", "numpy (>=1.16,<2.0)", "pwkit (>=1.0,<2.0)"] +color = ["numpy (>=1.16,<2.0)", "pwkit (>=1.0,<2.0)"] +raster = ["cairoSVG (>=2.3,<3.0)", "imageio (>=2.5,<3.0)", "imageio-ffmpeg (>=0.4,<1.0)", "numpy (>=1.16,<2.0)"] + [[package]] name = "exceptiongroup" version = "1.1.3" @@ -2985,8 +3000,9 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [extras] language-server = ["lsprotocol", "pygls"] +svg = ["drawsvg"] [metadata] lock-version = "2.0" python-versions = "^3.8,<3.13" -content-hash = "9a21768b3ab5a22c7f3e7864cc1e65eb77509368b159a9c188961b54ff33c2f4" +content-hash = "2ede3912fa5a5abcd78e084fca609082a2cfeabb568cd429f4095d6690f604d4" diff --git a/pyproject.toml b/pyproject.toml index c8633055..a1e539fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,6 +58,7 @@ numpy = [ pyyaml = "^6.0.1" pygls = { version = "^1.0.2", optional = true } lsprotocol = { version = "^2023.0.0a3", optional = true } +drawsvg = { version = "^2.3.0", optional = true } [tool.poetry.group.dev.dependencies] mypy = "^1.6.1" @@ -93,6 +94,7 @@ mike = "^1.1.2" [tool.poetry.extras] language_server = ["pygls", "lsprotocol"] +svg = ["drawsvg"] [build-system] requires = ["poetry-core"] diff --git a/src/pygerber/common/rgba.py b/src/pygerber/common/rgba.py index 60163bb9..01ce0f5d 100644 --- a/src/pygerber/common/rgba.py +++ b/src/pygerber/common/rgba.py @@ -156,3 +156,11 @@ def as_rgba_float(self) -> tuple[float, float, float, float]: float(Decimal(self.b) / Decimal(255)), float(Decimal(self.a) / Decimal(255)), ) + + def to_hex(self) -> str: + """Return color as hexadecimal string.""" + r = f"{self.r:0{2}x}" + g = f"{self.g:0{2}x}" + b = f"{self.b:0{2}x}" + a = f"{self.a:0{2}x}" + return f"#{r}{g}{b}{a}" diff --git a/src/pygerber/gerberx3/math/vector_2d.py b/src/pygerber/gerberx3/math/vector_2d.py index 29e7fc4f..2e86d009 100644 --- a/src/pygerber/gerberx3/math/vector_2d.py +++ b/src/pygerber/gerberx3/math/vector_2d.py @@ -209,6 +209,29 @@ def determinant(self, other: Vector2D) -> Offset: """Calculate determinant of matrix constructed from self and other.""" return self.x * other.y - self.y * other.x + def perpendicular(self) -> Vector2D: + """Return perpendicular vector to self.""" + return Vector2D(x=self.y, y=-self.x) + + def normalize(self) -> Vector2D: + """Return normalized (unit length) vector.""" + if self == Vector2D.NULL: + return Vector2D.UNIT_X + + return self / self.length() + + def as_float_tuple(self) -> tuple[float, float]: + """Return x, y Offset as tuple.""" + return (float(self.x.value), float(self.y.value)) + + def rotate_around_origin(self, angle_degrees: Decimal) -> Vector2D: + """Return vector rotated x degrees around origin.""" + angle_radians = math.radians(angle_degrees) + return Vector2D( + x=self.x * math.cos(angle_radians) - self.y * math.sin(angle_radians), + y=self.x * math.sin(angle_radians) + self.y * math.cos(angle_radians), + ) + Vector2D.NULL = Vector2D(x=Offset.NULL, y=Offset.NULL) Vector2D.UNIT_X = Vector2D(x=Offset(value=Decimal(1)), y=Offset.NULL) diff --git a/src/pygerber/gerberx3/parser2/apertures2/aperture2.py b/src/pygerber/gerberx3/parser2/apertures2/aperture2.py index e8302c08..bea094db 100644 --- a/src/pygerber/gerberx3/parser2/apertures2/aperture2.py +++ b/src/pygerber/gerberx3/parser2/apertures2/aperture2.py @@ -1,19 +1,34 @@ """Parser level abstraction of aperture info for Gerber AST parser, version 2.""" from __future__ import annotations +from typing import TYPE_CHECKING + from pydantic import Field from pygerber.common.frozen_general_model import FrozenGeneralModel from pygerber.common.immutable_map_model import ImmutableMapping from pygerber.gerberx3.math.bounding_box import BoundingBox +from pygerber.gerberx3.math.offset import Offset from pygerber.gerberx3.parser2.attributes2 import ApertureAttributes +if TYPE_CHECKING: + from pygerber.gerberx3.parser2.commands2.flash2 import Flash2 + from pygerber.gerberx3.renderer2.abstract import Renderer2 + class Aperture2(FrozenGeneralModel): """Parser level abstraction of aperture info.""" attributes: ApertureAttributes = Field(default_factory=ImmutableMapping) - def get_bounding_box_size(self) -> BoundingBox: + def render_flash(self, renderer: Renderer2, command: Flash2) -> None: + """Render draw operation.""" + raise NotImplementedError + + def get_bounding_box(self) -> BoundingBox: """Return bounding box of aperture.""" raise NotImplementedError + + def get_stroke_width(self) -> Offset: + """Get stroke width of command.""" + raise NotImplementedError diff --git a/src/pygerber/gerberx3/parser2/apertures2/block2.py b/src/pygerber/gerberx3/parser2/apertures2/block2.py index b49f08e6..3e62ff66 100644 --- a/src/pygerber/gerberx3/parser2/apertures2/block2.py +++ b/src/pygerber/gerberx3/parser2/apertures2/block2.py @@ -2,13 +2,28 @@ from __future__ import annotations +from typing import TYPE_CHECKING + +from pygerber.gerberx3.math.bounding_box import BoundingBox from pygerber.gerberx3.parser2.apertures2.aperture2 import Aperture2 from pygerber.gerberx3.parser2.command_buffer2 import ( ReadonlyCommandBuffer2, ) +if TYPE_CHECKING: + from pygerber.gerberx3.parser2.commands2.flash2 import Flash2 + from pygerber.gerberx3.renderer2.abstract import Renderer2 + class Block2(Aperture2): """Parser level abstraction of aperture info for block aperture.""" command_buffer: ReadonlyCommandBuffer2 + + def render_flash(self, renderer: Renderer2, command: Flash2) -> None: + """Render draw operation.""" + renderer.hooks.render_flash_block(command, self) + + def get_bounding_box(self) -> BoundingBox: + """Return bounding box of aperture.""" + return self.command_buffer.get_bounding_box() diff --git a/src/pygerber/gerberx3/parser2/apertures2/circle2.py b/src/pygerber/gerberx3/parser2/apertures2/circle2.py index efc2183b..4f6ebad5 100644 --- a/src/pygerber/gerberx3/parser2/apertures2/circle2.py +++ b/src/pygerber/gerberx3/parser2/apertures2/circle2.py @@ -1,11 +1,16 @@ """Parser level abstraction of circle aperture info for Gerber AST parser, version 2.""" from __future__ import annotations -from typing import Optional +from typing import TYPE_CHECKING, Optional +from pygerber.gerberx3.math.bounding_box import BoundingBox from pygerber.gerberx3.math.offset import Offset from pygerber.gerberx3.parser2.apertures2.aperture2 import Aperture2 +if TYPE_CHECKING: + from pygerber.gerberx3.parser2.commands2.flash2 import Flash2 + from pygerber.gerberx3.renderer2.abstract import Renderer2 + class Circle2(Aperture2): """Parser level abstraction of aperture info for circle aperture.""" @@ -13,8 +18,28 @@ class Circle2(Aperture2): diameter: Offset hole_diameter: Optional[Offset] + def render_flash(self, renderer: Renderer2, command: Flash2) -> None: + """Render draw operation.""" + renderer.hooks.render_flash_circle(command, self) + + def get_bounding_box(self) -> BoundingBox: + """Get bounding box of draw operation.""" + return BoundingBox.from_diameter(self.diameter) + + def get_stroke_width(self) -> Offset: + """Get stroke width of command.""" + return self.diameter + class NoCircle2(Circle2): """Dummy aperture representing case when aperture is not needed but has to be given to denote width of draw line command. """ + + def render_flash(self, renderer: Renderer2, command: Flash2) -> None: + """Render draw operation.""" + renderer.hooks.render_flash_no_circle(command, self) + + def get_bounding_box(self) -> BoundingBox: + """Get bounding box of draw operation.""" + return BoundingBox.NULL diff --git a/src/pygerber/gerberx3/parser2/apertures2/macro2.py b/src/pygerber/gerberx3/parser2/apertures2/macro2.py index eda84ee4..b9250023 100644 --- a/src/pygerber/gerberx3/parser2/apertures2/macro2.py +++ b/src/pygerber/gerberx3/parser2/apertures2/macro2.py @@ -2,11 +2,26 @@ from __future__ import annotations +from typing import TYPE_CHECKING + +from pygerber.gerberx3.math.bounding_box import BoundingBox from pygerber.gerberx3.parser2.apertures2.aperture2 import Aperture2 from pygerber.gerberx3.parser2.command_buffer2 import ReadonlyCommandBuffer2 +if TYPE_CHECKING: + from pygerber.gerberx3.parser2.commands2.flash2 import Flash2 + from pygerber.gerberx3.renderer2.abstract import Renderer2 + class Macro2(Aperture2): """Parser level abstraction of aperture info for macro aperture.""" command_buffer: ReadonlyCommandBuffer2 + + def render_flash(self, renderer: Renderer2, command: Flash2) -> None: + """Render draw operation.""" + renderer.hooks.render_flash_macro(command, self) + + def get_bounding_box(self) -> BoundingBox: + """Return bounding box of aperture.""" + return self.command_buffer.get_bounding_box() diff --git a/src/pygerber/gerberx3/parser2/apertures2/obround2.py b/src/pygerber/gerberx3/parser2/apertures2/obround2.py index c5951d25..1e4a02c8 100644 --- a/src/pygerber/gerberx3/parser2/apertures2/obround2.py +++ b/src/pygerber/gerberx3/parser2/apertures2/obround2.py @@ -3,12 +3,16 @@ """ from __future__ import annotations -from typing import Optional +from typing import TYPE_CHECKING, Optional from pygerber.gerberx3.math.bounding_box import BoundingBox from pygerber.gerberx3.math.offset import Offset from pygerber.gerberx3.parser2.apertures2.aperture2 import Aperture2 +if TYPE_CHECKING: + from pygerber.gerberx3.parser2.commands2.flash2 import Flash2 + from pygerber.gerberx3.renderer2.abstract import Renderer2 + class Obround2(Aperture2): """Parser level abstraction of aperture info for obround aperture.""" @@ -17,6 +21,10 @@ class Obround2(Aperture2): y_size: Offset hole_diameter: Optional[Offset] - def get_bounding_box_size(self) -> BoundingBox: + def render_flash(self, renderer: Renderer2, command: Flash2) -> None: + """Render draw operation.""" + renderer.hooks.render_flash_obround(command, self) + + def get_bounding_box(self) -> BoundingBox: """Return bounding box of aperture.""" return BoundingBox.from_rectangle(self.x_size, self.y_size) diff --git a/src/pygerber/gerberx3/parser2/apertures2/polygon2.py b/src/pygerber/gerberx3/parser2/apertures2/polygon2.py index fc67fa39..f073afef 100644 --- a/src/pygerber/gerberx3/parser2/apertures2/polygon2.py +++ b/src/pygerber/gerberx3/parser2/apertures2/polygon2.py @@ -4,12 +4,16 @@ from __future__ import annotations from decimal import Decimal # noqa: TCH003 -from typing import Optional +from typing import TYPE_CHECKING, Optional from pygerber.gerberx3.math.bounding_box import BoundingBox from pygerber.gerberx3.math.offset import Offset from pygerber.gerberx3.parser2.apertures2.aperture2 import Aperture2 +if TYPE_CHECKING: + from pygerber.gerberx3.parser2.commands2.flash2 import Flash2 + from pygerber.gerberx3.renderer2.abstract import Renderer2 + class Polygon2(Aperture2): """Parser level abstraction of aperture info for polygon aperture.""" @@ -19,6 +23,10 @@ class Polygon2(Aperture2): rotation: Decimal hole_diameter: Optional[Offset] - def get_bounding_box_size(self) -> BoundingBox: + def render_flash(self, renderer: Renderer2, command: Flash2) -> None: + """Render draw operation.""" + renderer.hooks.render_flash_polygon(command, self) + + def get_bounding_box(self) -> BoundingBox: """Return bounding box of aperture.""" return BoundingBox.from_diameter(self.outer_diameter) diff --git a/src/pygerber/gerberx3/parser2/apertures2/rectangle2.py b/src/pygerber/gerberx3/parser2/apertures2/rectangle2.py index b30f20ef..ea3b1c0f 100644 --- a/src/pygerber/gerberx3/parser2/apertures2/rectangle2.py +++ b/src/pygerber/gerberx3/parser2/apertures2/rectangle2.py @@ -4,12 +4,16 @@ from __future__ import annotations -from typing import Optional +from typing import TYPE_CHECKING, Optional from pygerber.gerberx3.math.bounding_box import BoundingBox from pygerber.gerberx3.math.offset import Offset from pygerber.gerberx3.parser2.apertures2.aperture2 import Aperture2 +if TYPE_CHECKING: + from pygerber.gerberx3.parser2.commands2.flash2 import Flash2 + from pygerber.gerberx3.renderer2.abstract import Renderer2 + class Rectangle2(Aperture2): """Parser level abstraction of aperture info for rectangle aperture.""" @@ -18,6 +22,10 @@ class Rectangle2(Aperture2): y_size: Offset hole_diameter: Optional[Offset] - def get_bounding_box_size(self) -> BoundingBox: + def render_flash(self, renderer: Renderer2, command: Flash2) -> None: + """Render draw operation.""" + renderer.hooks.render_flash_rectangle(command, self) + + def get_bounding_box(self) -> BoundingBox: """Return bounding box of aperture.""" return BoundingBox.from_rectangle(self.x_size, self.y_size) diff --git a/src/pygerber/gerberx3/parser2/command_buffer2.py b/src/pygerber/gerberx3/parser2/command_buffer2.py index 4e3a03b2..513699e7 100644 --- a/src/pygerber/gerberx3/parser2/command_buffer2.py +++ b/src/pygerber/gerberx3/parser2/command_buffer2.py @@ -7,6 +7,7 @@ from pydantic import Field from pygerber.common.frozen_general_model import FrozenGeneralModel +from pygerber.gerberx3.math.bounding_box import BoundingBox from pygerber.gerberx3.math.vector_2d import Vector2D from pygerber.gerberx3.parser2.commands2.command2 import Command2 from pygerber.gerberx3.state_enums import Mirroring @@ -82,3 +83,15 @@ def get_transposed(self, vector: Vector2D) -> Self: return self.model_copy( update={"commands": [c.get_transposed(vector) for c in self.commands]}, ) + + def get_bounding_box(self) -> BoundingBox: + """Get bounding box of command buffer.""" + bbox: Optional[BoundingBox] = None + + for command in self: + if bbox is None: + bbox = command.get_bounding_box() + else: + bbox += command.get_bounding_box() + + return BoundingBox.NULL if bbox is None else bbox diff --git a/src/pygerber/gerberx3/parser2/commands2/arc2.py b/src/pygerber/gerberx3/parser2/commands2/arc2.py index c7832055..b92752e8 100644 --- a/src/pygerber/gerberx3/parser2/commands2/arc2.py +++ b/src/pygerber/gerberx3/parser2/commands2/arc2.py @@ -14,6 +14,8 @@ if TYPE_CHECKING: from typing_extensions import Self + from pygerber.gerberx3.renderer2.abstract import Renderer2 + class Arc2(ApertureDrawCommand2): """Parser level abstraction of draw arc operation for Gerber AST parser, @@ -64,8 +66,16 @@ def get_transposed(self, vector: Vector2D) -> Self: }, ) + def render(self, renderer: Renderer2) -> None: + """Render draw operation.""" + renderer.hooks.render_arc(self) + class CCArc2(Arc2): """Parser level abstraction of draw counterclockwise arc operation for Gerber AST parser, version 2. """ + + def render(self, renderer: Renderer2) -> None: + """Render draw operation.""" + renderer.hooks.render_cc_arc(self) diff --git a/src/pygerber/gerberx3/parser2/commands2/buffer_command2.py b/src/pygerber/gerberx3/parser2/commands2/buffer_command2.py index 7a043517..c3d5c220 100644 --- a/src/pygerber/gerberx3/parser2/commands2/buffer_command2.py +++ b/src/pygerber/gerberx3/parser2/commands2/buffer_command2.py @@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Iterator +from pygerber.gerberx3.math.bounding_box import BoundingBox from pygerber.gerberx3.parser2.command_buffer2 import ReadonlyCommandBuffer2 from pygerber.gerberx3.parser2.commands2.command2 import Command2 from pygerber.gerberx3.state_enums import Mirroring @@ -13,6 +14,7 @@ from typing_extensions import Self from pygerber.gerberx3.math.vector_2d import Vector2D + from pygerber.gerberx3.renderer2.abstract import Renderer2 class BufferCommand2(Command2): @@ -38,6 +40,14 @@ def get_transposed(self, vector: Vector2D) -> Self: }, ) + def get_bounding_box(self) -> BoundingBox: + """Get bounding box of draw operation.""" + return self.command_buffer.get_bounding_box() + + def render(self, renderer: Renderer2) -> None: + """Render draw operation.""" + renderer.hooks.render_buffer(self) + def __len__(self) -> int: """Return length of buffered commands.""" return len(self.command_buffer) diff --git a/src/pygerber/gerberx3/parser2/commands2/command2.py b/src/pygerber/gerberx3/parser2/commands2/command2.py index 7c7c9749..2168b931 100644 --- a/src/pygerber/gerberx3/parser2/commands2/command2.py +++ b/src/pygerber/gerberx3/parser2/commands2/command2.py @@ -13,6 +13,8 @@ if TYPE_CHECKING: from typing_extensions import Self + from pygerber.gerberx3.renderer2.abstract import Renderer2 + class Command2(FrozenGeneralModel): """Parser level abstraction of draw operation for Gerber AST parser, version 2.""" @@ -31,6 +33,10 @@ def get_transposed(self, vector: Vector2D) -> Self: """Get transposed command.""" raise NotImplementedError + def render(self, hooks: Renderer2) -> None: + """Render draw operation.""" + raise NotImplementedError + def command_to_json(self) -> str: """Dump draw operation.""" return json.dumps( diff --git a/src/pygerber/gerberx3/parser2/commands2/flash2.py b/src/pygerber/gerberx3/parser2/commands2/flash2.py index 714428bb..b80fbb11 100644 --- a/src/pygerber/gerberx3/parser2/commands2/flash2.py +++ b/src/pygerber/gerberx3/parser2/commands2/flash2.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING +from pygerber.gerberx3.math.bounding_box import BoundingBox from pygerber.gerberx3.math.vector_2d import Vector2D from pygerber.gerberx3.parser2.commands2.aperture_draw_command2 import ( ApertureDrawCommand2, @@ -12,6 +13,8 @@ if TYPE_CHECKING: from typing_extensions import Self + from pygerber.gerberx3.renderer2.abstract import Renderer2 + class Flash2(ApertureDrawCommand2): """Parser level abstraction of flash operation for Gerber AST parser, @@ -35,3 +38,11 @@ def get_transposed(self, vector: Vector2D) -> Self: "flash_point": self.flash_point + vector, }, ) + + def render(self, renderer: Renderer2) -> None: + """Render draw operation.""" + self.aperture.render_flash(renderer, self) + + def get_bounding_box(self) -> BoundingBox: + """Get bounding box of draw operation.""" + return self.aperture.get_bounding_box() + self.flash_point diff --git a/src/pygerber/gerberx3/parser2/commands2/line2.py b/src/pygerber/gerberx3/parser2/commands2/line2.py index cd25c1c8..c78fcdd5 100644 --- a/src/pygerber/gerberx3/parser2/commands2/line2.py +++ b/src/pygerber/gerberx3/parser2/commands2/line2.py @@ -4,7 +4,6 @@ from typing import TYPE_CHECKING from pygerber.gerberx3.math.bounding_box import BoundingBox -from pygerber.gerberx3.math.offset import Offset from pygerber.gerberx3.math.vector_2d import Vector2D from pygerber.gerberx3.parser2.commands2.aperture_draw_command2 import ( ApertureDrawCommand2, @@ -14,6 +13,8 @@ if TYPE_CHECKING: from typing_extensions import Self + from pygerber.gerberx3.renderer2.abstract import Renderer2 + class Line2(ApertureDrawCommand2): """Parser level abstraction of draw line operation for Gerber AST parser, @@ -25,7 +26,7 @@ class Line2(ApertureDrawCommand2): def get_bounding_box(self) -> BoundingBox: """Return bounding box of draw operation.""" - vertex_box = BoundingBox.from_diameter(Offset.new(1)) + vertex_box = self.aperture.get_bounding_box() return (vertex_box + self.start_point) + (vertex_box + self.end_point) def get_mirrored(self, mirror: Mirroring) -> Self: @@ -45,3 +46,7 @@ def get_transposed(self, vector: Vector2D) -> Self: "end_point": self.end_point + vector, }, ) + + def render(self, renderer: Renderer2) -> None: + """Render draw operation.""" + renderer.hooks.render_line(self) diff --git a/src/pygerber/gerberx3/parser2/commands2/region2.py b/src/pygerber/gerberx3/parser2/commands2/region2.py index 609080ae..2982a5ba 100644 --- a/src/pygerber/gerberx3/parser2/commands2/region2.py +++ b/src/pygerber/gerberx3/parser2/commands2/region2.py @@ -3,12 +3,17 @@ """ from __future__ import annotations +from typing import TYPE_CHECKING + from pydantic import Field from pygerber.gerberx3.parser2.attributes2 import ApertureAttributes, ObjectAttributes from pygerber.gerberx3.parser2.command_buffer2 import ReadonlyCommandBuffer2 from pygerber.gerberx3.parser2.commands2.buffer_command2 import BufferCommand2 +if TYPE_CHECKING: + from pygerber.gerberx3.renderer2.abstract import Renderer2 + class Region2(BufferCommand2): """Parser level abstraction of draw region operation for Gerber AST parser, @@ -29,3 +34,7 @@ def command_to_json(self) -> str: self.command_buffer.debug_buffer_to_json(8)} }} }}""" # noqa: E501 + + def render(self, renderer: Renderer2) -> None: + """Render draw operation.""" + renderer.hooks.render_region(self) diff --git a/src/pygerber/gerberx3/parser2/parser2.py b/src/pygerber/gerberx3/parser2/parser2.py index d938e1f2..393d2c26 100644 --- a/src/pygerber/gerberx3/parser2/parser2.py +++ b/src/pygerber/gerberx3/parser2/parser2.py @@ -8,7 +8,9 @@ from pydantic import Field from pygerber.common.frozen_general_model import FrozenGeneralModel -from pygerber.gerberx3.parser2.command_buffer2 import CommandBuffer2 +from pygerber.gerberx3.parser2.command_buffer2 import ( + ReadonlyCommandBuffer2, +) from pygerber.gerberx3.parser2.context2 import Parser2Context, Parser2ContextOptions from pygerber.gerberx3.parser2.errors2 import ( ExitParsingProcess2Interrupt, @@ -44,12 +46,12 @@ def __init__( ) self.get_hooks().on_parser_init(self) - def parse(self, ast: AST) -> CommandBuffer2: + def parse(self, ast: AST) -> ReadonlyCommandBuffer2: """Parse token stack.""" for _ in self.parse_iter(ast): pass - return self.context.main_command_buffer + return self.context.main_command_buffer.get_readonly() def parse_iter( self, diff --git a/src/pygerber/gerberx3/renderer2/__init__.py b/src/pygerber/gerberx3/renderer2/__init__.py new file mode 100644 index 00000000..ab831879 --- /dev/null +++ b/src/pygerber/gerberx3/renderer2/__init__.py @@ -0,0 +1,3 @@ +"""Package `backend2` contains classes implementing Gerber rendering for command +buffers generated by Parser2 based Gerber source. +""" diff --git a/src/pygerber/gerberx3/renderer2/abstract.py b/src/pygerber/gerberx3/renderer2/abstract.py new file mode 100644 index 00000000..26fa9bb0 --- /dev/null +++ b/src/pygerber/gerberx3/renderer2/abstract.py @@ -0,0 +1,135 @@ +"""Module contains base class Rendering backend for Parser2 based Gerber data +structures. +""" +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, BinaryIO, Generator, Optional + +from pygerber.gerberx3.parser2.apertures2.block2 import Block2 +from pygerber.gerberx3.parser2.apertures2.circle2 import Circle2, NoCircle2 +from pygerber.gerberx3.parser2.apertures2.macro2 import Macro2 +from pygerber.gerberx3.parser2.apertures2.obround2 import Obround2 +from pygerber.gerberx3.parser2.apertures2.polygon2 import Polygon2 +from pygerber.gerberx3.parser2.apertures2.rectangle2 import Rectangle2 +from pygerber.gerberx3.parser2.command_buffer2 import ( + ReadonlyCommandBuffer2, +) +from pygerber.gerberx3.parser2.commands2.arc2 import Arc2 +from pygerber.gerberx3.parser2.commands2.buffer_command2 import BufferCommand2 +from pygerber.gerberx3.parser2.commands2.command2 import Command2 +from pygerber.gerberx3.parser2.commands2.flash2 import Flash2 +from pygerber.gerberx3.parser2.commands2.line2 import Line2 +from pygerber.gerberx3.parser2.commands2.region2 import Region2 + +if TYPE_CHECKING: + from io import BytesIO + + +class Renderer2: + """Rendering backend base class for Parser2 based Gerber data structures.""" + + def __init__(self, hooks: Renderer2HooksABC) -> None: + self.hooks = hooks + + def render(self, command_buffer: ReadonlyCommandBuffer2) -> ImageRef: + """Render Gerber structures.""" + for _ in self.render_iter(command_buffer): + pass + + return self.get_image_ref() + + def get_image_ref(self) -> ImageRef: + """Get reference to render image.""" + return self.hooks.get_image_ref() + + def render_iter( + self, + command_buffer: ReadonlyCommandBuffer2, + ) -> Generator[Command2, None, None]: + """Iterate over commands in buffer and render image for each command.""" + self.hooks.init(self, command_buffer) + for command in command_buffer: + command.render(self) + yield command + self.hooks.finalize() + + +class Renderer2HooksABC: + """Hooks for implementing rendering of Gerber structures to a target format.""" + + def init(self, renderer: Renderer2, command_buffer: ReadonlyCommandBuffer2) -> None: + """Initialize rendering.""" + + def render_buffer(self, command: BufferCommand2) -> None: + """Render command buffer to target image.""" + + def render_line(self, command: Line2) -> None: + """Render line to target image.""" + + def render_arc(self, command: Arc2) -> None: + """Render arc to target image.""" + + def render_cc_arc(self, command: Arc2) -> None: + """Render arc to target image.""" + + def render_flash_circle(self, command: Flash2, aperture: Circle2) -> None: + """Render flash circle to target image.""" + + def render_flash_no_circle(self, command: Flash2, aperture: NoCircle2) -> None: + """Render flash no circle aperture to target image.""" + + def render_flash_rectangle(self, command: Flash2, aperture: Rectangle2) -> None: + """Render flash rectangle to target image.""" + + def render_flash_obround(self, command: Flash2, aperture: Obround2) -> None: + """Render flash obround to target image.""" + + def render_flash_polygon(self, command: Flash2, aperture: Polygon2) -> None: + """Render flash polygon to target image.""" + + def render_flash_macro(self, command: Flash2, aperture: Macro2) -> None: + """Render flash macro aperture to target image.""" + + def render_flash_block(self, command: Flash2, aperture: Block2) -> None: + """Render flash block aperture to target image.""" + + def render_region(self, command: Region2) -> None: + """Render region to target image.""" + + def get_image_ref(self) -> ImageRef: + """Get reference to render image.""" + raise NotImplementedError + + def finalize(self) -> None: + """Finalize rendering.""" + + +class ImageRef: + """Generic container for reference to rendered image.""" + + def save_to( + self, + dest: BytesIO | Path | str, + options: Optional[FormatOptions] = None, + ) -> None: + """Save rendered image.""" + if isinstance(dest, str): + dest = Path(dest) + if isinstance(dest, Path): + with dest.open("wb") as output: + return self._save_to_io(output, options) + else: + return self._save_to_io(dest, options) + + def _save_to_io( + self, + output: BinaryIO, + options: Optional[FormatOptions] = None, + ) -> None: + """Save rendered image to bytes stream buffer.""" + raise NotImplementedError + + +class FormatOptions: + """Base class for representing of possible format options.""" diff --git a/src/pygerber/gerberx3/renderer2/svg.py b/src/pygerber/gerberx3/renderer2/svg.py new file mode 100644 index 00000000..6885196a --- /dev/null +++ b/src/pygerber/gerberx3/renderer2/svg.py @@ -0,0 +1,425 @@ +"""Module contains implementation of Gerber rendering backend outputting SVG files.""" +from __future__ import annotations + +import importlib.util +from decimal import Decimal +from typing import BinaryIO, Optional + +from pygerber.backend.rasterized_2d.color_scheme import ColorScheme +from pygerber.gerberx3.math.vector_2d import Vector2D +from pygerber.gerberx3.parser2.apertures2.block2 import Block2 +from pygerber.gerberx3.parser2.apertures2.circle2 import Circle2, NoCircle2 +from pygerber.gerberx3.parser2.apertures2.macro2 import Macro2 +from pygerber.gerberx3.parser2.apertures2.obround2 import Obround2 +from pygerber.gerberx3.parser2.apertures2.polygon2 import Polygon2 +from pygerber.gerberx3.parser2.apertures2.rectangle2 import Rectangle2 +from pygerber.gerberx3.parser2.command_buffer2 import ReadonlyCommandBuffer2 +from pygerber.gerberx3.parser2.commands2.arc2 import Arc2, CCArc2 +from pygerber.gerberx3.parser2.commands2.buffer_command2 import BufferCommand2 +from pygerber.gerberx3.parser2.commands2.flash2 import Flash2 +from pygerber.gerberx3.parser2.commands2.line2 import Line2 +from pygerber.gerberx3.parser2.commands2.region2 import Region2 +from pygerber.gerberx3.renderer2.abstract import ( + FormatOptions, + ImageRef, + Renderer2, + Renderer2HooksABC, +) +from pygerber.gerberx3.state_enums import Polarity + +IS_SVG_BACKEND_AVAILABLE: bool = False + +try: + _spec_drawsvg = importlib.util.find_spec("drawsvg") + + IS_SVG_BACKEND_AVAILABLE = _spec_drawsvg is not None +except (ImportError, ValueError): + IS_SVG_BACKEND_AVAILABLE = False + + +if IS_SVG_BACKEND_AVAILABLE: + import drawsvg + + +class SvgRenderer2(Renderer2): + """Rendering backend class for rendering SVG images.""" + + def __init__( + self, + hooks: Optional[SvgRenderer2Hooks] = None, + color_scheme: ColorScheme = ColorScheme.DEBUG_1, + ) -> None: + hooks = SvgRenderer2Hooks() if hooks is None else hooks + self.color_scheme = color_scheme + super().__init__(hooks) + + +class SvgRenderer2Hooks(Renderer2HooksABC): + """Rendering backend hooks used to render SVG images.""" + + renderer: SvgRenderer2 + + def init( + self, + renderer: Renderer2, + command_buffer: ReadonlyCommandBuffer2, + ) -> None: + """Initialize rendering.""" + if not isinstance(renderer, SvgRenderer2): + raise NotImplementedError + + self.renderer = renderer + self.command_buffer = command_buffer + self.bounding_box = self.command_buffer.get_bounding_box() + self.color_scheme = self.renderer.color_scheme + + self.mask = drawsvg.Mask() + self.layer = drawsvg.Group() + self.current_polarity: Optional[Polarity] = None + + self.region_point_buffer: list[Decimal] = [] + self.is_region: bool = False + self.scale = Decimal("10") + + self.apertures: dict[str, drawsvg.Group] = {} + + def get_layer(self, polarity: Polarity) -> drawsvg.Group | drawsvg.Mask: + """Get image layer.""" + if self.current_polarity is None or polarity != self.current_polarity: + self.current_polarity = polarity + new_mask = drawsvg.Mask() + # Add solid background for mask to not mask anything by default. + # Following writes to mask will be black to hide parts of the mask. + new_mask.append( + drawsvg.Rectangle( + self.bounding_box.min_x.as_millimeters(), + self.bounding_box.min_y.as_millimeters(), + self.bounding_box.width.as_millimeters(), + self.bounding_box.height.as_millimeters(), + fill="white", + ), + ) + new_layer = drawsvg.Group(mask=new_mask) + new_layer.append(self.layer) + + self.layer = new_layer + self.mask = new_mask + + if self.current_polarity == Polarity.Dark: + return self.layer + + return self.mask + + def get_color(self, polarity: Polarity) -> str: + """Get color for specified polarity.""" + if self.is_region: + if polarity == Polarity.Dark: + return self.color_scheme.solid_region_color.to_hex() + return "black" + + if polarity == Polarity.Dark: + return self.color_scheme.solid_color.to_hex() + return "black" + + def get_aperture(self, aperture_id: int, color: str) -> Optional[drawsvg.Group]: + """Get SVG group representing aperture.""" + return self.apertures.get(self._get_aperture_id(aperture_id, color)) + + def _get_aperture_id(self, aperture_id: int, color: str) -> str: + """Return combined ID for listed aperture.""" + return f"{color}+{aperture_id}" + + def set_aperture( + self, + aperture_id: int, + color: str, + aperture: drawsvg.Group, + ) -> None: + """Set SVG group representing aperture.""" + self.apertures[self._get_aperture_id(aperture_id, color)] = aperture + + def render_buffer(self, command: BufferCommand2) -> None: + """Render command buffer to target image.""" + + def render_line(self, command: Line2) -> None: + """Render line to target image.""" + color = self.get_color(command.transform.polarity) + + command.aperture.render_flash( + self.renderer, + Flash2( + transform=command.transform, + attributes=command.attributes, + aperture=command.aperture, + flash_point=command.start_point, + ), + ) + + parallel_vector = command.start_point - command.end_point + perpendicular_vector = parallel_vector.perpendicular() + normalized_perpendicular_vector = perpendicular_vector.normalize() + point_offset = normalized_perpendicular_vector * ( + command.aperture.get_stroke_width() / 2.0 + ) + + p0 = command.start_point - point_offset + p1 = command.start_point + point_offset + p2 = command.end_point + point_offset + p3 = command.end_point - point_offset + + rectangle = drawsvg.Lines( + p0.x.as_millimeters(), + p0.y.as_millimeters(), + p1.x.as_millimeters(), + p1.y.as_millimeters(), + p2.x.as_millimeters(), + p2.y.as_millimeters(), + p3.x.as_millimeters(), + p3.y.as_millimeters(), + fill=color, + ) + self.get_layer(command.transform.polarity).append(rectangle) + + command.aperture.render_flash( + self.renderer, + Flash2( + transform=command.transform, + attributes=command.attributes, + aperture=command.aperture, + flash_point=command.end_point, + ), + ) + + def render_arc(self, command: Arc2) -> None: + """Render arc to target image.""" + + def render_cc_arc(self, command: Arc2) -> None: + """Render arc to target image.""" + + def render_flash_circle(self, command: Flash2, aperture: Circle2) -> None: + """Render flash circle to target image.""" + color = self.get_color(command.transform.polarity) + aperture_group = self.get_aperture(id(aperture), color) + + if aperture_group is None: + aperture_group = drawsvg.Group() + aperture_group.append( + drawsvg.Circle( + 0, + 0, + aperture.diameter.as_millimeters() / Decimal("2.0"), + fill=color, + ), + ) + self.set_aperture(id(aperture), color, aperture_group) + + self.get_layer(command.transform.polarity).append( + drawsvg.Use( + aperture_group, + command.flash_point.x.as_millimeters(), + command.flash_point.y.as_millimeters(), + ), + ) + + def render_flash_no_circle(self, command: Flash2, aperture: NoCircle2) -> None: + """Render flash no circle aperture to target image.""" + + def render_flash_rectangle(self, command: Flash2, aperture: Rectangle2) -> None: + """Render flash rectangle to target image.""" + color = self.get_color(command.transform.polarity) + aperture_group = self.get_aperture(id(aperture), color) + + x_size = aperture.x_size.as_millimeters() + y_size = aperture.y_size.as_millimeters() + + if aperture_group is None: + aperture_group = drawsvg.Group() + aperture_group.append( + drawsvg.Rectangle( + Decimal("0.0"), + Decimal("0.0"), + x_size, + y_size, + fill=color, + ), + ) + self.set_aperture(id(aperture), color, aperture_group) + + self.get_layer(command.transform.polarity).append( + drawsvg.Use( + aperture_group, + command.flash_point.x.as_millimeters() - (x_size / Decimal("2.0")), + command.flash_point.y.as_millimeters() - (y_size / Decimal("2.0")), + ), + ) + + def render_flash_obround(self, command: Flash2, aperture: Obround2) -> None: + """Render flash obround to target image.""" + color = self.get_color(command.transform.polarity) + aperture_group = self.get_aperture(id(aperture), color) + + x_size = aperture.x_size.as_millimeters() + y_size = aperture.y_size.as_millimeters() + + if aperture_group is None: + aperture_group = drawsvg.Group() + radius = x_size.min(y_size) / Decimal("2.0") + aperture_group.append( + drawsvg.Rectangle( + Decimal("0.0"), + Decimal("0.0"), + x_size, + y_size, + fill=color, + rx=radius, + ry=radius, + ), + ) + self.set_aperture(id(aperture), color, aperture_group) + + self.get_layer(command.transform.polarity).append( + drawsvg.Use( + aperture_group, + command.flash_point.x.as_millimeters() - (x_size / Decimal("2.0")), + command.flash_point.y.as_millimeters() - (y_size / Decimal("2.0")), + ), + ) + + def render_flash_polygon(self, command: Flash2, aperture: Polygon2) -> None: + """Render flash polygon to target image.""" + color = self.get_color(command.transform.polarity) + aperture_group = self.get_aperture(id(aperture), color) + + outer_diameter = aperture.outer_diameter.as_millimeters() + + if aperture_group is None: + aperture_group = drawsvg.Group() + + number_of_vertices = aperture.number_vertices + initial_angle = aperture.rotation + inner_angle = Decimal("360") / Decimal(number_of_vertices) + radius_vector = Vector2D.UNIT_X * (outer_diameter / Decimal("2.0")) + + p = drawsvg.Path(fill=color) + rotated_radius_vector = radius_vector.rotate_around_origin(initial_angle) + p.M( + rotated_radius_vector.x.as_millimeters(), + rotated_radius_vector.y.as_millimeters(), + ) + + for i in range(1, number_of_vertices): + rotation_angle = inner_angle * i + initial_angle + rotated_radius_vector = radius_vector.rotate_around_origin( + rotation_angle, + ) + p.L( + rotated_radius_vector.x.as_millimeters(), + rotated_radius_vector.y.as_millimeters(), + ) + + p.Z() + + aperture_group.append(p) + self.set_aperture(id(aperture), color, aperture_group) + + self.get_layer(command.transform.polarity).append( + drawsvg.Use( + aperture_group, + command.flash_point.x.as_millimeters(), + command.flash_point.y.as_millimeters(), + ), + ) + + def render_flash_macro(self, command: Flash2, aperture: Macro2) -> None: + """Render flash macro aperture to target image.""" + + def render_flash_block(self, command: Flash2, aperture: Block2) -> None: + """Render flash block aperture to target image.""" + + def render_region(self, command: Region2) -> None: + """Render region to target image.""" + self.is_region = True + self.region_point_buffer = [] + + for cmd in command.command_buffer: + if isinstance(cmd, Line2): + self.render_region_line(cmd) + elif isinstance(cmd, Arc2): + self.render_region_arc(cmd) + elif isinstance(cmd, CCArc2): + self.render_region_cc_arc(cmd) + else: + raise NotImplementedError + + color = self.get_color(command.transform.polarity) + + region = drawsvg.Lines( + *self.region_point_buffer, + fill=color, + close=True, + ) + self.get_layer(command.transform.polarity).append(region) + + self.is_region = False + self.region_point_buffer = [] + + def render_region_line(self, command: Line2) -> None: + """Render line region boundary.""" + self.region_point_buffer.append(command.start_point.x.as_millimeters()) + self.region_point_buffer.append(command.start_point.y.as_millimeters()) + self.region_point_buffer.append(command.end_point.x.as_millimeters()) + self.region_point_buffer.append(command.end_point.y.as_millimeters()) + + def render_region_arc(self, command: Arc2) -> None: + """Render line region boundary.""" + self.region_point_buffer.append(command.start_point.x.as_millimeters()) + self.region_point_buffer.append(command.start_point.y.as_millimeters()) + self.region_point_buffer.append(command.end_point.x.as_millimeters()) + self.region_point_buffer.append(command.end_point.y.as_millimeters()) + + def render_region_cc_arc(self, command: CCArc2) -> None: + """Render line region boundary.""" + self.region_point_buffer.append(command.start_point.x.as_millimeters()) + self.region_point_buffer.append(command.start_point.y.as_millimeters()) + self.region_point_buffer.append(command.end_point.x.as_millimeters()) + self.region_point_buffer.append(command.end_point.y.as_millimeters()) + + def get_image_ref(self) -> ImageRef: + """Get reference to render image.""" + return SvgImageRef(self.drawing) + + def finalize(self) -> None: + """Finalize rendering.""" + width = self.bounding_box.width.as_millimeters() + height = self.bounding_box.height.as_millimeters() + self.drawing = drawsvg.Drawing( + width=width, + height=height, + origin=( + self.bounding_box.min_x.as_millimeters(), + self.bounding_box.min_y.as_millimeters(), + ), + ) + self.drawing.append(self.get_layer(Polarity.Dark)) + + +class SvgImageRef(ImageRef): + """Generic container for reference to rendered image.""" + + def __init__(self, image: drawsvg.Drawing) -> None: + self.image = image + + def _save_to_io( + self, + output: BinaryIO, + options: Optional[FormatOptions] = None, # noqa: ARG002 + ) -> None: + """Save rendered image to bytes stream buffer.""" + svg = self.image.as_svg() + if svg is None: + return + output.write(svg.encode("utf-8")) + + +class SvgFormatOptions: + """Format options for SVG format.""" diff --git a/test/gerberx3/test_parser2/test_parser2.py b/test/gerberx3/test_parser2/test_parser2.py index 8150555f..a0d534b8 100644 --- a/test/gerberx3/test_parser2/test_parser2.py +++ b/test/gerberx3/test_parser2/test_parser2.py @@ -4,7 +4,9 @@ import pytest -from pygerber.gerberx3.parser2.command_buffer2 import CommandBuffer2 +from pygerber.gerberx3.parser2.command_buffer2 import ( + ReadonlyCommandBuffer2, +) from pygerber.gerberx3.parser2.parser2 import ( Parser2, Parser2OnErrorAction, @@ -31,8 +33,8 @@ def test_parser_parse(parser: Parser2) -> None: """, ) - command_buffer: CommandBuffer2 = parser.parse(ast) - assert isinstance(command_buffer, CommandBuffer2) + command_buffer: ReadonlyCommandBuffer2 = parser.parse(ast) + assert isinstance(command_buffer, ReadonlyCommandBuffer2) def test_parser_parse_iter(parser: Parser2) -> None: From da87023604cd671063dfd3faa884044a298cf1b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Wi=C5=9Bniewski?= Date: Sun, 21 Jan 2024 03:07:53 +0100 Subject: [PATCH 2/8] Add auto flip of svg image and scale option --- .../gerberx3/parser2/apertures2/polygon2.py | 4 + .../gerberx3/parser2/commands2/arc2.py | 7 +- src/pygerber/gerberx3/renderer2/svg.py | 309 +++++++++++++----- 3 files changed, 239 insertions(+), 81 deletions(-) diff --git a/src/pygerber/gerberx3/parser2/apertures2/polygon2.py b/src/pygerber/gerberx3/parser2/apertures2/polygon2.py index f073afef..d2a65185 100644 --- a/src/pygerber/gerberx3/parser2/apertures2/polygon2.py +++ b/src/pygerber/gerberx3/parser2/apertures2/polygon2.py @@ -30,3 +30,7 @@ def render_flash(self, renderer: Renderer2, command: Flash2) -> None: def get_bounding_box(self) -> BoundingBox: """Return bounding box of aperture.""" return BoundingBox.from_diameter(self.outer_diameter) + + def get_stroke_width(self) -> Offset: + """Get stroke width of command.""" + return self.outer_diameter diff --git a/src/pygerber/gerberx3/parser2/commands2/arc2.py b/src/pygerber/gerberx3/parser2/commands2/arc2.py index b92752e8..0c1155c0 100644 --- a/src/pygerber/gerberx3/parser2/commands2/arc2.py +++ b/src/pygerber/gerberx3/parser2/commands2/arc2.py @@ -44,7 +44,12 @@ def get_radius(self) -> Offset: def get_bounding_box(self) -> BoundingBox: """Return bounding box of arc.""" - return BoundingBox.from_diameter(self.get_radius() * 2) + self.center_point + return ( + BoundingBox.from_diameter( + (self.get_radius() * 2) + (self.aperture.get_stroke_width() * 2), + ) + + self.center_point + ) def get_mirrored(self, mirror: Mirroring) -> Self: """Get mirrored command.""" diff --git a/src/pygerber/gerberx3/renderer2/svg.py b/src/pygerber/gerberx3/renderer2/svg.py index 6885196a..abe4a8e2 100644 --- a/src/pygerber/gerberx3/renderer2/svg.py +++ b/src/pygerber/gerberx3/renderer2/svg.py @@ -6,6 +6,7 @@ from typing import BinaryIO, Optional from pygerber.backend.rasterized_2d.color_scheme import ColorScheme +from pygerber.gerberx3.math.offset import Offset from pygerber.gerberx3.math.vector_2d import Vector2D from pygerber.gerberx3.parser2.apertures2.block2 import Block2 from pygerber.gerberx3.parser2.apertures2.circle2 import Circle2, NoCircle2 @@ -47,10 +48,8 @@ class SvgRenderer2(Renderer2): def __init__( self, hooks: Optional[SvgRenderer2Hooks] = None, - color_scheme: ColorScheme = ColorScheme.DEBUG_1, ) -> None: hooks = SvgRenderer2Hooks() if hooks is None else hooks - self.color_scheme = color_scheme super().__init__(hooks) @@ -59,27 +58,36 @@ class SvgRenderer2Hooks(Renderer2HooksABC): renderer: SvgRenderer2 + def __init__( + self, + color_scheme: ColorScheme = ColorScheme.DEBUG_1, + scale: Decimal = Decimal("1"), + *, + flip_y: bool = True, + ) -> None: + self.color_scheme = color_scheme + self.scale = scale + self.flip_y = flip_y + def init( self, renderer: Renderer2, command_buffer: ReadonlyCommandBuffer2, ) -> None: - """Initialize rendering.""" + """Initialize rendering hooks.""" if not isinstance(renderer, SvgRenderer2): raise NotImplementedError self.renderer = renderer self.command_buffer = command_buffer + self.bounding_box = self.command_buffer.get_bounding_box() - self.color_scheme = self.renderer.color_scheme self.mask = drawsvg.Mask() self.layer = drawsvg.Group() self.current_polarity: Optional[Polarity] = None - self.region_point_buffer: list[Decimal] = [] self.is_region: bool = False - self.scale = Decimal("10") self.apertures: dict[str, drawsvg.Group] = {} @@ -92,10 +100,10 @@ def get_layer(self, polarity: Polarity) -> drawsvg.Group | drawsvg.Mask: # Following writes to mask will be black to hide parts of the mask. new_mask.append( drawsvg.Rectangle( - self.bounding_box.min_x.as_millimeters(), - self.bounding_box.min_y.as_millimeters(), - self.bounding_box.width.as_millimeters(), - self.bounding_box.height.as_millimeters(), + 0, + 0, + self.convert_size(self.bounding_box.width), + self.convert_size(self.bounding_box.height), fill="white", ), ) @@ -110,6 +118,27 @@ def get_layer(self, polarity: Polarity) -> drawsvg.Group | drawsvg.Mask: return self.mask + def convert_x(self, x: Offset) -> Decimal: + """Convert x offset to pixel x coordinate.""" + return ( + x.as_millimeters() - self.bounding_box.min_x.as_millimeters() + ) * self.scale + + def convert_y(self, y: Offset) -> Decimal: + """Convert y offset to pixel y coordinate.""" + if self.flip_y: + return ( + self.bounding_box.height.as_millimeters() + - (y.as_millimeters() - self.bounding_box.min_y.as_millimeters()) + ) * self.scale + return ( + y.as_millimeters() - self.bounding_box.min_y.as_millimeters() + ) * self.scale + + def convert_size(self, diameter: Offset) -> Decimal: + """Convert y offset to pixel y coordinate.""" + return diameter.as_millimeters() * self.scale + def get_color(self, polarity: Polarity) -> str: """Get color for specified polarity.""" if self.is_region: @@ -168,14 +197,14 @@ def render_line(self, command: Line2) -> None: p3 = command.end_point - point_offset rectangle = drawsvg.Lines( - p0.x.as_millimeters(), - p0.y.as_millimeters(), - p1.x.as_millimeters(), - p1.y.as_millimeters(), - p2.x.as_millimeters(), - p2.y.as_millimeters(), - p3.x.as_millimeters(), - p3.y.as_millimeters(), + self.convert_x(p0.x), + self.convert_y(p0.y), + self.convert_x(p1.x), + self.convert_y(p1.y), + self.convert_x(p2.x), + self.convert_y(p2.y), + self.convert_x(p3.x), + self.convert_y(p3.y), fill=color, ) self.get_layer(command.transform.polarity).append(rectangle) @@ -192,9 +221,93 @@ def render_line(self, command: Line2) -> None: def render_arc(self, command: Arc2) -> None: """Render arc to target image.""" + color = self.get_color(command.transform.polarity) + + command.aperture.render_flash( + self.renderer, + Flash2( + transform=command.transform, + attributes=command.attributes, + aperture=command.aperture, + flash_point=command.start_point, + ), + ) + + start_perpendicular_vector = command.get_relative_start_point() + start_normalized_perpendicular_vector = start_perpendicular_vector.normalize() + start_point_offset = start_normalized_perpendicular_vector * ( + command.aperture.get_stroke_width() / 2.0 + ) + + end_perpendicular_vector = command.get_relative_end_point() + end_normalized_perpendicular_vector = end_perpendicular_vector.normalize() + end_point_offset = end_normalized_perpendicular_vector * ( + command.aperture.get_stroke_width() / 2.0 + ) + + arc_path = drawsvg.Path(fill=color) + + # Determine start point of inner arc. + start_inner = command.start_point + start_point_offset + end_inner = command.end_point + end_point_offset + # Move path ptr to inner arc start point. + arc_path.M( + self.convert_x(start_inner.x), + self.convert_y(start_inner.y), + ) + self.render_arc_to_path( + command.model_copy( + update={ + "start_point": start_inner, + "end_point": end_inner, + }, + ), + arc_path, + ) + # Determine start point of outer arc. + # This arc have to be in reverse direction, so we swap start/end points. + start_outer = command.end_point - end_point_offset + end_outer = command.start_point - start_point_offset + # Draw line between end of inner arc and start of outer arc. + arc_path.L( + self.convert_x(start_outer.x), + self.convert_y(start_outer.y), + ) + self.render_cc_arc_to_path( + CCArc2( + transform=command.transform, + attributes=command.attributes, + aperture=command.aperture, + start_point=start_outer, + center_point=command.center_point, + end_point=end_outer, + ), + arc_path, + ) + # Close arc box by drawing line between end of outer arc and start of inner + arc_path.Z() + self.get_layer(command.transform.polarity).append(arc_path) + + command.aperture.render_flash( + self.renderer, + Flash2( + transform=command.transform, + attributes=command.attributes, + aperture=command.aperture, + flash_point=command.end_point, + ), + ) def render_cc_arc(self, command: Arc2) -> None: """Render arc to target image.""" + self.render_arc( + command.model_copy( + update={ + "start_point": command.end_point, + "end_point": command.start_point, + }, + ), + ) def render_flash_circle(self, command: Flash2, aperture: Circle2) -> None: """Render flash circle to target image.""" @@ -207,7 +320,7 @@ def render_flash_circle(self, command: Flash2, aperture: Circle2) -> None: drawsvg.Circle( 0, 0, - aperture.diameter.as_millimeters() / Decimal("2.0"), + self.convert_size(aperture.diameter) / Decimal("2.0"), fill=color, ), ) @@ -216,8 +329,8 @@ def render_flash_circle(self, command: Flash2, aperture: Circle2) -> None: self.get_layer(command.transform.polarity).append( drawsvg.Use( aperture_group, - command.flash_point.x.as_millimeters(), - command.flash_point.y.as_millimeters(), + self.convert_x(command.flash_point.x), + self.convert_y(command.flash_point.y), ), ) @@ -229,17 +342,14 @@ def render_flash_rectangle(self, command: Flash2, aperture: Rectangle2) -> None: color = self.get_color(command.transform.polarity) aperture_group = self.get_aperture(id(aperture), color) - x_size = aperture.x_size.as_millimeters() - y_size = aperture.y_size.as_millimeters() - if aperture_group is None: aperture_group = drawsvg.Group() aperture_group.append( drawsvg.Rectangle( - Decimal("0.0"), - Decimal("0.0"), - x_size, - y_size, + 0, + 0, + self.convert_size(aperture.x_size), + self.convert_size(aperture.y_size), fill=color, ), ) @@ -248,8 +358,10 @@ def render_flash_rectangle(self, command: Flash2, aperture: Rectangle2) -> None: self.get_layer(command.transform.polarity).append( drawsvg.Use( aperture_group, - command.flash_point.x.as_millimeters() - (x_size / Decimal("2.0")), - command.flash_point.y.as_millimeters() - (y_size / Decimal("2.0")), + self.convert_x(command.flash_point.x) + - self.convert_size(aperture.x_size / Decimal("2.0")), + self.convert_y(command.flash_point.y) + - self.convert_size(aperture.y_size / Decimal("2.0")), ), ) @@ -258,16 +370,16 @@ def render_flash_obround(self, command: Flash2, aperture: Obround2) -> None: color = self.get_color(command.transform.polarity) aperture_group = self.get_aperture(id(aperture), color) - x_size = aperture.x_size.as_millimeters() - y_size = aperture.y_size.as_millimeters() - if aperture_group is None: aperture_group = drawsvg.Group() + x_size = self.convert_size(aperture.x_size) + y_size = self.convert_size(aperture.y_size) radius = x_size.min(y_size) / Decimal("2.0") + aperture_group.append( drawsvg.Rectangle( - Decimal("0.0"), - Decimal("0.0"), + 0, + 0, x_size, y_size, fill=color, @@ -280,8 +392,10 @@ def render_flash_obround(self, command: Flash2, aperture: Obround2) -> None: self.get_layer(command.transform.polarity).append( drawsvg.Use( aperture_group, - command.flash_point.x.as_millimeters() - (x_size / Decimal("2.0")), - command.flash_point.y.as_millimeters() - (y_size / Decimal("2.0")), + self.convert_x(command.flash_point.x) + - self.convert_size(aperture.x_size / Decimal("2.0")), + self.convert_y(command.flash_point.y) + - self.convert_size(aperture.y_size / Decimal("2.0")), ), ) @@ -290,21 +404,20 @@ def render_flash_polygon(self, command: Flash2, aperture: Polygon2) -> None: color = self.get_color(command.transform.polarity) aperture_group = self.get_aperture(id(aperture), color) - outer_diameter = aperture.outer_diameter.as_millimeters() - if aperture_group is None: aperture_group = drawsvg.Group() number_of_vertices = aperture.number_vertices initial_angle = aperture.rotation inner_angle = Decimal("360") / Decimal(number_of_vertices) - radius_vector = Vector2D.UNIT_X * (outer_diameter / Decimal("2.0")) - p = drawsvg.Path(fill=color) + radius_vector = Vector2D.UNIT_X * (aperture.outer_diameter / Decimal("2.0")) rotated_radius_vector = radius_vector.rotate_around_origin(initial_angle) + + p = drawsvg.Path(fill=color) p.M( - rotated_radius_vector.x.as_millimeters(), - rotated_radius_vector.y.as_millimeters(), + self.convert_size(rotated_radius_vector.x), + self.convert_size(rotated_radius_vector.y), ) for i in range(1, number_of_vertices): @@ -313,8 +426,8 @@ def render_flash_polygon(self, command: Flash2, aperture: Polygon2) -> None: rotation_angle, ) p.L( - rotated_radius_vector.x.as_millimeters(), - rotated_radius_vector.y.as_millimeters(), + self.convert_size(rotated_radius_vector.x), + self.convert_size(rotated_radius_vector.y), ) p.Z() @@ -325,8 +438,8 @@ def render_flash_polygon(self, command: Flash2, aperture: Polygon2) -> None: self.get_layer(command.transform.polarity).append( drawsvg.Use( aperture_group, - command.flash_point.x.as_millimeters(), - command.flash_point.y.as_millimeters(), + self.convert_x(command.flash_point.x), + self.convert_y(command.flash_point.y), ), ) @@ -338,51 +451,91 @@ def render_flash_block(self, command: Flash2, aperture: Block2) -> None: def render_region(self, command: Region2) -> None: """Render region to target image.""" + if len(command.command_buffer) == 0: + return + self.is_region = True - self.region_point_buffer = [] + + color = self.get_color(command.transform.polarity) + region = drawsvg.Path(fill=color) + + for cmd in command.command_buffer: + if isinstance(cmd, (Line2, Arc2, CCArc2)): + region.M( + self.convert_x(cmd.start_point.x), + self.convert_y(cmd.start_point.y), + ) + break for cmd in command.command_buffer: if isinstance(cmd, Line2): - self.render_region_line(cmd) + self.render_line_to_path(cmd, region) elif isinstance(cmd, Arc2): - self.render_region_arc(cmd) + self.render_arc_to_path(cmd, region) elif isinstance(cmd, CCArc2): - self.render_region_cc_arc(cmd) + self.render_cc_arc_to_path(cmd, region) else: raise NotImplementedError - color = self.get_color(command.transform.polarity) - - region = drawsvg.Lines( - *self.region_point_buffer, - fill=color, - close=True, - ) + region.Z() self.get_layer(command.transform.polarity).append(region) self.is_region = False - self.region_point_buffer = [] - def render_region_line(self, command: Line2) -> None: + def render_line_to_path(self, command: Line2, path: drawsvg.Path) -> None: """Render line region boundary.""" - self.region_point_buffer.append(command.start_point.x.as_millimeters()) - self.region_point_buffer.append(command.start_point.y.as_millimeters()) - self.region_point_buffer.append(command.end_point.x.as_millimeters()) - self.region_point_buffer.append(command.end_point.y.as_millimeters()) + path.L( + self.convert_x(command.end_point.x), + self.convert_y(command.end_point.y), + ) - def render_region_arc(self, command: Arc2) -> None: + def render_arc_to_path(self, command: Arc2, path: drawsvg.Path) -> None: """Render line region boundary.""" - self.region_point_buffer.append(command.start_point.x.as_millimeters()) - self.region_point_buffer.append(command.start_point.y.as_millimeters()) - self.region_point_buffer.append(command.end_point.x.as_millimeters()) - self.region_point_buffer.append(command.end_point.y.as_millimeters()) + relative_start_vector = command.get_relative_start_point() + relative_end_vector = command.get_relative_end_point() + + angle_clockwise = relative_start_vector.angle_between(relative_end_vector) + angle_counter_clockwise = relative_start_vector.angle_between_cc( + relative_end_vector, + ) + # We want to render clockwise angle, so if cc angle is bigger, we need to + # choose small angle. + large_arc = angle_clockwise >= angle_counter_clockwise + sweep = 1 + + path.A( + rx=self.convert_size(command.get_radius()), + ry=self.convert_size(command.get_radius()), + ex=self.convert_x(command.end_point.x), + ey=self.convert_y(command.end_point.y), + rot=0, + large_arc=large_arc, + sweep=sweep, + ) - def render_region_cc_arc(self, command: CCArc2) -> None: + def render_cc_arc_to_path(self, command: CCArc2, path: drawsvg.Path) -> None: """Render line region boundary.""" - self.region_point_buffer.append(command.start_point.x.as_millimeters()) - self.region_point_buffer.append(command.start_point.y.as_millimeters()) - self.region_point_buffer.append(command.end_point.x.as_millimeters()) - self.region_point_buffer.append(command.end_point.y.as_millimeters()) + relative_start_vector = command.get_relative_start_point() + relative_end_vector = command.get_relative_end_point() + + angle_clockwise = relative_start_vector.angle_between(relative_end_vector) + angle_counter_clockwise = relative_start_vector.angle_between_cc( + relative_end_vector, + ) + # We want to render clockwise angle, so if cc angle is bigger, we need to + # choose small angle. + large_arc = not (angle_clockwise >= angle_counter_clockwise) + sweep = 0 + + path.A( + rx=self.convert_size(command.get_radius()), + ry=self.convert_size(command.get_radius()), + ex=self.convert_x(command.end_point.x), + ey=self.convert_y(command.end_point.y), + rot=0, + large_arc=large_arc, + sweep=sweep, + ) def get_image_ref(self) -> ImageRef: """Get reference to render image.""" @@ -390,15 +543,11 @@ def get_image_ref(self) -> ImageRef: def finalize(self) -> None: """Finalize rendering.""" - width = self.bounding_box.width.as_millimeters() - height = self.bounding_box.height.as_millimeters() + width = self.convert_size(self.bounding_box.width) + height = self.convert_size(self.bounding_box.height) self.drawing = drawsvg.Drawing( width=width, height=height, - origin=( - self.bounding_box.min_x.as_millimeters(), - self.bounding_box.min_y.as_millimeters(), - ), ) self.drawing.append(self.get_layer(Polarity.Dark)) From 78ab471e321bd8413511f5da0597b884f38858cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Wi=C5=9Bniewski?= Date: Mon, 22 Jan 2024 00:24:44 +0100 Subject: [PATCH 3/8] Add block apperture flash support with resolution at Parser2 level --- .../gerberx3/parser2/apertures2/block2.py | 3 +- src/pygerber/gerberx3/parser2/context2.py | 16 +- src/pygerber/gerberx3/parser2/errors2.py | 6 - src/pygerber/gerberx3/parser2/parser2hooks.py | 41 ++-- src/pygerber/gerberx3/parser2/state2.py | 31 --- src/pygerber/gerberx3/renderer2/abstract.py | 8 +- src/pygerber/gerberx3/renderer2/errors2.py | 17 ++ src/pygerber/gerberx3/renderer2/svg.py | 190 ++++++++++++++---- 8 files changed, 216 insertions(+), 96 deletions(-) create mode 100644 src/pygerber/gerberx3/renderer2/errors2.py diff --git a/src/pygerber/gerberx3/parser2/apertures2/block2.py b/src/pygerber/gerberx3/parser2/apertures2/block2.py index 3e62ff66..b84b2083 100644 --- a/src/pygerber/gerberx3/parser2/apertures2/block2.py +++ b/src/pygerber/gerberx3/parser2/apertures2/block2.py @@ -22,7 +22,8 @@ class Block2(Aperture2): def render_flash(self, renderer: Renderer2, command: Flash2) -> None: """Render draw operation.""" - renderer.hooks.render_flash_block(command, self) + # Block apertures are resolved into series of commands at parser level. + raise NotImplementedError def get_bounding_box(self) -> BoundingBox: """Return bounding box of aperture.""" diff --git a/src/pygerber/gerberx3/parser2/context2.py b/src/pygerber/gerberx3/parser2/context2.py index a0983875..d675c7c5 100644 --- a/src/pygerber/gerberx3/parser2/context2.py +++ b/src/pygerber/gerberx3/parser2/context2.py @@ -69,6 +69,7 @@ def __init__(self, options: Parser2ContextOptions | None = None) -> None: ) self.region_command_buffer: Optional[CommandBuffer2] = None self.block_command_buffer_stack: list[CommandBuffer2] = [] + self.block_state_stack: list[State2] = [] self.step_and_repeat_command_buffer: Optional[CommandBuffer2] = None self.state_before_step_and_repeat: Optional[State2] = None self.macro_statement_buffer: Optional[StatementBuffer2] = None @@ -91,6 +92,7 @@ def __init__(self, options: Parser2ContextOptions | None = None) -> None: if self.options.custom_macro_expression_factories is None else self.options.custom_macro_expression_factories ) + self.apertures: dict[ApertureID, Aperture2] = {} def push_block_command_buffer(self) -> None: """Add new command buffer for block aperture draw commands.""" @@ -112,6 +114,16 @@ def first_block_command_buffer(self) -> CommandBuffer2: raise ReferencedNotInitializedBlockBufferError(self.current_token) return self.block_command_buffer_stack[-1] + def push_block_state(self) -> None: + """Add new command buffer for block aperture draw commands.""" + self.block_state_stack.append(self.state) + + def pop_block_state(self) -> State2: + """Return latest block aperture command buffer and delete it from the stack.""" + if len(self.block_state_stack) == 0: + raise ReferencedNotInitializedBlockBufferError(self.current_token) + return self.block_state_stack.pop() + def set_region_command_buffer(self) -> None: """Add new command buffer for block aperture draw commands.""" self.region_command_buffer = ( @@ -443,13 +455,13 @@ def set_current_aperture_id(self, current_aperture: Optional[ApertureID]) -> Non def get_aperture(self, __key: ApertureID) -> Aperture2: """Get apertures property value.""" try: - return self.get_state().get_aperture(__key) + return self.apertures[__key] except KeyError as e: raise ApertureNotDefined2Error(self.current_token) from e def set_aperture(self, __key: ApertureID, __value: Aperture2) -> None: """Set the apertures property value.""" - return self.set_state(self.get_state().set_aperture(__key, __value)) + self.apertures[__key] = __value def get_macro(self, __key: str) -> ApertureMacro2: """Get macro property value.""" diff --git a/src/pygerber/gerberx3/parser2/errors2.py b/src/pygerber/gerberx3/parser2/errors2.py index 3f1c1b8f..77b39d0e 100644 --- a/src/pygerber/gerberx3/parser2/errors2.py +++ b/src/pygerber/gerberx3/parser2/errors2.py @@ -75,12 +75,6 @@ class RegionNotInitializedError(Parser2Error): """Raised when region is modified without being accessed without initialization.""" -class NestedRegionNotAllowedError(Parser2Error): - """Raised when attempting to define region while another region was already - started. - """ - - class ApertureNotDefined2Error(Parser2Error): """Raised when undefined aperture is selected.""" diff --git a/src/pygerber/gerberx3/parser2/parser2hooks.py b/src/pygerber/gerberx3/parser2/parser2hooks.py index 5b021b31..e1497a74 100644 --- a/src/pygerber/gerberx3/parser2/parser2hooks.py +++ b/src/pygerber/gerberx3/parser2/parser2hooks.py @@ -26,7 +26,6 @@ from pygerber.gerberx3.parser2.errors2 import ( ApertureNotSelected2Error, IncrementalCoordinatesNotSupported2Error, - NestedRegionNotAllowedError, NoValidArcCenterFoundError, StepAndRepeatNotInitializedError, UnnamedBlockApertureNotAllowedError, @@ -516,12 +515,14 @@ def on_parser_visit_token( context : Parser2Context The context object containing information about the parser state. """ - if context.get_is_aperture_block(): - raise NestedRegionNotAllowedError(token) - context.push_block_command_buffer() + # Save state from before block definition started. + context.push_block_state() + + context.set_current_position(Vector2D.NULL) context.set_is_aperture_block(is_aperture_block=True) context.set_aperture_block_id(token.identifier) + return super().on_parser_visit_token(token, context) class EndBlockApertureTokenHooks(IHooks.EndBlockApertureTokenHooks): @@ -555,9 +556,8 @@ def on_parser_visit_token( command_buffer=command_buffer.get_readonly(), ), ) - - context.set_is_aperture_block(is_aperture_block=False) - context.set_aperture_block_id(None) + # Restore context state from before the block definition. + context.set_state(context.pop_block_state()) return super().on_parser_visit_token(token, context) class DefineApertureCircleTokenHooks(IHooks.DefineApertureCircleTokenHooks): @@ -1369,14 +1369,25 @@ def on_parser_visit_token( ) aperture = context.get_aperture(aperture_id) - context.add_command( - Flash2( - attributes=context.object_attributes, - aperture=aperture, - flash_point=flash_point, - transform=context.get_state().get_aperture_transform(), - ), - ) + if isinstance(aperture, Block2): + context.add_command( + BufferCommand2( + transform=context.get_state().get_aperture_transform(), + command_buffer=aperture.command_buffer.get_transposed( + flash_point, + ), + ), + ) + + else: + context.add_command( + Flash2( + attributes=context.object_attributes, + aperture=aperture, + flash_point=flash_point, + transform=context.get_state().get_aperture_transform(), + ), + ) context.set_current_position(flash_point) return super().on_parser_visit_token(token, context) diff --git a/src/pygerber/gerberx3/parser2/state2.py b/src/pygerber/gerberx3/parser2/state2.py index a27066df..7275de56 100644 --- a/src/pygerber/gerberx3/parser2/state2.py +++ b/src/pygerber/gerberx3/parser2/state2.py @@ -16,7 +16,6 @@ from pygerber.common.immutable_map_model import ImmutableMapping from pygerber.gerberx3.math.offset import Offset from pygerber.gerberx3.math.vector_2d import Vector2D -from pygerber.gerberx3.parser2.apertures2.aperture2 import Aperture2 from pygerber.gerberx3.parser2.errors2 import ( CoordinateFormatNotSet2Error, UnitNotSet2Error, @@ -242,21 +241,6 @@ def set_scaling(self, scaling: Decimal) -> Self: ) -class State2ApertureIndex(ImmutableMapping[ApertureID, Aperture2]): - """Index of all apertures defined in Gerber AST until currently parsed token.""" - - def set_aperture(self, __id: ApertureID, __aperture: Aperture2) -> Self: - """Add new aperture to apertures index.""" - # TODO(argmaster): Add warning handling. # noqa: TD003 - return self.update(__id, __aperture) - - def get_aperture(self, __id: ApertureID) -> Aperture2: - """Get existing aperture from index. When aperture is missing KeyError is - raised. - """ - return self.mapping[__id] - - class State2MacroIndex(ImmutableMapping[str, ApertureMacro2]): """Index of all macros defined in Gerber AST until currently parsed token.""" @@ -831,21 +815,6 @@ def set_current_aperture_id(self, current_aperture: Optional[ApertureID]) -> Sel }, ) - apertures: State2ApertureIndex = Field(default_factory=State2ApertureIndex) - """Collection of all apertures defined until given point in code.""" - - def get_aperture(self, __key: ApertureID) -> Aperture2: - """Get apertures property value.""" - return self.apertures.get_aperture(__key) - - def set_aperture(self, __key: ApertureID, __value: Aperture2) -> Self: - """Set the apertures property value.""" - return self.model_copy( - update={ - "apertures": self.apertures.set_aperture(__key, __value), - }, - ) - macros: State2MacroIndex = Field(default_factory=State2MacroIndex) """Collection of all macros defined until given point in code.""" diff --git a/src/pygerber/gerberx3/renderer2/abstract.py b/src/pygerber/gerberx3/renderer2/abstract.py index 26fa9bb0..191c88c1 100644 --- a/src/pygerber/gerberx3/renderer2/abstract.py +++ b/src/pygerber/gerberx3/renderer2/abstract.py @@ -6,7 +6,6 @@ from pathlib import Path from typing import TYPE_CHECKING, BinaryIO, Generator, Optional -from pygerber.gerberx3.parser2.apertures2.block2 import Block2 from pygerber.gerberx3.parser2.apertures2.circle2 import Circle2, NoCircle2 from pygerber.gerberx3.parser2.apertures2.macro2 import Macro2 from pygerber.gerberx3.parser2.apertures2.obround2 import Obround2 @@ -61,9 +60,6 @@ class Renderer2HooksABC: def init(self, renderer: Renderer2, command_buffer: ReadonlyCommandBuffer2) -> None: """Initialize rendering.""" - def render_buffer(self, command: BufferCommand2) -> None: - """Render command buffer to target image.""" - def render_line(self, command: Line2) -> None: """Render line to target image.""" @@ -91,8 +87,8 @@ def render_flash_polygon(self, command: Flash2, aperture: Polygon2) -> None: def render_flash_macro(self, command: Flash2, aperture: Macro2) -> None: """Render flash macro aperture to target image.""" - def render_flash_block(self, command: Flash2, aperture: Block2) -> None: - """Render flash block aperture to target image.""" + def render_buffer(self, command: BufferCommand2) -> None: + """Render buffer command, performing no writes.""" def render_region(self, command: Region2) -> None: """Render region to target image.""" diff --git a/src/pygerber/gerberx3/renderer2/errors2.py b/src/pygerber/gerberx3/renderer2/errors2.py new file mode 100644 index 00000000..532bc22d --- /dev/null +++ b/src/pygerber/gerberx3/renderer2/errors2.py @@ -0,0 +1,17 @@ +"""Module contains exceptions raised by rendering backends.""" +from __future__ import annotations + + +class Renderer2Error(Exception): + """Base class for exceptions raised by rendering backends.""" + + +class SvgRenderer2Error(Renderer2Error): + """Error raised by SVG rendering backend.""" + + +class DRAWSVGNotAvailableError(SvgRenderer2Error): + """Raised when `drawsvg` can't be imported, probably because it was not installed. + + You can install it with `pip install pygerber[svg]`. + """ diff --git a/src/pygerber/gerberx3/renderer2/svg.py b/src/pygerber/gerberx3/renderer2/svg.py index abe4a8e2..2b3da9ad 100644 --- a/src/pygerber/gerberx3/renderer2/svg.py +++ b/src/pygerber/gerberx3/renderer2/svg.py @@ -2,13 +2,14 @@ from __future__ import annotations import importlib.util +from dataclasses import dataclass, field from decimal import Decimal from typing import BinaryIO, Optional from pygerber.backend.rasterized_2d.color_scheme import ColorScheme +from pygerber.gerberx3.math.bounding_box import BoundingBox from pygerber.gerberx3.math.offset import Offset from pygerber.gerberx3.math.vector_2d import Vector2D -from pygerber.gerberx3.parser2.apertures2.block2 import Block2 from pygerber.gerberx3.parser2.apertures2.circle2 import Circle2, NoCircle2 from pygerber.gerberx3.parser2.apertures2.macro2 import Macro2 from pygerber.gerberx3.parser2.apertures2.obround2 import Obround2 @@ -26,6 +27,7 @@ Renderer2, Renderer2HooksABC, ) +from pygerber.gerberx3.renderer2.errors2 import DRAWSVGNotAvailableError from pygerber.gerberx3.state_enums import Polarity IS_SVG_BACKEND_AVAILABLE: bool = False @@ -53,6 +55,21 @@ def __init__( super().__init__(hooks) +if IS_SVG_BACKEND_AVAILABLE: + import drawsvg + + @dataclass + class SvgRenderingFrame: + """Rendering variable container.""" + + command_buffer: ReadonlyCommandBuffer2 + bounding_box: BoundingBox + normalize_origin_to_0_0: bool + mask: drawsvg.Mask = field(default_factory=drawsvg.Mask) + layer: drawsvg.Group = field(default_factory=drawsvg.Group) + polarity: Optional[Polarity] = None + + class SvgRenderer2Hooks(Renderer2HooksABC): """Rendering backend hooks used to render SVG images.""" @@ -65,6 +82,8 @@ def __init__( *, flip_y: bool = True, ) -> None: + if not IS_SVG_BACKEND_AVAILABLE: + raise DRAWSVGNotAvailableError self.color_scheme = color_scheme self.scale = scale self.flip_y = flip_y @@ -80,21 +99,59 @@ def init( self.renderer = renderer self.command_buffer = command_buffer + self.rendering_stack: list[SvgRenderingFrame] = [ + SvgRenderingFrame( + command_buffer=self.command_buffer, + bounding_box=self.command_buffer.get_bounding_box(), + normalize_origin_to_0_0=True, + ), + ] + self.is_region: bool = False + self.apertures: dict[str, drawsvg.Group] = {} - self.bounding_box = self.command_buffer.get_bounding_box() - - self.mask = drawsvg.Mask() - self.layer = drawsvg.Group() - self.current_polarity: Optional[Polarity] = None + def push_render_frame( + self, + cmd: ReadonlyCommandBuffer2, + *, + normalize_origin_to_0_0: bool = True, + ) -> None: + """Push new segment render frame.""" + self.rendering_stack.append( + SvgRenderingFrame( + command_buffer=cmd, + bounding_box=cmd.get_bounding_box(), + normalize_origin_to_0_0=normalize_origin_to_0_0, + ), + ) - self.is_region: bool = False + def pop_render_frame(self) -> SvgRenderingFrame: + """Pop segment render frame.""" + if len(self.rendering_stack) <= 1: + raise RuntimeError + return self.rendering_stack.pop() - self.apertures: dict[str, drawsvg.Group] = {} + @property + def frame(self) -> SvgRenderingFrame: + """Get current rendering stack frame.""" + return self.rendering_stack[-1] def get_layer(self, polarity: Polarity) -> drawsvg.Group | drawsvg.Mask: """Get image layer.""" - if self.current_polarity is None or polarity != self.current_polarity: - self.current_polarity = polarity + if self.frame.polarity is None or polarity != self.frame.polarity: + self.frame.polarity = polarity + if polarity == Polarity.Dark: + self._new_layer(with_mask=False) + else: + self._new_layer(with_mask=True) + + if self.frame.polarity == Polarity.Dark: + return self.frame.layer + + return self.frame.mask + + def _new_layer(self, *, with_mask: bool) -> None: + """Create new layer including previous layer.""" + if with_mask: new_mask = drawsvg.Mask() # Add solid background for mask to not mask anything by default. # Following writes to mask will be black to hide parts of the mask. @@ -102,38 +159,42 @@ def get_layer(self, polarity: Polarity) -> drawsvg.Group | drawsvg.Mask: drawsvg.Rectangle( 0, 0, - self.convert_size(self.bounding_box.width), - self.convert_size(self.bounding_box.height), + self.convert_size(self.frame.bounding_box.width), + self.convert_size(self.frame.bounding_box.height), fill="white", ), ) + self.frame.mask = new_mask new_layer = drawsvg.Group(mask=new_mask) - new_layer.append(self.layer) + else: + new_layer = drawsvg.Group() - self.layer = new_layer - self.mask = new_mask + new_layer.append(self.frame.layer) - if self.current_polarity == Polarity.Dark: - return self.layer - - return self.mask + self.frame.layer = new_layer def convert_x(self, x: Offset) -> Decimal: """Convert x offset to pixel x coordinate.""" - return ( - x.as_millimeters() - self.bounding_box.min_x.as_millimeters() - ) * self.scale + if self.frame.normalize_origin_to_0_0: + origin_offset_x = self.frame.bounding_box.min_x.as_millimeters() + else: + origin_offset_x = Decimal(0) + + return (x.as_millimeters() - origin_offset_x) * self.scale def convert_y(self, y: Offset) -> Decimal: """Convert y offset to pixel y coordinate.""" + if self.frame.normalize_origin_to_0_0: + origin_offset_y = self.frame.bounding_box.min_y.as_millimeters() + else: + origin_offset_y = Decimal(0) + if self.flip_y: return ( - self.bounding_box.height.as_millimeters() - - (y.as_millimeters() - self.bounding_box.min_y.as_millimeters()) + self.frame.bounding_box.height.as_millimeters() + - (y.as_millimeters() - origin_offset_y) ) * self.scale - return ( - y.as_millimeters() - self.bounding_box.min_y.as_millimeters() - ) * self.scale + return (y.as_millimeters() - origin_offset_y) * self.scale def convert_size(self, diameter: Offset) -> Decimal: """Convert y offset to pixel y coordinate.""" @@ -167,9 +228,6 @@ def set_aperture( """Set SVG group representing aperture.""" self.apertures[self._get_aperture_id(aperture_id, color)] = aperture - def render_buffer(self, command: BufferCommand2) -> None: - """Render command buffer to target image.""" - def render_line(self, command: Line2) -> None: """Render line to target image.""" color = self.get_color(command.transform.polarity) @@ -222,6 +280,33 @@ def render_line(self, command: Line2) -> None: def render_arc(self, command: Arc2) -> None: """Render arc to target image.""" color = self.get_color(command.transform.polarity) + # Arcs which start and end point overlaps are completely invisible in SVG. + # Therefore we need to replace them with two half-full-arcs. + # THB spec recommends doing it when exporting Gerber files, to avoid problems + # with floating point numbers, but I guess nobody does that. + if command.start_point == command.end_point: + # This is a vector from center to start point, so we can invert it and + # apply it twice to get the point on the opposite side of the center point. + relative = command.get_relative_start_point() + # Now we cen recursively invoke self with two modified copies of this + # command. + self.render_arc( + command.model_copy( + update={ + "start_point": command.start_point, + "end_point": command.start_point - (relative * 2), + }, + ), + ) + self.render_arc( + command.model_copy( + update={ + "start_point": command.start_point - (relative * 2), + "end_point": command.start_point, + }, + ), + ) + return command.aperture.render_flash( self.renderer, @@ -232,7 +317,14 @@ def render_arc(self, command: Arc2) -> None: flash_point=command.start_point, ), ) - + # First we calculate perpendicular vector. This vector is always pointing + # from the center, thus it is perpendicular to arc. + # Then we can normalize it and multiply by half of aperture diameter, + # effectively giving us vector pointing to inner/outer edge of line. + # We can ignore the fact that we don't know which point (inner/outer) we + # have, as long as we get the same every time, then we can pair it with + # corresponding vector made from end point and create single arc, + # Then invert both vectors and draw second arc. start_perpendicular_vector = command.get_relative_start_point() start_normalized_perpendicular_vector = start_perpendicular_vector.normalize() start_point_offset = start_normalized_perpendicular_vector * ( @@ -445,9 +537,32 @@ def render_flash_polygon(self, command: Flash2, aperture: Polygon2) -> None: def render_flash_macro(self, command: Flash2, aperture: Macro2) -> None: """Render flash macro aperture to target image.""" + color = self.get_color(command.transform.polarity) + aperture_group = self.get_aperture(id(aperture), color) + + if aperture_group is None: + self.push_render_frame( + aperture.command_buffer, + normalize_origin_to_0_0=False, + ) + for cmd in aperture.command_buffer: + cmd.render(self.renderer) + + frame = self.pop_render_frame() + self.set_aperture(id(aperture), color, frame.layer) + + self.get_layer(command.transform.polarity).append( + drawsvg.Use( + aperture_group, + self.convert_x(command.flash_point.x), + self.convert_y(command.flash_point.y), + ), + ) - def render_flash_block(self, command: Flash2, aperture: Block2) -> None: - """Render flash block aperture to target image.""" + def render_buffer(self, command: BufferCommand2) -> None: + """Render buffer command, performing no writes.""" + for cmd in command: + cmd.render(self.renderer) def render_region(self, command: Region2) -> None: """Render region to target image.""" @@ -543,8 +658,13 @@ def get_image_ref(self) -> ImageRef: def finalize(self) -> None: """Finalize rendering.""" - width = self.convert_size(self.bounding_box.width) - height = self.convert_size(self.bounding_box.height) + if len(self.rendering_stack) > 1: + self.rendering_stack = [self.rendering_stack[0]] + elif len(self.rendering_stack) < 1: + raise RuntimeError + + width = self.convert_size(self.frame.bounding_box.width) + height = self.convert_size(self.frame.bounding_box.height) self.drawing = drawsvg.Drawing( width=width, height=height, From 4bb8eb815f14a64833ab7d126d16ccc76d77c62c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Wi=C5=9Bniewski?= Date: Mon, 22 Jan 2024 00:28:26 +0100 Subject: [PATCH 4/8] Fix unit tests based on sample-ab Gerber source --- .prettierrc | 2 +- .../gerberx3/basic/sample-ab/source.grb | 55 ++++++++++++++++++ .../basic/sample-ab/source.grb.overrides | 3 + .../gerberx3/basic/sample-ab/source.png | Bin 0 -> 8166 bytes .../sample-arc/source_clockwise_2_cc.grb | 8 +++ .../sample-arc/source_clockwise_3_cc.grb | 8 +++ test/conftest.py | 15 +++++ .../test_parser2/test_parser2hooks.py | 16 +---- test/gerberx3/test_parser2/test_state2.py | 8 --- test/gerberx3/test_rasterized_2d/common.py | 3 + .../basic/sample-ab/source/image.png | Bin 0 -> 36484 bytes .../source_clockwise_2_cc/image.png | Bin 0 -> 1745 bytes .../source_clockwise_3_cc/image.png | Bin 0 -> 3558 bytes 13 files changed, 94 insertions(+), 24 deletions(-) create mode 100644 test/assets/gerberx3/basic/sample-ab/source.grb create mode 100644 test/assets/gerberx3/basic/sample-ab/source.grb.overrides create mode 100644 test/assets/gerberx3/basic/sample-ab/source.png create mode 100644 test/assets/gerberx3/basic/sample-arc/source_clockwise_2_cc.grb create mode 100644 test/assets/gerberx3/basic/sample-arc/source_clockwise_3_cc.grb create mode 100644 test/gerberx3/test_rasterized_2d/reference/basic/sample-ab/source/image.png create mode 100644 test/gerberx3/test_rasterized_2d/reference/basic/sample-arc/source_clockwise_2_cc/image.png create mode 100644 test/gerberx3/test_rasterized_2d/reference/basic/sample-arc/source_clockwise_3_cc/image.png diff --git a/.prettierrc b/.prettierrc index 2d76c815..7f4534c0 100644 --- a/.prettierrc +++ b/.prettierrc @@ -5,7 +5,7 @@ "printWidth": 88, "overrides": [ { - "files": ["*.html", "*.js", "*.json", "*.prettierrc"], + "files": ["*.html", "*.js", "*.json", "*.prettierrc", "*.overrides"], "options": { "tabWidth": 4 } diff --git a/test/assets/gerberx3/basic/sample-ab/source.grb b/test/assets/gerberx3/basic/sample-ab/source.grb new file mode 100644 index 00000000..a0f920c7 --- /dev/null +++ b/test/assets/gerberx3/basic/sample-ab/source.grb @@ -0,0 +1,55 @@ +G04 Ucamco copyright* +%TF.GenerationSoftware,Ucamco,UcamX,2016.04-160425*% +%TF.CreationDate,2016-04-25T00:00;00+01:00*% +%TF.Part,Other,Testfile*% +%FSLAX46Y46*% +%MOMM*% +G04 Define standard apertures* +%ADD10C,7.500000*% +%ADD11C,15*% +%ADD12R,20X10*% +%ADD13R,10X20*% +G04 Define block aperture 100, consisting of two draws and a round dot* +%ABD100*% + D10* + X65532000Y17605375D02* + Y65865375D01* + X-3556000D01* + D11* + X-3556000Y17605375D03* +%AB*% +G04 Define block aperture 102, consisting of 2x3 flashes of aperture 101 +and 1 flash of D12* +%ABD102*% + G04 Define nested block aperture 101, consisting of 2x2 flashes of + aperture 100* + %ABD101*% + D100* + X0Y0D03* + X0Y70000000D03* + X100000000Y0D03* + X100000000Y70000000D03* + %AB*% + D101* + X0Y0D03* + X0Y160000000D03* + X0Y320000000D03* + X230000000Y0D03* + X230000000Y160000000D03* + X230000000Y320000000D03* + D12* + X19500000Y-10000000D03* +%AB*% +G04 Flash D13 twice outside of blocks* +D13* +X-30000000Y10000000D03* +X143000000Y-30000000D03* +G04 Flash block 102 3x2 times* +D102* +X0Y0D03* +X0Y520000000D03* +X500000000Y0D03* +X500000000Y520000000D03* +X1000000000Y0D03* +X1000000000Y520000000D03* +M02* diff --git a/test/assets/gerberx3/basic/sample-ab/source.grb.overrides b/test/assets/gerberx3/basic/sample-ab/source.grb.overrides new file mode 100644 index 00000000..c071217c --- /dev/null +++ b/test/assets/gerberx3/basic/sample-ab/source.grb.overrides @@ -0,0 +1,3 @@ +{ + "dpi": 100 +} diff --git a/test/assets/gerberx3/basic/sample-ab/source.png b/test/assets/gerberx3/basic/sample-ab/source.png new file mode 100644 index 0000000000000000000000000000000000000000..1966846e32b74f6e0133b0adc80cf434a29200c8 GIT binary patch literal 8166 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 bytes: return asset + def load_asset_overrides(self, src: str) -> dict[str, Any]: + src = f"{src}.overrides" + asset = self.asset_cache.get(src) + if asset is None: + try: + asset = self._load_asset(src) + except FileNotFoundError: + asset = b"{}" + + config = json.loads(asset) + assert isinstance(config, dict) + return config + def _load_asset(self, src: str) -> bytes: return (self.assets_directory / src).read_bytes() diff --git a/test/gerberx3/test_parser2/test_parser2hooks.py b/test/gerberx3/test_parser2/test_parser2hooks.py index 915622b6..35025548 100644 --- a/test/gerberx3/test_parser2/test_parser2hooks.py +++ b/test/gerberx3/test_parser2/test_parser2hooks.py @@ -30,7 +30,6 @@ from pygerber.gerberx3.parser2.context2 import Parser2Context from pygerber.gerberx3.parser2.errors2 import ( ApertureNotDefined2Error, - NestedRegionNotAllowedError, NoValidArcCenterFoundError, OnUpdateDrawingState2Error, ReferencedNotInitializedBlockBufferError, @@ -524,20 +523,6 @@ def test_begin_block_aperture_token_hooks() -> None: ) -def test_begin_block_aperture_token_hooks_nested_region() -> None: - gerber_source = "%ABD10*%%ABD11*%" - - context = Parser2Context() - - with pytest.raises(NestedRegionNotAllowedError): - parse_code(gerber_source, context) - - debug_dump_context( - context, - DEBUG_DUMP_DIR / test_begin_block_aperture_token_hooks.__qualname__, - ) - - def test_end_block_aperture_token_hooks_uninitialized_block() -> None: gerber_source = "%AB*%" @@ -573,6 +558,7 @@ def test_end_block_aperture_token_hooks() -> None: context = Parser2Context() context.push_block_command_buffer() + context.push_block_state() context.set_is_aperture_block(is_aperture_block=True) context.set_aperture_block_id(ApertureID("D10")) diff --git a/test/gerberx3/test_parser2/test_state2.py b/test/gerberx3/test_parser2/test_state2.py index e8f850c3..4ba004fe 100644 --- a/test/gerberx3/test_parser2/test_state2.py +++ b/test/gerberx3/test_parser2/test_state2.py @@ -1,7 +1,6 @@ from __future__ import annotations from decimal import Decimal -from unittest.mock import MagicMock from pygerber.gerberx3.math.offset import Offset from pygerber.gerberx3.math.vector_2d import Vector2D @@ -139,13 +138,6 @@ def test_set_current_aperture_id() -> None: assert new_state.get_current_aperture_id() == ApertureID("D10") -def test_set_aperture() -> None: - state = State2() - mm = MagicMock() - new_state = state.set_aperture(ApertureID("D10"), mm) - assert new_state.get_aperture(ApertureID("D10")) == mm - - def test_parse_coordinate() -> None: coordinate_parser = CoordinateParser.new( x_format=AxisFormat(integer=4, decimal=6), diff --git a/test/gerberx3/test_rasterized_2d/common.py b/test/gerberx3/test_rasterized_2d/common.py index b26a8c62..b8729d76 100644 --- a/test/gerberx3/test_rasterized_2d/common.py +++ b/test/gerberx3/test_rasterized_2d/common.py @@ -38,6 +38,9 @@ def draw_rasterized_2d( dest_apertures = dest / "apertures" dest_apertures.mkdir(mode=0o777, parents=True, exist_ok=True) + config_overrides = asset_loader.load_asset_overrides(src) + dpi = config_overrides.get("dpi", dpi) + parser_options = ParserOptions( backend=Rasterized2DBackend( options=Rasterized2DBackendOptions( diff --git a/test/gerberx3/test_rasterized_2d/reference/basic/sample-ab/source/image.png b/test/gerberx3/test_rasterized_2d/reference/basic/sample-ab/source/image.png new file mode 100644 index 0000000000000000000000000000000000000000..87867a566f1ec17306ffeb7ef5c14745e718e0e1 GIT binary patch literal 36484 zcmeI*drZ?u00;0x0a1q(jgkmbHS$Psg7blhfR9>1jhJn+ZbXI7$wJr=1+6^VpSY1C zD07RCEFv&Ab+HNzBhDdolZ0d{h=zhA1JpT@M`0P|QE0ngF(JL9Gh~^`T)w}*+TXoD zzq|Y0T^h*Tx@Cjol-W}VA&z08qBjX~>LA1+*TJ5@quagg0wKpXg^AX@Ej!csqm2!J zll#nAq;)VSzy$#aKmY;|fB*y_009U<00Izz00bZa0SG_<0uX=z1Rwwb2tWV=5P-ld zExdG_v_aW8vdAn^fO|;GS4*AQhr^ z&(yihKW!&YFi}~NKC!COpg6R3c`wW^SS=!M10w412= zYImj1%TsB0^=HFmDnODep=(LV{E}cQ3Lzq?AuPA1p}NhHbSLD|B}RB^CehKkP?ZyX zha8s(x$^I6aNdfO|S)zppm2B3K+x`tGa~ zQK8(-$=(M<*POEFGOdYNkZ;>~+}Wv1&_NgCv+|fbl?DH$)R~DyRxe=kBi5f4lasX$ z!8C^`tMp7NL8gxG%w+C#RLBwuLY5i?eTe4Zr(KLPOA^dOmKwzTR;6!PeN&IOeXdKI)(=OH8Hf z!Cew`giC!2h9maV0VLbpgzhG(n`z&Dz6IIM^!|nT{glv4jB!~7M#V>IOkLEaN}Ye~ zNrC)iE|Y6Zn3au-;_&X2NJCr-)7XlQs>;Qs8QrB?^M4DsxCUM;F=pI$FR!g&%55#Q zww(5to+!Ilw3l7zcUxQMw{Z2%;>uqJElM+g9Nsvl+I3BumUMaL_GyLwtL|$|#=Io{ zXlb2c!~mv9f1<67bi+eY>|vUBydOcss4R`#0z-V3Yo_)yng$)JpGO40oB z?Si6Wv}L!Vzj;ij9AYJiJohkq4i%(s=bvw&PL;po z0xi;cNFt1?t_W1Qw9^~BlzAFqTzzp;{h#SG(;9T`*XfC?Po#2EpIVnvKyTfP{iOy+ z$?JQ!ccn)82B&mg|DEcZTu`!W5aGH8;$;f+5Fd1-Yw13xIcsrQRQQ@UO=VV2tEPS)(r#Sw5=RtJ!@a;tcYPV^HlhiTK z&t*%mw=BBMyf_Ea`Sa%IIhd}*S1%Q;wGoIW&boWp^U4{|-h7+nBb(0Y2OkF6MSi?w zv>-p%m-4^I6(sJO{>f3VeH!02i>ZkYD?#o35I)b*{$mYi{OP?nthL+4OV3$fiBUGqTZ#XfyO7+T;J*hrXH=e0lb^M<2X3vfmC{zeRL;ZB+VS Di_Q0d literal 0 HcmV?d00001 diff --git a/test/gerberx3/test_rasterized_2d/reference/basic/sample-arc/source_clockwise_2_cc/image.png b/test/gerberx3/test_rasterized_2d/reference/basic/sample-arc/source_clockwise_2_cc/image.png new file mode 100644 index 0000000000000000000000000000000000000000..f018bfaf4edf1b7feaed1062225af0b7fa6cee95 GIT binary patch literal 1745 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 dKW|LL>2 Date: Mon, 22 Jan 2024 04:11:57 +0100 Subject: [PATCH 5/8] Fix test failures --- src/pygerber/gerberx3/math/vector_2d.py | 4 +- .../gerberx3/parser2/apertures2/rectangle2.py | 4 + src/pygerber/gerberx3/parser2/context2.py | 17 +++- src/pygerber/gerberx3/parser2/parser2hooks.py | 4 +- src/pygerber/gerberx3/renderer2/svg.py | 56 ++++++----- .../assets/gerberx3/kicad/hello/F_Cu/F_Cu.gbr | 1 - test/gerberx3/test_renderer2/__init__.py | 0 test/gerberx3/test_renderer2/common.py | 92 +++++++++++++++++++ test/gerberx3/test_renderer2/test_svg.py | 44 +++++++++ 9 files changed, 192 insertions(+), 30 deletions(-) create mode 100644 test/gerberx3/test_renderer2/__init__.py create mode 100644 test/gerberx3/test_renderer2/common.py create mode 100644 test/gerberx3/test_renderer2/test_svg.py diff --git a/src/pygerber/gerberx3/math/vector_2d.py b/src/pygerber/gerberx3/math/vector_2d.py index 2e86d009..35ac7a2b 100644 --- a/src/pygerber/gerberx3/math/vector_2d.py +++ b/src/pygerber/gerberx3/math/vector_2d.py @@ -192,8 +192,8 @@ def angle_between_cc(self, other: Vector2D) -> float: Value returned is always between 0 and 360 (can be 0, never 360). """ - v0 = self / self.length() - v1 = other / other.length() + v0 = self.normalize() + v1 = other.normalize() angle_radians = math.atan2( ((v0.x * v1.y) - (v1.x * v0.y)).value, # determinant ((v0.x * v1.x) + (v0.y * v1.y)).value, # dot product diff --git a/src/pygerber/gerberx3/parser2/apertures2/rectangle2.py b/src/pygerber/gerberx3/parser2/apertures2/rectangle2.py index ea3b1c0f..a05c4579 100644 --- a/src/pygerber/gerberx3/parser2/apertures2/rectangle2.py +++ b/src/pygerber/gerberx3/parser2/apertures2/rectangle2.py @@ -26,6 +26,10 @@ def render_flash(self, renderer: Renderer2, command: Flash2) -> None: """Render draw operation.""" renderer.hooks.render_flash_rectangle(command, self) + def get_stroke_width(self) -> Offset: + """Return stroke width of aperture.""" + return (self.x_size + self.y_size) / 2 + def get_bounding_box(self) -> BoundingBox: """Return bounding box of aperture.""" return BoundingBox.from_rectangle(self.x_size, self.y_size) diff --git a/src/pygerber/gerberx3/parser2/context2.py b/src/pygerber/gerberx3/parser2/context2.py index d675c7c5..0b186cf2 100644 --- a/src/pygerber/gerberx3/parser2/context2.py +++ b/src/pygerber/gerberx3/parser2/context2.py @@ -8,6 +8,7 @@ from pygerber.common.frozen_general_model import FrozenGeneralModel from pygerber.gerberx3.math.offset import Offset +from pygerber.gerberx3.parser2.apertures2.circle2 import NoCircle2 from pygerber.gerberx3.parser2.attributes2 import ( ApertureAttributes, FileAttributes, @@ -52,6 +53,9 @@ from pygerber.gerberx3.tokenizer.tokens.fs_coordinate_format import CoordinateParser +REGION_OUTLINE_DEFAULT_APERTURE_ID = ApertureID("%*__REGION_OUTLINE_APERTURE__*%") + + class Parser2Context: """Context used by Gerber AST parser, version 2.""" @@ -92,7 +96,12 @@ def __init__(self, options: Parser2ContextOptions | None = None) -> None: if self.options.custom_macro_expression_factories is None else self.options.custom_macro_expression_factories ) - self.apertures: dict[ApertureID, Aperture2] = {} + self.apertures: dict[ApertureID, Aperture2] = { + REGION_OUTLINE_DEFAULT_APERTURE_ID: NoCircle2( + diameter=Offset.NULL, + hole_diameter=None, + ), + } def push_block_command_buffer(self) -> None: """Add new command buffer for block aperture draw commands.""" @@ -444,7 +453,11 @@ def set_current_position(self, current_position: Vector2D) -> None: def get_current_aperture_id(self) -> Optional[ApertureID]: """Get current_aperture property value.""" - return self.get_state().get_current_aperture_id() + current_aperture_id = self.get_state().get_current_aperture_id() + if current_aperture_id is None and self.get_is_region(): + return REGION_OUTLINE_DEFAULT_APERTURE_ID + + return current_aperture_id def set_current_aperture_id(self, current_aperture: Optional[ApertureID]) -> None: """Set the current_aperture property value.""" diff --git a/src/pygerber/gerberx3/parser2/parser2hooks.py b/src/pygerber/gerberx3/parser2/parser2hooks.py index e1497a74..6dddede7 100644 --- a/src/pygerber/gerberx3/parser2/parser2hooks.py +++ b/src/pygerber/gerberx3/parser2/parser2hooks.py @@ -976,11 +976,11 @@ def on_code_20_vector_line( ), end_point=Vector2D( x=Offset.new( - primitive.start_x.on_parser2_eval_expression(context), + primitive.end_x.on_parser2_eval_expression(context), context.get_draw_units(), ), y=Offset.new( - primitive.start_y.on_parser2_eval_expression(context), + primitive.end_y.on_parser2_eval_expression(context), context.get_draw_units(), ), ), diff --git a/src/pygerber/gerberx3/renderer2/svg.py b/src/pygerber/gerberx3/renderer2/svg.py index 2b3da9ad..e58fb183 100644 --- a/src/pygerber/gerberx3/renderer2/svg.py +++ b/src/pygerber/gerberx3/renderer2/svg.py @@ -68,6 +68,8 @@ class SvgRenderingFrame: mask: drawsvg.Mask = field(default_factory=drawsvg.Mask) layer: drawsvg.Group = field(default_factory=drawsvg.Group) polarity: Optional[Polarity] = None + is_region: bool = False + flip_y: bool = True class SvgRenderer2Hooks(Renderer2HooksABC): @@ -104,16 +106,17 @@ def init( command_buffer=self.command_buffer, bounding_box=self.command_buffer.get_bounding_box(), normalize_origin_to_0_0=True, + flip_y=self.flip_y, ), ] - self.is_region: bool = False self.apertures: dict[str, drawsvg.Group] = {} def push_render_frame( self, cmd: ReadonlyCommandBuffer2, *, - normalize_origin_to_0_0: bool = True, + normalize_origin_to_0_0: bool, + flip_y: bool, ) -> None: """Push new segment render frame.""" self.rendering_stack.append( @@ -121,6 +124,7 @@ def push_render_frame( command_buffer=cmd, bounding_box=cmd.get_bounding_box(), normalize_origin_to_0_0=normalize_origin_to_0_0, + flip_y=flip_y, ), ) @@ -157,10 +161,10 @@ def _new_layer(self, *, with_mask: bool) -> None: # Following writes to mask will be black to hide parts of the mask. new_mask.append( drawsvg.Rectangle( - 0, - 0, - self.convert_size(self.frame.bounding_box.width), - self.convert_size(self.frame.bounding_box.height), + x=self.convert_x(self.frame.bounding_box.min_x), + y=self.convert_y(self.frame.bounding_box.min_y), + width=self.convert_size(self.frame.bounding_box.width), + height=self.convert_size(self.frame.bounding_box.height), fill="white", ), ) @@ -180,7 +184,9 @@ def convert_x(self, x: Offset) -> Decimal: else: origin_offset_x = Decimal(0) - return (x.as_millimeters() - origin_offset_x) * self.scale + corrected_position_x = x.as_millimeters() - origin_offset_x + + return corrected_position_x * self.scale def convert_y(self, y: Offset) -> Decimal: """Convert y offset to pixel y coordinate.""" @@ -189,12 +195,14 @@ def convert_y(self, y: Offset) -> Decimal: else: origin_offset_y = Decimal(0) - if self.flip_y: - return ( - self.frame.bounding_box.height.as_millimeters() - - (y.as_millimeters() - origin_offset_y) - ) * self.scale - return (y.as_millimeters() - origin_offset_y) * self.scale + corrected_position_y = y.as_millimeters() - origin_offset_y + + if self.frame.flip_y: + flipped_position_y = ( + self.frame.bounding_box.height.as_millimeters() - corrected_position_y + ) + return flipped_position_y * self.scale + return corrected_position_y * self.scale def convert_size(self, diameter: Offset) -> Decimal: """Convert y offset to pixel y coordinate.""" @@ -202,7 +210,7 @@ def convert_size(self, diameter: Offset) -> Decimal: def get_color(self, polarity: Polarity) -> str: """Get color for specified polarity.""" - if self.is_region: + if self.frame.is_region: if polarity == Polarity.Dark: return self.color_scheme.solid_region_color.to_hex() return "black" @@ -264,6 +272,7 @@ def render_line(self, command: Line2) -> None: self.convert_x(p3.x), self.convert_y(p3.y), fill=color, + close=True, ) self.get_layer(command.transform.polarity).append(rectangle) @@ -410,9 +419,9 @@ def render_flash_circle(self, command: Flash2, aperture: Circle2) -> None: aperture_group = drawsvg.Group() aperture_group.append( drawsvg.Circle( - 0, - 0, - self.convert_size(aperture.diameter) / Decimal("2.0"), + cx=0, + cy=0, + r=self.convert_size(aperture.diameter) / Decimal("2.0"), fill=color, ), ) @@ -421,8 +430,8 @@ def render_flash_circle(self, command: Flash2, aperture: Circle2) -> None: self.get_layer(command.transform.polarity).append( drawsvg.Use( aperture_group, - self.convert_x(command.flash_point.x), - self.convert_y(command.flash_point.y), + x=self.convert_x(command.flash_point.x), + y=self.convert_y(command.flash_point.y), ), ) @@ -544,6 +553,7 @@ def render_flash_macro(self, command: Flash2, aperture: Macro2) -> None: self.push_render_frame( aperture.command_buffer, normalize_origin_to_0_0=False, + flip_y=False, ) for cmd in aperture.command_buffer: cmd.render(self.renderer) @@ -554,8 +564,8 @@ def render_flash_macro(self, command: Flash2, aperture: Macro2) -> None: self.get_layer(command.transform.polarity).append( drawsvg.Use( aperture_group, - self.convert_x(command.flash_point.x), - self.convert_y(command.flash_point.y), + x=self.convert_x(command.flash_point.x), + y=self.convert_y(command.flash_point.y), ), ) @@ -569,7 +579,7 @@ def render_region(self, command: Region2) -> None: if len(command.command_buffer) == 0: return - self.is_region = True + self.frame.is_region = True color = self.get_color(command.transform.polarity) region = drawsvg.Path(fill=color) @@ -595,7 +605,7 @@ def render_region(self, command: Region2) -> None: region.Z() self.get_layer(command.transform.polarity).append(region) - self.is_region = False + self.frame.is_region = False def render_line_to_path(self, command: Line2, path: drawsvg.Path) -> None: """Render line region boundary.""" diff --git a/test/assets/gerberx3/kicad/hello/F_Cu/F_Cu.gbr b/test/assets/gerberx3/kicad/hello/F_Cu/F_Cu.gbr index 1fa4a504..1d3b2dee 100644 --- a/test/assets/gerberx3/kicad/hello/F_Cu/F_Cu.gbr +++ b/test/assets/gerberx3/kicad/hello/F_Cu/F_Cu.gbr @@ -29,7 +29,6 @@ G04 Aperture macros list* 20,1,$1+$1,$6,$7,$8,$9,0* 20,1,$1+$1,$8,$9,$2,$3,0*% G04 Aperture macros list end* -M02* %TA.AperFunction,SMDPad,CuDef*% %ADD10RoundRect,0.150000X-0.825000X-0.150000X0.825000X-0.150000X0.825000X0.150000X-0.825000X0.150000X0*% %TD*% diff --git a/test/gerberx3/test_renderer2/__init__.py b/test/gerberx3/test_renderer2/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/gerberx3/test_renderer2/common.py b/test/gerberx3/test_renderer2/common.py new file mode 100644 index 00000000..480a50fc --- /dev/null +++ b/test/gerberx3/test_renderer2/common.py @@ -0,0 +1,92 @@ +"""Common elements of Rasterized2D tests.""" + +from __future__ import annotations + +from decimal import Decimal +from pathlib import Path +from test.gerberx3.common import find_gerberx3_asset_files +from typing import TYPE_CHECKING, Callable + +import pytest + +from pygerber.gerberx3.parser2.parser2 import Parser2 +from pygerber.gerberx3.renderer2.abstract import ImageRef +from pygerber.gerberx3.renderer2.svg import SvgRenderer2, SvgRenderer2Hooks +from pygerber.gerberx3.tokenizer.tokenizer import Tokenizer + +if TYPE_CHECKING: + from test.conftest import AssetLoader + + +def debug_dump_output(out: ImageRef, dest_dir: Path) -> None: + """Dump parser context to JSON file.""" + dest_dir.mkdir(exist_ok=True, parents=True) + out.save_to(dest_dir / "dump.svg") + + +def render( + asset_loader: AssetLoader, + src: str, + dest: Path, + *, + expression: bool = False, +) -> None: + """Tokenize gerber code and save debug output.""" + source = asset_loader.load_asset(src).decode("utf-8") + if expression: + stack = Tokenizer().tokenize_expressions(source) + else: + stack = Tokenizer().tokenize(source) + + parser = Parser2() + cmd_buf = parser.parse(stack) + ref = SvgRenderer2(SvgRenderer2Hooks(scale=Decimal("10"))).render(cmd_buf) + + debug_dump_output(ref, dest) + + +def make_svg_renderer2_test( + test_file_path: str, + path_to_assets: str, + *, + expression: bool = False, +) -> Callable[..., None]: + """Create parametrized test case for all files from path_to_assets. + + All Gerber files from `path_to_assets` will be included as separate test cases + thanks to use of `@pytest.mark.parametrize`. + + Parameters + ---------- + test_file_path : str + Path to test file, simply use `__file__` variable from module global scope. + path_to_assets : str + Path to assets directory, originating from the root of repository, eg. + `test/assets/gerberx3/basic`. + + Returns + ------- + Callable[..., None] + Test callable. Must be assigned to variable with name starting with `test_`. + """ + debug_output_directory = Path(test_file_path).parent / ".output" + + @pytest.mark.parametrize( + ("directory", "file_name"), + sorted(find_gerberx3_asset_files(path_to_assets)), + ) + def test_sample( + asset_loader: AssetLoader, + directory: str, + file_name: str, + ) -> None: + dest = debug_output_directory / directory / Path(file_name).with_suffix("") + dest.mkdir(mode=0o777, parents=True, exist_ok=True) + render( + asset_loader, + f"gerberx3/{directory}/{file_name}", + dest, + expression=expression, + ) + + return test_sample diff --git a/test/gerberx3/test_renderer2/test_svg.py b/test/gerberx3/test_renderer2/test_svg.py new file mode 100644 index 00000000..77df8cea --- /dev/null +++ b/test/gerberx3/test_renderer2/test_svg.py @@ -0,0 +1,44 @@ +"""Renderer tests based on auto loaded tests asserts..""" + +from __future__ import annotations + +from test.gerberx3.test_renderer2.common import make_svg_renderer2_test + +test_a64_o_linu_xino_rev_g = make_svg_renderer2_test( + __file__, + "test/assets/gerberx3/A64-OLinuXino-rev-G", +) +test_altium_gerber_x2 = make_svg_renderer2_test( + __file__, + "test/assets/gerberx3/AltiumGerberX2", +) + +test_atmega328_motor_board = make_svg_renderer2_test( + __file__, + "test/assets/gerberx3/ATMEGA328-Motor-Board", +) +test_basic = make_svg_renderer2_test( + __file__, + "test/assets/gerberx3/basic", +) +test_expressions = make_svg_renderer2_test( + __file__, + "test/assets/gerberx3/expressions", + expression=True, +) +test_kicad_arduino = make_svg_renderer2_test( + __file__, + "test/assets/gerberx3/kicad/arduino", +) +test_kicad_gerber_x2 = make_svg_renderer2_test( + __file__, + "test/assets/gerberx3/KicadGerberX2", +) +test_kicad_hello = make_svg_renderer2_test( + __file__, + "test/assets/gerberx3/kicad/hello", +) +test_pcb_tools_issues = make_svg_renderer2_test( + __file__, + "test/assets/gerberx3/pcb_tools_issues", +) From 64be0614d1b1aedb04dad396682f1c6fbc46d518 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Wi=C5=9Bniewski?= Date: Mon, 22 Jan 2024 20:11:57 +0100 Subject: [PATCH 6/8] Fix test dependencies --- .github/workflows/unit_tests.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/unit_tests.yaml b/.github/workflows/unit_tests.yaml index c5613ad7..8876ed2a 100644 --- a/.github/workflows/unit_tests.yaml +++ b/.github/workflows/unit_tests.yaml @@ -70,7 +70,7 @@ jobs: run: pip install poetry==1.6.1 - name: Install dependencies - run: poetry install --no-cache --sync + run: poetry install --no-cache --sync --extras=svg - name: Run unit tests run: poetry run poe run-unit-tests From 27d5f1537723e7bfc071333dd868d5087d4f8cd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Wi=C5=9Bniewski?= Date: Tue, 23 Jan 2024 02:45:41 +0100 Subject: [PATCH 7/8] Add support for aperture holes in svg renderer --- src/pygerber/gerberx3/renderer2/svg.py | 93 +++++++++++++++++--------- 1 file changed, 60 insertions(+), 33 deletions(-) diff --git a/src/pygerber/gerberx3/renderer2/svg.py b/src/pygerber/gerberx3/renderer2/svg.py index e58fb183..4c70aa1b 100644 --- a/src/pygerber/gerberx3/renderer2/svg.py +++ b/src/pygerber/gerberx3/renderer2/svg.py @@ -156,20 +156,8 @@ def get_layer(self, polarity: Polarity) -> drawsvg.Group | drawsvg.Mask: def _new_layer(self, *, with_mask: bool) -> None: """Create new layer including previous layer.""" if with_mask: - new_mask = drawsvg.Mask() - # Add solid background for mask to not mask anything by default. - # Following writes to mask will be black to hide parts of the mask. - new_mask.append( - drawsvg.Rectangle( - x=self.convert_x(self.frame.bounding_box.min_x), - y=self.convert_y(self.frame.bounding_box.min_y), - width=self.convert_size(self.frame.bounding_box.width), - height=self.convert_size(self.frame.bounding_box.height), - fill="white", - ), - ) - self.frame.mask = new_mask - new_layer = drawsvg.Group(mask=new_mask) + self.frame.mask = self._make_mask(self.frame.bounding_box) + new_layer = drawsvg.Group(mask=self.frame.mask) else: new_layer = drawsvg.Group() @@ -178,7 +166,7 @@ def _new_layer(self, *, with_mask: bool) -> None: self.frame.layer = new_layer def convert_x(self, x: Offset) -> Decimal: - """Convert x offset to pixel x coordinate.""" + """Convert y offset to y coordinate in image space.""" if self.frame.normalize_origin_to_0_0: origin_offset_x = self.frame.bounding_box.min_x.as_millimeters() else: @@ -189,15 +177,29 @@ def convert_x(self, x: Offset) -> Decimal: return corrected_position_x * self.scale def convert_y(self, y: Offset) -> Decimal: + """Convert y offset to y coordinate in image space.""" + return self._convert_y( + y, + normalize_origin_to_0_0=self.frame.normalize_origin_to_0_0, + flip_y=self.frame.flip_y, + ) + + def _convert_y( + self, + y: Offset, + *, + normalize_origin_to_0_0: bool, + flip_y: bool, + ) -> Decimal: """Convert y offset to pixel y coordinate.""" - if self.frame.normalize_origin_to_0_0: + if normalize_origin_to_0_0: origin_offset_y = self.frame.bounding_box.min_y.as_millimeters() else: origin_offset_y = Decimal(0) corrected_position_y = y.as_millimeters() - origin_offset_y - if self.frame.flip_y: + if flip_y: flipped_position_y = ( self.frame.bounding_box.height.as_millimeters() - corrected_position_y ) @@ -416,7 +418,8 @@ def render_flash_circle(self, command: Flash2, aperture: Circle2) -> None: aperture_group = self.get_aperture(id(aperture), color) if aperture_group is None: - aperture_group = drawsvg.Group() + mask = self._make_mask(aperture.get_bounding_box(), aperture.hole_diameter) + aperture_group = drawsvg.Group(mask=mask) aperture_group.append( drawsvg.Circle( cx=0, @@ -435,6 +438,31 @@ def render_flash_circle(self, command: Flash2, aperture: Circle2) -> None: ), ) + def _make_mask( + self, + bbox: BoundingBox, + hole_diameter: Optional[Offset] = None, + ) -> drawsvg.Mask: + mask = drawsvg.Mask() + mask.append( + drawsvg.Rectangle( + x=self.convert_size(bbox.min_x), + y=self.convert_size(bbox.min_y), + width=self.convert_size(bbox.width), + height=self.convert_size(bbox.height), + fill="white", + ), + ) + if hole_diameter is not None: + central_circle = drawsvg.Circle( + cx=0, + cy=0, + r=self.convert_size(hole_diameter) / 2, + fill="black", + ) + mask.append(central_circle) + return mask + def render_flash_no_circle(self, command: Flash2, aperture: NoCircle2) -> None: """Render flash no circle aperture to target image.""" @@ -444,11 +472,12 @@ def render_flash_rectangle(self, command: Flash2, aperture: Rectangle2) -> None: aperture_group = self.get_aperture(id(aperture), color) if aperture_group is None: - aperture_group = drawsvg.Group() + mask = self._make_mask(aperture.get_bounding_box(), aperture.hole_diameter) + aperture_group = drawsvg.Group(mask=mask) aperture_group.append( drawsvg.Rectangle( - 0, - 0, + -self.convert_size(aperture.x_size) / 2, + -self.convert_size(aperture.y_size) / 2, self.convert_size(aperture.x_size), self.convert_size(aperture.y_size), fill=color, @@ -459,10 +488,8 @@ def render_flash_rectangle(self, command: Flash2, aperture: Rectangle2) -> None: self.get_layer(command.transform.polarity).append( drawsvg.Use( aperture_group, - self.convert_x(command.flash_point.x) - - self.convert_size(aperture.x_size / Decimal("2.0")), - self.convert_y(command.flash_point.y) - - self.convert_size(aperture.y_size / Decimal("2.0")), + self.convert_x(command.flash_point.x), + self.convert_y(command.flash_point.y), ), ) @@ -472,15 +499,16 @@ def render_flash_obround(self, command: Flash2, aperture: Obround2) -> None: aperture_group = self.get_aperture(id(aperture), color) if aperture_group is None: - aperture_group = drawsvg.Group() + mask = self._make_mask(aperture.get_bounding_box(), aperture.hole_diameter) + aperture_group = drawsvg.Group(mask=mask) x_size = self.convert_size(aperture.x_size) y_size = self.convert_size(aperture.y_size) radius = x_size.min(y_size) / Decimal("2.0") aperture_group.append( drawsvg.Rectangle( - 0, - 0, + -self.convert_size(aperture.x_size) / 2, + -self.convert_size(aperture.y_size) / 2, x_size, y_size, fill=color, @@ -493,10 +521,8 @@ def render_flash_obround(self, command: Flash2, aperture: Obround2) -> None: self.get_layer(command.transform.polarity).append( drawsvg.Use( aperture_group, - self.convert_x(command.flash_point.x) - - self.convert_size(aperture.x_size / Decimal("2.0")), - self.convert_y(command.flash_point.y) - - self.convert_size(aperture.y_size / Decimal("2.0")), + self.convert_x(command.flash_point.x), + self.convert_y(command.flash_point.y), ), ) @@ -506,7 +532,8 @@ def render_flash_polygon(self, command: Flash2, aperture: Polygon2) -> None: aperture_group = self.get_aperture(id(aperture), color) if aperture_group is None: - aperture_group = drawsvg.Group() + mask = self._make_mask(aperture.get_bounding_box(), aperture.hole_diameter) + aperture_group = drawsvg.Group(mask=mask) number_of_vertices = aperture.number_vertices initial_angle = aperture.rotation From e02bf5088ebb3df940bc2d13fb371a151d70ce52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Wi=C5=9Bniewski?= Date: Tue, 23 Jan 2024 02:52:24 +0100 Subject: [PATCH 8/8] Add feature support docs for SvgRenderer2 --- docs/gerber/feature_support/svgrenderer2.md | 259 ++++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 docs/gerber/feature_support/svgrenderer2.md diff --git a/docs/gerber/feature_support/svgrenderer2.md b/docs/gerber/feature_support/svgrenderer2.md new file mode 100644 index 00000000..7efed95b --- /dev/null +++ b/docs/gerber/feature_support/svgrenderer2.md @@ -0,0 +1,259 @@ +# SvgRenderer2 feature support + +## Introduction + +`SvgRenderer2` is an experimental SVG backend for rendering of Gerber files. It operates +on command buffers generated by `Parser2`. + +| Symbol | Meaning | +| ------ | ------------------------------------------ | +| ✅ | Feature implemented and usable. | +| 🚧 | Work in progress. Related APIs can change. | +| 🚫 | Not planned, unless contributed or needed. | +| ❌ | Not implemented, but planned. | +| 👽 | Partially implemented. | +| 👾 | Bugged. | +| ⛔ | Feature doesn't apply. | + +| Symbol | Count | +| ------ | ----- | +| ✅ | 169 | +| 🚧 | 0 | +| 🚫 | 2 | +| ❌ | 7 | +| 👽 | 2 | +| 👾 | 0 | +| total | 185 | + +## Supported Gerber X3 features + +### General + +- ⛔ MO - Mode - Sets the unit to mm or inch. +- ⛔ FS - Format specification: + - ⛔ absolute coordinates. + - ⛔ incremental coordinates + - ⛔ trailing zeros omission. + - ⛔ leading zeros omission. +- ⛔ AD - Aperture define - Defines a template-based aperture, assigns a D code to it. + - ⛔ circle. + - ⛔ rectangle. + - ⛔ obround. + - ⛔ polygon. + - ⛔ Define macro. +- ⛔ AM - Aperture macro - Defines a macro aperture template. +- ⛔ Dnn (nn≥10) - Sets the current aperture to D code nn. +- ⛔ G01 - Sets draw mode to linear. + - ⛔ Variable zero padding variants allowed. +- ⛔ G02 - Sets draw mode to clockwise circular. + - ⛔ Variable zero padding variants allowed. +- ⛔ G03 - Sets draw mode to counterclockwise circular. + - ⛔ Variable zero padding variants allowed. +- ❌ LP - Load polarity (changes flag, not fully implemented). +- ❌ LM - Load mirroring (changes flag, not fully implemented). +- ❌ LR - Load rotation (changes flag, not fully implemented). +- ❌ LS - Load scaling (changes flag, not fully implemented). +- ⛔ TF - Attribute on file. +- ⛔ TA - Attribute on aperture. +- ⛔ TO - Attribute on object. +- ⛔ TD - Attribute delete. +- ⛔ M02 - End of file. + +### D01, D02, D03 + +- 👽 D01 - Plot operation, mode + - 👽 Line, with: + - ✅ circle, + - 👽 rectangle, + - 👽 obround, + - 👽 polygon, + - 👽 macro. + - 👾 Arc, with: + - 👾 circle, + - 👽 rectangle, + - 👽 obround, + - 👽 polygon, + - 👽 macro. + - 👾 Counter clockwise arc, with: + - 👾 circle, + - 👽 rectangle, + - 👽 obround, + - 👽 polygon, + - 👽 macro. + - ⛔ Variable zero padding variants allowed. +- ⛔ D02 - Move operation + - ⛔ Variable zero padding variants allowed. +- 👽 D03 - Flash operation, with + - ✅ circle, + - ✅ rectangle, + - ✅ obround, + - ✅ polygon, + - 👽 macro. + - ⛔ Variable zero padding variants allowed. + +### Regions + +- ✅ G36 - Starts a region statement. +- ✅ G37 - Ends the region statement. +- 👽 Regions, with: + - 👽 Line, aperture: + - ✅ circle, + - ✅ rectangle, + - ✅ obround, + - ✅ polygon, + - 👽 macro. + - 👽 Arc, aperture: + - ✅ circle, + - ✅ rectangle, + - ✅ obround, + - ✅ polygon, + - 👽 macro. + - 👽 Counter clockwise arc, aperture: + - ✅ circle, + - ✅ rectangle, + - ✅ obround, + - ✅ polygon, + - 👽 macro. + +### Macros + +- ⛔ Parameters. +- 👽 Primitives in definition: + - ✅ Code 1, Circle + - ❌ Code 2, Vector line + - ✅ Code 4, Outline + - ✅ Code 5, Polygon + - ❌ Code 6, Moire + - ✅ Code 7, Thermal + - ✅ Code 20, Vector line + - ✅ Code 21, Center Line + - ❌ Code 22, Lower Left Line +- 👽 Primitives in aperture instance: + - ✅ Code 1, Circle + - ❌ Code 2, Vector line + - ✅ Code 4, Outline + - ✅ Code 5, Polygon + - ❌ Code 6, Moire + - ❌ Code 7, Thermal + - ✅ Code 20, Vector line + - ✅ Code 21, Center Line + - ❌ Code 22, Lower Left Line +- ❌ Rotation around macro origin: + - ❌ Code 1, Circle + - ❌ Code 2, Vector line + - ❌ Code 4, Outline + - ❌ Code 5, Polygon + - ❌ Code 6, Moire + - ❌ Code 7, Thermal + - ❌ Code 20, Vector line + - ❌ Code 21, Center Line + - ❌ Code 22, Lower Left Line +- ⛔ Expressions. + - ⛔ Constants. + - ⛔ Variables. + - ⛔ Addition. + - ⛔ Subtraction. + - ⛔ Multiplication. + - ⛔ Division. + - ⛔ Unary + operator. + - ⛔ Negation. +- ⛔ Variable definitions. + +### Aperture blocks + +- 👽 Nested Line, aperture: + - ✅ circle, + - ✅ rectangle, + - ✅ obround, + - ✅ polygon, + - 👽 macro. +- 👽 Nested Arc, aperture: + - ✅ circle, + - ✅ rectangle, + - ✅ obround, + - ✅ polygon, + - 👽 macro. +- 👽 Nested Counter clockwise arc, aperture: + - ✅ circle, + - ✅ rectangle, + - ✅ obround, + - ✅ polygon, + - 👽 macro. +- 👽 Nested Flash: + - ✅ circle, + - ✅ rectangle, + - ✅ obround, + - ✅ polygon, + - 👽 macro. +- 👽 Nested regions (partial macro support). + +### Step and repeat + +- 👽 Nested Line, aperture: + - ✅ circle, + - ✅ rectangle, + - ✅ obround, + - ✅ polygon, + - 👽 macro. +- 👽 Nested Arc, aperture: + - ✅ circle, + - ✅ rectangle, + - ✅ obround, + - ✅ polygon, + - 👽 macro. +- 👽 Nested Counter clockwise arc, aperture: + - ✅ circle, + - ✅ rectangle, + - ✅ obround, + - ✅ polygon, + - 👽 macro. +- 👽 Nested Flash: + - ✅ circle, + - ✅ rectangle, + - ✅ obround, + - ✅ polygon, + - 👽 macro. +- 👽 Nested regions (partial macro support). +- 👽 Nested blocks (partial macro support). + +## Supported DEPRECATED Gerber features + +- ⛔ G54 - Select aperture. (Spec. 8.1.1) +- ⛔ G55 - Prepare for flash. (Spec. 8.1.1) +- ⛔ G70 - Set the 'Unit' to inch. (Spec. 8.1.1) +- ⛔ G71 - Set the 'Unit' to mm. (Spec. 8.1.1) +- ⛔ G90 - Set the 'Coordinate format' to 'Absolute notation'. (Spec. 8.1.1) +- ⛔ G91 - Set the 'Coordinate format' to 'Incremental notation'. (Spec. 8.1.1) + + - **Important**: _Incremental notation itself is not supported and is not planned + due to lack of test assets and expected complications during implementation._ + +- ⛔ M00 - Program stop. (Spec. 8.1.1) +- ⛔ M01 - Optional stop. (Spec. 8.1.1) +- ⛔ AS - Sets the 'Axes correspondence'. (Spec. 8.1.2) +- ⛔ IN - Sets the name of the file image. (Spec. 8.1.3) +- ⛔ IP - Sets the 'Image polarity'. (Spec. 8.1.4) +- ❌ IR - Sets 'Image rotation' graphics state parameter. (Spec. 8.1.5) +- ⛔ LN - Loads a name. (Spec. 8.1.6) +- ❌ MI - Sets 'Image mirroring' graphics state parameter (Spec. 8.1.7) +- ❌ OF - Sets 'Image offset' graphics state parameter (Spec. 8.1.8) +- ❌ SF - Sets 'Scale factor' graphics state parameter (Spec. 8.1.9) +- ✅ G74 - Sets single quadrant mode. (Spec. 8.1.10) +- 🚫 Format Specification (FS) Options. (Spec. 8.2.1) +- 🚫 Rectangular aperture hole in standard apertures. (Spec. 8.2.2) +- 👽 Draws and arcs wit rectangular apertures. (Spec. 8.2.3) +- ❌ Macro Primitive Code 2, Vector Line. (Spec 8.2.4) +- ❌ Macro Primitive Code 22, Lower Left Line. (Spec 8.2.5) +- ❌ Macro Primitive Code 6, Moiré. (Spec 8.2.6) +- ✅ Combining G01/G02/G03/G70/G71 and D01 in a single command. (Spec 8.3.1) +- ✅ Combining G01/G02/G03/G70/G71 and D02 in a single command. (Spec 8.3.1) +- ✅ Combining G01/G02/G03/G70/G71 and D03 in a single command. (Spec 8.3.1) +- ⛔ Coordinate Data without Operation Code. (Spec 8.3.2) +- ⛔ Style Variations in Command Codes. (Spec 8.3.3) +- ❌ Deprecated usage of SR. (Spec 8.3.4) +- ❌ Deprecated Attribute Values. (Spec 8.4) + + - **Important**: _Incremental notation itself is not supported and is not planned + due to lack of test assets and expected complications during implementation._ + +> PS. I had great time adding emoji to this table.