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 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/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. 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..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 @@ -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..b84b2083 100644 --- a/src/pygerber/gerberx3/parser2/apertures2/block2.py +++ b/src/pygerber/gerberx3/parser2/apertures2/block2.py @@ -2,13 +2,29 @@ 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.""" + # Block apertures are resolved into series of commands at parser level. + raise NotImplementedError + + 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..d2a65185 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,14 @@ 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) + + def get_stroke_width(self) -> Offset: + """Get stroke width of command.""" + return self.outer_diameter diff --git a/src/pygerber/gerberx3/parser2/apertures2/rectangle2.py b/src/pygerber/gerberx3/parser2/apertures2/rectangle2.py index b30f20ef..a05c4579 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,14 @@ 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_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/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..0c1155c0 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, @@ -42,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.""" @@ -64,8 +71,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/context2.py b/src/pygerber/gerberx3/parser2/context2.py index a0983875..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.""" @@ -69,6 +73,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 +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] = { + 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.""" @@ -112,6 +123,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 = ( @@ -432,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.""" @@ -443,13 +468,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/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/parser2/parser2hooks.py b/src/pygerber/gerberx3/parser2/parser2hooks.py index 5b021b31..6dddede7 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): @@ -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(), ), ), @@ -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/__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..191c88c1 --- /dev/null +++ b/src/pygerber/gerberx3/renderer2/abstract.py @@ -0,0 +1,131 @@ +"""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.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_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_buffer(self, command: BufferCommand2) -> None: + """Render buffer command, performing no writes.""" + + 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/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 new file mode 100644 index 00000000..4c70aa1b --- /dev/null +++ b/src/pygerber/gerberx3/renderer2/svg.py @@ -0,0 +1,731 @@ +"""Module contains implementation of Gerber rendering backend outputting SVG files.""" +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.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.renderer2.errors2 import DRAWSVGNotAvailableError +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, + ) -> None: + hooks = SvgRenderer2Hooks() if hooks is None else hooks + 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 + is_region: bool = False + flip_y: bool = True + + +class SvgRenderer2Hooks(Renderer2HooksABC): + """Rendering backend hooks used to render SVG images.""" + + renderer: SvgRenderer2 + + def __init__( + self, + color_scheme: ColorScheme = ColorScheme.DEBUG_1, + scale: Decimal = Decimal("1"), + *, + 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 + + def init( + self, + renderer: Renderer2, + command_buffer: ReadonlyCommandBuffer2, + ) -> None: + """Initialize rendering hooks.""" + if not isinstance(renderer, SvgRenderer2): + raise NotImplementedError + + 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, + flip_y=self.flip_y, + ), + ] + self.apertures: dict[str, drawsvg.Group] = {} + + def push_render_frame( + self, + cmd: ReadonlyCommandBuffer2, + *, + normalize_origin_to_0_0: bool, + flip_y: bool, + ) -> 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, + flip_y=flip_y, + ), + ) + + def pop_render_frame(self) -> SvgRenderingFrame: + """Pop segment render frame.""" + if len(self.rendering_stack) <= 1: + raise RuntimeError + return self.rendering_stack.pop() + + @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.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: + self.frame.mask = self._make_mask(self.frame.bounding_box) + new_layer = drawsvg.Group(mask=self.frame.mask) + else: + new_layer = drawsvg.Group() + + new_layer.append(self.frame.layer) + + self.frame.layer = new_layer + + def convert_x(self, x: Offset) -> Decimal: + """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: + origin_offset_x = Decimal(0) + + 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 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 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 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.""" + return diameter.as_millimeters() * self.scale + + def get_color(self, polarity: Polarity) -> str: + """Get color for specified polarity.""" + if self.frame.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_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( + 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, + close=True, + ) + 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.""" + 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, + Flash2( + transform=command.transform, + attributes=command.attributes, + aperture=command.aperture, + 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 * ( + 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.""" + color = self.get_color(command.transform.polarity) + aperture_group = self.get_aperture(id(aperture), color) + + if aperture_group is None: + mask = self._make_mask(aperture.get_bounding_box(), aperture.hole_diameter) + aperture_group = drawsvg.Group(mask=mask) + aperture_group.append( + drawsvg.Circle( + cx=0, + cy=0, + r=self.convert_size(aperture.diameter) / Decimal("2.0"), + fill=color, + ), + ) + self.set_aperture(id(aperture), color, aperture_group) + + self.get_layer(command.transform.polarity).append( + drawsvg.Use( + aperture_group, + x=self.convert_x(command.flash_point.x), + y=self.convert_y(command.flash_point.y), + ), + ) + + 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.""" + + 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) + + if aperture_group is None: + mask = self._make_mask(aperture.get_bounding_box(), aperture.hole_diameter) + aperture_group = drawsvg.Group(mask=mask) + aperture_group.append( + drawsvg.Rectangle( + -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, + ), + ) + self.set_aperture(id(aperture), color, aperture_group) + + 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_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) + + if aperture_group is None: + 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( + -self.convert_size(aperture.x_size) / 2, + -self.convert_size(aperture.y_size) / 2, + 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, + self.convert_x(command.flash_point.x), + self.convert_y(command.flash_point.y), + ), + ) + + 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) + + if aperture_group is None: + 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 + inner_angle = Decimal("360") / Decimal(number_of_vertices) + + 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( + self.convert_size(rotated_radius_vector.x), + self.convert_size(rotated_radius_vector.y), + ) + + 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( + self.convert_size(rotated_radius_vector.x), + self.convert_size(rotated_radius_vector.y), + ) + + 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, + self.convert_x(command.flash_point.x), + self.convert_y(command.flash_point.y), + ), + ) + + 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, + flip_y=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, + x=self.convert_x(command.flash_point.x), + y=self.convert_y(command.flash_point.y), + ), + ) + + 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.""" + if len(command.command_buffer) == 0: + return + + self.frame.is_region = True + + 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_line_to_path(cmd, region) + elif isinstance(cmd, Arc2): + self.render_arc_to_path(cmd, region) + elif isinstance(cmd, CCArc2): + self.render_cc_arc_to_path(cmd, region) + else: + raise NotImplementedError + + region.Z() + self.get_layer(command.transform.polarity).append(region) + + self.frame.is_region = False + + def render_line_to_path(self, command: Line2, path: drawsvg.Path) -> None: + """Render line region boundary.""" + path.L( + self.convert_x(command.end_point.x), + self.convert_y(command.end_point.y), + ) + + def render_arc_to_path(self, command: Arc2, path: drawsvg.Path) -> None: + """Render line region boundary.""" + 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_cc_arc_to_path(self, command: CCArc2, path: drawsvg.Path) -> None: + """Render line region boundary.""" + 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.""" + return SvgImageRef(self.drawing) + + def finalize(self) -> None: + """Finalize rendering.""" + 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, + ) + 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/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 00000000..1966846e Binary files /dev/null and b/test/assets/gerberx3/basic/sample-ab/source.png differ diff --git a/test/assets/gerberx3/basic/sample-arc/source_clockwise_2_cc.grb b/test/assets/gerberx3/basic/sample-arc/source_clockwise_2_cc.grb new file mode 100644 index 00000000..df659af5 --- /dev/null +++ b/test/assets/gerberx3/basic/sample-arc/source_clockwise_2_cc.grb @@ -0,0 +1,8 @@ +%FSLAX26Y26*% +%MOMM*% +%ADD100C,0.5*% +D100* +G75* +G03* +X2000000Y-2000000I2000000J0D01* +M02* diff --git a/test/assets/gerberx3/basic/sample-arc/source_clockwise_3_cc.grb b/test/assets/gerberx3/basic/sample-arc/source_clockwise_3_cc.grb new file mode 100644 index 00000000..343bb8a2 --- /dev/null +++ b/test/assets/gerberx3/basic/sample-arc/source_clockwise_3_cc.grb @@ -0,0 +1,8 @@ +%FSLAX26Y26*% +%MOMM*% +%ADD100C,0.5*% +D100* +G75* +G03* +X0Y0I2000000J0D01* +M02* 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/conftest.py b/test/conftest.py index b15bb1a1..267fb063 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -3,8 +3,10 @@ from __future__ import annotations import datetime +import json import logging from pathlib import Path +from typing import Any import pytest import tzlocal @@ -33,6 +35,19 @@ def load_asset(self, src: str) -> 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_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: 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 00000000..87867a56 Binary files /dev/null and b/test/gerberx3/test_rasterized_2d/reference/basic/sample-ab/source/image.png differ 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 00000000..f018bfaf Binary files /dev/null and b/test/gerberx3/test_rasterized_2d/reference/basic/sample-arc/source_clockwise_2_cc/image.png differ diff --git a/test/gerberx3/test_rasterized_2d/reference/basic/sample-arc/source_clockwise_3_cc/image.png b/test/gerberx3/test_rasterized_2d/reference/basic/sample-arc/source_clockwise_3_cc/image.png new file mode 100644 index 00000000..3c30d680 Binary files /dev/null and b/test/gerberx3/test_rasterized_2d/reference/basic/sample-arc/source_clockwise_3_cc/image.png differ 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", +)