gerberdiff 0.29.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gerberdiff/__init__.py +86 -0
- gerberdiff/cli.py +527 -0
- gerberdiff/diff/__init__.py +0 -0
- gerberdiff/diff/diff_engine.py +409 -0
- gerberdiff/diff/layer_matcher.py +202 -0
- gerberdiff/export/__init__.py +0 -0
- gerberdiff/export/json_report.py +184 -0
- gerberdiff/export/png_export.py +92 -0
- gerberdiff/export/svg_export.py +187 -0
- gerberdiff/geometry/__init__.py +37 -0
- gerberdiff/geometry/attribute.py +203 -0
- gerberdiff/geometry/driver.py +227 -0
- gerberdiff/geometry/expand.py +232 -0
- gerberdiff/geometry/geom_diff.py +153 -0
- gerberdiff/geometry/layer_geometry.py +665 -0
- gerberdiff/geometry/macro_geom.py +215 -0
- gerberdiff/geometry/primitives.py +108 -0
- gerberdiff/geometry/types.py +85 -0
- gerberdiff/parse/__init__.py +0 -0
- gerberdiff/parse/arc_math.py +162 -0
- gerberdiff/parse/excellon_parser.py +338 -0
- gerberdiff/parse/gerber_parser.py +244 -0
- gerberdiff/parse/gerber_state.py +780 -0
- gerberdiff/parse/macro_parser.py +604 -0
- gerberdiff/parse/tokenizer.py +153 -0
- gerberdiff/py.typed +0 -0
- gerberdiff/render/__init__.py +0 -0
- gerberdiff/render/compiled_render.py +240 -0
- gerberdiff/render/draw_ops.py +205 -0
- gerberdiff/render/macro_renderer.py +343 -0
- gerberdiff/render/renderer.py +283 -0
- gerberdiff/render/viewport.py +85 -0
- gerberdiff/types.py +360 -0
- gerberdiff-0.29.0.dist-info/METADATA +105 -0
- gerberdiff-0.29.0.dist-info/RECORD +38 -0
- gerberdiff-0.29.0.dist-info/WHEEL +4 -0
- gerberdiff-0.29.0.dist-info/entry_points.txt +2 -0
- gerberdiff-0.29.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Generator
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TokenType(StrEnum):
|
|
9
|
+
G = "G"
|
|
10
|
+
D = "D"
|
|
11
|
+
M = "M"
|
|
12
|
+
X = "X"
|
|
13
|
+
Y = "Y"
|
|
14
|
+
I = "I" # noqa: E741
|
|
15
|
+
J = "J"
|
|
16
|
+
END_OF_BLOCK = "EOB"
|
|
17
|
+
EXTENDED = "EXTENDED"
|
|
18
|
+
COMMENT = "COMMENT"
|
|
19
|
+
EOF = "EOF"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Token:
|
|
24
|
+
type: TokenType
|
|
25
|
+
value: int | str | None # int for G/D/M/X/Y/I/J; str for EXTENDED/COMMENT; None for EOB/EOF
|
|
26
|
+
line: int
|
|
27
|
+
# raw digit string for X/Y/I/J -- needed by convert_coordinate for trailing-zero omission
|
|
28
|
+
raw: str | None = field(default=None)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def tokenize_gerber(content: str) -> Generator[Token, None, None]:
|
|
32
|
+
"""Yield tokens from a Gerber RS-274X file content string.
|
|
33
|
+
|
|
34
|
+
Handles:
|
|
35
|
+
- %...% extended command blocks (yields one EXTENDED token per *-terminated
|
|
36
|
+
command inside the block; the % delimiters are stripped)
|
|
37
|
+
- G04 comments (rest of block until * is the comment body)
|
|
38
|
+
- Coordinate words: X, Y, I, J with sign-aware integer values
|
|
39
|
+
- G, D, M codes with integer values
|
|
40
|
+
- Bare * end-of-block separators
|
|
41
|
+
- Whitespace and newlines outside extended blocks are silently skipped
|
|
42
|
+
"""
|
|
43
|
+
pos = 0
|
|
44
|
+
line = 1
|
|
45
|
+
length = len(content)
|
|
46
|
+
|
|
47
|
+
def peek() -> str:
|
|
48
|
+
return content[pos] if pos < length else ""
|
|
49
|
+
|
|
50
|
+
def advance() -> str:
|
|
51
|
+
nonlocal pos, line
|
|
52
|
+
ch = content[pos]
|
|
53
|
+
pos += 1
|
|
54
|
+
if ch == "\n":
|
|
55
|
+
line += 1
|
|
56
|
+
return ch
|
|
57
|
+
|
|
58
|
+
def read_digits() -> tuple[str, int]:
|
|
59
|
+
"""Consume an optional leading sign then digit characters.
|
|
60
|
+
|
|
61
|
+
Returns (raw_str, parsed_int). raw_str carries the sign (if any) and
|
|
62
|
+
all digit characters exactly as written -- needed for trailing-zero-omission
|
|
63
|
+
coordinate scaling. parsed_int is the signed integer value (0 on empty).
|
|
64
|
+
"""
|
|
65
|
+
raw = ""
|
|
66
|
+
if peek() in ("+", "-"):
|
|
67
|
+
raw += advance()
|
|
68
|
+
while pos < length and content[pos].isdigit():
|
|
69
|
+
raw += advance()
|
|
70
|
+
value = int(raw, 10) if raw and raw not in ("+", "-") else 0
|
|
71
|
+
return raw, value
|
|
72
|
+
|
|
73
|
+
while pos < length:
|
|
74
|
+
ch = peek()
|
|
75
|
+
|
|
76
|
+
# Skip whitespace and newlines (newlines also increment `line` via advance)
|
|
77
|
+
if ch in (" ", "\t", "\r", "\n"):
|
|
78
|
+
advance()
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
tok_line = line
|
|
82
|
+
|
|
83
|
+
if ch == "%":
|
|
84
|
+
advance() # consume opening %
|
|
85
|
+
buf = ""
|
|
86
|
+
while pos < length and peek() != "%":
|
|
87
|
+
c = peek()
|
|
88
|
+
if c == "*":
|
|
89
|
+
advance()
|
|
90
|
+
if buf:
|
|
91
|
+
yield Token(TokenType.EXTENDED, buf, tok_line)
|
|
92
|
+
buf = ""
|
|
93
|
+
elif c in ("\n", "\r"):
|
|
94
|
+
advance()
|
|
95
|
+
else:
|
|
96
|
+
buf += advance()
|
|
97
|
+
# Emit any trailing content before closing %
|
|
98
|
+
if buf:
|
|
99
|
+
yield Token(TokenType.EXTENDED, buf, tok_line)
|
|
100
|
+
if pos < length and peek() == "%":
|
|
101
|
+
advance() # consume closing %
|
|
102
|
+
|
|
103
|
+
elif ch == "G":
|
|
104
|
+
advance()
|
|
105
|
+
raw, num = read_digits()
|
|
106
|
+
if num == 4:
|
|
107
|
+
# G04: rest of block (until *) is a comment
|
|
108
|
+
comment = ""
|
|
109
|
+
while pos < length and peek() != "*":
|
|
110
|
+
comment += advance()
|
|
111
|
+
yield Token(TokenType.COMMENT, comment.strip(), tok_line)
|
|
112
|
+
else:
|
|
113
|
+
yield Token(TokenType.G, num, tok_line)
|
|
114
|
+
|
|
115
|
+
elif ch == "D":
|
|
116
|
+
advance()
|
|
117
|
+
_, num = read_digits()
|
|
118
|
+
yield Token(TokenType.D, num, tok_line)
|
|
119
|
+
|
|
120
|
+
elif ch == "M":
|
|
121
|
+
advance()
|
|
122
|
+
_, num = read_digits()
|
|
123
|
+
yield Token(TokenType.M, num, tok_line)
|
|
124
|
+
|
|
125
|
+
elif ch == "X":
|
|
126
|
+
advance()
|
|
127
|
+
raw, num = read_digits()
|
|
128
|
+
yield Token(TokenType.X, num, tok_line, raw)
|
|
129
|
+
|
|
130
|
+
elif ch == "Y":
|
|
131
|
+
advance()
|
|
132
|
+
raw, num = read_digits()
|
|
133
|
+
yield Token(TokenType.Y, num, tok_line, raw)
|
|
134
|
+
|
|
135
|
+
elif ch == "I":
|
|
136
|
+
advance()
|
|
137
|
+
raw, num = read_digits()
|
|
138
|
+
yield Token(TokenType.I, num, tok_line, raw)
|
|
139
|
+
|
|
140
|
+
elif ch == "J":
|
|
141
|
+
advance()
|
|
142
|
+
raw, num = read_digits()
|
|
143
|
+
yield Token(TokenType.J, num, tok_line, raw)
|
|
144
|
+
|
|
145
|
+
elif ch == "*":
|
|
146
|
+
advance()
|
|
147
|
+
yield Token(TokenType.END_OF_BLOCK, None, tok_line)
|
|
148
|
+
|
|
149
|
+
else:
|
|
150
|
+
# Unknown character -- skip silently (robustness for non-standard files)
|
|
151
|
+
advance()
|
|
152
|
+
|
|
153
|
+
yield Token(TokenType.EOF, None, line)
|
gerberdiff/py.typed
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Compile pass: group ParsedImage nets into batched draw groups.
|
|
2
|
+
|
|
3
|
+
The compile pass walks the flat nets list once and emits ``CompiledRender``
|
|
4
|
+
-- a list of ``CompiledLayer`` objects each holding ``CompiledGroup`` items
|
|
5
|
+
that the renderer can dispatch without re-inspecting every net.
|
|
6
|
+
|
|
7
|
+
Batching rules
|
|
8
|
+
--------------
|
|
9
|
+
* ``ApertureState.Flash`` + simple aperture (no hole) -> ``FlashBatch``
|
|
10
|
+
(grouped by aperture_code)
|
|
11
|
+
* ``ApertureState.Flash`` + holed aperture -> ``HoledFlash`` (one per net)
|
|
12
|
+
* ``ApertureState.Flash`` + ``MacroAperture`` -> ``MacroFlash`` (one per net)
|
|
13
|
+
* ``ApertureState.Flash`` + ``BlockAperture`` -> ``BlockFlash`` (one per net)
|
|
14
|
+
* ``ApertureState.On`` -> ``StrokeBatch`` (grouped by aperture_code)
|
|
15
|
+
* ``RegionFill`` -> ``RegionGroup``
|
|
16
|
+
* ``ApertureState.Off`` flushes any open stroke batch.
|
|
17
|
+
|
|
18
|
+
Layer boundaries flush all open batches.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from typing import Literal
|
|
25
|
+
|
|
26
|
+
from gerberdiff.types import (
|
|
27
|
+
ApertureState,
|
|
28
|
+
BlockAperture,
|
|
29
|
+
CircleAperture,
|
|
30
|
+
DrawOp,
|
|
31
|
+
MacroAperture,
|
|
32
|
+
MirrorState,
|
|
33
|
+
ObroundAperture,
|
|
34
|
+
ParsedImage,
|
|
35
|
+
Polarity,
|
|
36
|
+
PolygonAperture,
|
|
37
|
+
RectangleAperture,
|
|
38
|
+
RegionFill,
|
|
39
|
+
StepAndRepeat,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
# Group dataclasses
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class FlashBatch:
|
|
49
|
+
"""Simple flashes sharing one aperture (no hole, no macro/block)."""
|
|
50
|
+
|
|
51
|
+
kind: Literal["flash_batch"] = field(default="flash_batch", init=False)
|
|
52
|
+
aperture_code: int = 0
|
|
53
|
+
nets: list[DrawOp] = field(default_factory=list)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class StrokeBatch:
|
|
58
|
+
"""D01 strokes sharing one aperture."""
|
|
59
|
+
|
|
60
|
+
kind: Literal["stroke_batch"] = field(default="stroke_batch", init=False)
|
|
61
|
+
aperture_code: int = 0
|
|
62
|
+
nets: list[DrawOp] = field(default_factory=list)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class RegionGroup:
|
|
67
|
+
"""Contiguous region fill bounded by G36/G37 markers."""
|
|
68
|
+
|
|
69
|
+
kind: Literal["region_group"] = field(default="region_group", init=False)
|
|
70
|
+
nets: list[DrawOp] = field(default_factory=list)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class HoledFlash:
|
|
75
|
+
"""Single flash for an aperture with a punch-through hole."""
|
|
76
|
+
|
|
77
|
+
kind: Literal["holed_flash"] = field(default="holed_flash", init=False)
|
|
78
|
+
aperture_code: int = 0
|
|
79
|
+
net: DrawOp | None = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class MacroFlash:
|
|
84
|
+
"""Single flash for a macro aperture."""
|
|
85
|
+
|
|
86
|
+
kind: Literal["macro_flash"] = field(default="macro_flash", init=False)
|
|
87
|
+
aperture_code: int = 0
|
|
88
|
+
net: DrawOp | None = None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class BlockFlash:
|
|
93
|
+
"""Single flash for a block aperture (rendered in Phase 14)."""
|
|
94
|
+
|
|
95
|
+
kind: Literal["block_flash"] = field(default="block_flash", init=False)
|
|
96
|
+
aperture_code: int = 0
|
|
97
|
+
net: DrawOp | None = None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
CompiledGroup = FlashBatch | StrokeBatch | RegionGroup | HoledFlash | MacroFlash | BlockFlash
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
# Compiled layer / render containers
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass
|
|
109
|
+
class CompiledLayer:
|
|
110
|
+
"""One polarity layer with batched draw groups."""
|
|
111
|
+
|
|
112
|
+
layer_index: int = 0
|
|
113
|
+
step_and_repeat: StepAndRepeat = field(default_factory=StepAndRepeat)
|
|
114
|
+
polarity: Polarity = Polarity.Dark
|
|
115
|
+
mirror: MirrorState = MirrorState.None_
|
|
116
|
+
rotation: float = 0.0
|
|
117
|
+
scale: float = 1.0
|
|
118
|
+
groups: list[CompiledGroup] = field(default_factory=list)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass
|
|
122
|
+
class CompiledRender:
|
|
123
|
+
"""Output of the compile pass, consumed by the renderer."""
|
|
124
|
+
|
|
125
|
+
layers: list[CompiledLayer] = field(default_factory=list)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
# Compile pass
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def compile_render(parsed_image: ParsedImage) -> CompiledRender:
|
|
134
|
+
"""Walk parsed_image.nets once and produce a ``CompiledRender``.
|
|
135
|
+
|
|
136
|
+
The resulting structure is layer-ordered and batch-merged so the renderer
|
|
137
|
+
can operate without any aperture-classification logic.
|
|
138
|
+
"""
|
|
139
|
+
compiled = CompiledRender()
|
|
140
|
+
|
|
141
|
+
# Build a CompiledLayer for every LayerState in the parsed image.
|
|
142
|
+
layer_map: dict[int, CompiledLayer] = {}
|
|
143
|
+
for i, ls in enumerate(parsed_image.layers):
|
|
144
|
+
cl = CompiledLayer(
|
|
145
|
+
layer_index=i,
|
|
146
|
+
step_and_repeat=ls.step_and_repeat,
|
|
147
|
+
polarity=ls.polarity,
|
|
148
|
+
mirror=ls.mirror,
|
|
149
|
+
rotation=ls.rotation,
|
|
150
|
+
scale=ls.scale,
|
|
151
|
+
)
|
|
152
|
+
layer_map[i] = cl
|
|
153
|
+
compiled.layers.append(cl)
|
|
154
|
+
|
|
155
|
+
# Mutable batching state (closed over by the flush helpers below).
|
|
156
|
+
pending_flash: FlashBatch | None = None
|
|
157
|
+
pending_stroke: StrokeBatch | None = None
|
|
158
|
+
# The layer that owns the two pending batches.
|
|
159
|
+
batch_layer: CompiledLayer | None = None
|
|
160
|
+
|
|
161
|
+
def _flush_flash() -> None:
|
|
162
|
+
nonlocal pending_flash
|
|
163
|
+
if pending_flash is not None and batch_layer is not None:
|
|
164
|
+
batch_layer.groups.append(pending_flash)
|
|
165
|
+
pending_flash = None
|
|
166
|
+
|
|
167
|
+
def _flush_stroke() -> None:
|
|
168
|
+
nonlocal pending_stroke
|
|
169
|
+
if pending_stroke is not None and batch_layer is not None:
|
|
170
|
+
batch_layer.groups.append(pending_stroke)
|
|
171
|
+
pending_stroke = None
|
|
172
|
+
|
|
173
|
+
def _switch_layer(new_layer: CompiledLayer) -> None:
|
|
174
|
+
nonlocal batch_layer
|
|
175
|
+
_flush_flash()
|
|
176
|
+
_flush_stroke()
|
|
177
|
+
batch_layer = new_layer
|
|
178
|
+
|
|
179
|
+
for item in parsed_image.draw_ops:
|
|
180
|
+
if isinstance(item, RegionFill):
|
|
181
|
+
layer = layer_map.get(item.layer_index)
|
|
182
|
+
if layer is not None:
|
|
183
|
+
_flush_flash()
|
|
184
|
+
_flush_stroke()
|
|
185
|
+
if item.segments:
|
|
186
|
+
layer.groups.append(RegionGroup(nets=list(item.segments)))
|
|
187
|
+
continue
|
|
188
|
+
|
|
189
|
+
net = item
|
|
190
|
+
layer = layer_map.get(net.layer_index)
|
|
191
|
+
if layer is None:
|
|
192
|
+
continue
|
|
193
|
+
|
|
194
|
+
# Flush open batches whenever the active layer changes.
|
|
195
|
+
if batch_layer is not layer:
|
|
196
|
+
_switch_layer(layer)
|
|
197
|
+
|
|
198
|
+
# ---- Flash (D03) ----
|
|
199
|
+
if net.aperture_state == ApertureState.Flash:
|
|
200
|
+
_flush_stroke()
|
|
201
|
+
ap = parsed_image.apertures.get(net.aperture_index)
|
|
202
|
+
if isinstance(ap, MacroAperture):
|
|
203
|
+
_flush_flash()
|
|
204
|
+
layer.groups.append(MacroFlash(aperture_code=net.aperture_index, net=net))
|
|
205
|
+
elif isinstance(ap, BlockAperture):
|
|
206
|
+
_flush_flash()
|
|
207
|
+
layer.groups.append(BlockFlash(aperture_code=net.aperture_index, net=net))
|
|
208
|
+
elif (
|
|
209
|
+
isinstance(
|
|
210
|
+
ap, (CircleAperture, RectangleAperture, ObroundAperture, PolygonAperture)
|
|
211
|
+
)
|
|
212
|
+
and ap.hole_diameter is not None
|
|
213
|
+
):
|
|
214
|
+
_flush_flash()
|
|
215
|
+
layer.groups.append(HoledFlash(aperture_code=net.aperture_index, net=net))
|
|
216
|
+
else:
|
|
217
|
+
# Simple flash -- batch by aperture_code.
|
|
218
|
+
if pending_flash is None or pending_flash.aperture_code != net.aperture_index:
|
|
219
|
+
_flush_flash()
|
|
220
|
+
pending_flash = FlashBatch(aperture_code=net.aperture_index)
|
|
221
|
+
pending_flash.nets.append(net)
|
|
222
|
+
|
|
223
|
+
# ---- On (D01 draw) ----
|
|
224
|
+
elif net.aperture_state == ApertureState.On:
|
|
225
|
+
_flush_flash()
|
|
226
|
+
if pending_stroke is None or pending_stroke.aperture_code != net.aperture_index:
|
|
227
|
+
_flush_stroke()
|
|
228
|
+
pending_stroke = StrokeBatch(aperture_code=net.aperture_index)
|
|
229
|
+
pending_stroke.nets.append(net)
|
|
230
|
+
|
|
231
|
+
# ---- Off (D02 move) -- just flush open batches ----
|
|
232
|
+
elif net.aperture_state == ApertureState.Off:
|
|
233
|
+
_flush_flash()
|
|
234
|
+
_flush_stroke()
|
|
235
|
+
|
|
236
|
+
# Final flush.
|
|
237
|
+
_flush_flash()
|
|
238
|
+
_flush_stroke()
|
|
239
|
+
|
|
240
|
+
return compiled
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
|
|
5
|
+
import cairocffi as cairo
|
|
6
|
+
|
|
7
|
+
from gerberdiff.types import (
|
|
8
|
+
Aperture,
|
|
9
|
+
ApertureState,
|
|
10
|
+
ArcSegment,
|
|
11
|
+
BlockAperture,
|
|
12
|
+
CircleAperture,
|
|
13
|
+
DrawOp,
|
|
14
|
+
InterpolationMode,
|
|
15
|
+
MacroAperture,
|
|
16
|
+
ObroundAperture,
|
|
17
|
+
PolygonAperture,
|
|
18
|
+
RectangleAperture,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# Arc
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def draw_arc_path(ctx: cairo.Context, arc: ArcSegment, clockwise: bool) -> None:
|
|
27
|
+
"""Add an arc to the current path without stroking or filling.
|
|
28
|
+
|
|
29
|
+
Gerber clockwise arcs map to cairo's ``arc_negative`` because cairo
|
|
30
|
+
angles increase counter-clockwise.
|
|
31
|
+
"""
|
|
32
|
+
start_rad = math.radians(arc.start_angle_deg)
|
|
33
|
+
end_rad = math.radians(arc.end_angle_deg)
|
|
34
|
+
if clockwise:
|
|
35
|
+
ctx.arc_negative(arc.center_x, arc.center_y, arc.radius, start_rad, end_rad)
|
|
36
|
+
else:
|
|
37
|
+
ctx.arc(arc.center_x, arc.center_y, arc.radius, start_rad, end_rad)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
# Region segments
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def draw_net_segment_in_region(ctx: cairo.Context, net: DrawOp) -> None:
|
|
46
|
+
"""Add one net to the current region path (inside a G36..G37 block).
|
|
47
|
+
|
|
48
|
+
``ApertureState.Off`` starts a new contour; arcs use :func:`draw_arc_path`;
|
|
49
|
+
linear moves become ``line_to``.
|
|
50
|
+
"""
|
|
51
|
+
if net.aperture_state == ApertureState.Off:
|
|
52
|
+
ctx.move_to(net.stop_x, net.stop_y)
|
|
53
|
+
elif net.arc_segment is not None:
|
|
54
|
+
draw_arc_path(
|
|
55
|
+
ctx,
|
|
56
|
+
net.arc_segment,
|
|
57
|
+
net.interpolation == InterpolationMode.ClockwiseCircular,
|
|
58
|
+
)
|
|
59
|
+
else:
|
|
60
|
+
ctx.line_to(net.stop_x, net.stop_y)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
# Stroke
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def draw_net_as_stroke(ctx: cairo.Context, net: DrawOp, aperture: Aperture | None) -> None:
|
|
69
|
+
"""Stroke a single ``On``-state net using the aperture's line width."""
|
|
70
|
+
if aperture is None:
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
match aperture:
|
|
74
|
+
case CircleAperture():
|
|
75
|
+
ctx.set_line_width(aperture.diameter)
|
|
76
|
+
ctx.set_line_cap(cairo.LINE_CAP_ROUND)
|
|
77
|
+
case RectangleAperture():
|
|
78
|
+
# NOTE: max(w, h) is used as the stroke width so that the wide
|
|
79
|
+
# dimension is used for traces where the long axis aligns with the
|
|
80
|
+
# stroke direction (the common case). A fully correct Minkowski-sum
|
|
81
|
+
# stroke requires geometry-aware rendering; see
|
|
82
|
+
# docs/geometry-diff.md.
|
|
83
|
+
ctx.set_line_width(max(aperture.width, aperture.height))
|
|
84
|
+
ctx.set_line_cap(cairo.LINE_CAP_SQUARE)
|
|
85
|
+
case ObroundAperture():
|
|
86
|
+
# NOTE: same approximation as RectangleAperture above.
|
|
87
|
+
ctx.set_line_width(max(aperture.width, aperture.height))
|
|
88
|
+
ctx.set_line_cap(cairo.LINE_CAP_ROUND)
|
|
89
|
+
case PolygonAperture():
|
|
90
|
+
ctx.set_line_width(aperture.outer_diameter)
|
|
91
|
+
ctx.set_line_cap(cairo.LINE_CAP_ROUND)
|
|
92
|
+
case _:
|
|
93
|
+
ctx.set_line_width(0.001)
|
|
94
|
+
ctx.set_line_cap(cairo.LINE_CAP_ROUND)
|
|
95
|
+
|
|
96
|
+
ctx.new_path()
|
|
97
|
+
ctx.move_to(net.start_x, net.start_y)
|
|
98
|
+
if net.arc_segment is not None:
|
|
99
|
+
draw_arc_path(
|
|
100
|
+
ctx,
|
|
101
|
+
net.arc_segment,
|
|
102
|
+
net.interpolation == InterpolationMode.ClockwiseCircular,
|
|
103
|
+
)
|
|
104
|
+
else:
|
|
105
|
+
ctx.line_to(net.stop_x, net.stop_y)
|
|
106
|
+
ctx.stroke()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
# Flash
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def draw_flash(ctx: cairo.Context, net: DrawOp, aperture: Aperture | None) -> None:
|
|
115
|
+
"""Draw a flash (D03) at ``net.stop_x``, ``net.stop_y``.
|
|
116
|
+
|
|
117
|
+
Macro and Block apertures are handled by the renderer directly
|
|
118
|
+
(``macro_renderer`` / block render pass) so they are silently skipped here.
|
|
119
|
+
"""
|
|
120
|
+
if aperture is None:
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
x, y = net.stop_x, net.stop_y
|
|
124
|
+
match aperture:
|
|
125
|
+
case CircleAperture():
|
|
126
|
+
_draw_circle_flash(ctx, x, y, aperture)
|
|
127
|
+
case RectangleAperture():
|
|
128
|
+
_draw_rectangle_flash(ctx, x, y, aperture)
|
|
129
|
+
case ObroundAperture():
|
|
130
|
+
_draw_obround_flash(ctx, x, y, aperture)
|
|
131
|
+
case PolygonAperture():
|
|
132
|
+
_draw_polygon_flash(ctx, x, y, aperture)
|
|
133
|
+
case MacroAperture() | BlockAperture():
|
|
134
|
+
pass # deferred to renderer
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
# Private helpers -- each takes a concrete aperture type for mypy narrowing
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _draw_hole(ctx: cairo.Context, x: float, y: float, hole_diameter: float) -> None:
|
|
143
|
+
"""Punch a circular hole using DEST_OUT compositing."""
|
|
144
|
+
ctx.save()
|
|
145
|
+
ctx.set_operator(cairo.OPERATOR_DEST_OUT)
|
|
146
|
+
ctx.new_path()
|
|
147
|
+
ctx.arc(x, y, hole_diameter / 2.0, 0.0, 2.0 * math.pi)
|
|
148
|
+
ctx.fill()
|
|
149
|
+
ctx.restore()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _draw_circle_flash(ctx: cairo.Context, x: float, y: float, ap: CircleAperture) -> None:
|
|
153
|
+
ctx.new_path()
|
|
154
|
+
ctx.arc(x, y, ap.diameter / 2.0, 0.0, 2.0 * math.pi)
|
|
155
|
+
ctx.fill()
|
|
156
|
+
if ap.hole_diameter is not None:
|
|
157
|
+
_draw_hole(ctx, x, y, ap.hole_diameter)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _draw_rectangle_flash(ctx: cairo.Context, x: float, y: float, ap: RectangleAperture) -> None:
|
|
161
|
+
w2, h2 = ap.width / 2.0, ap.height / 2.0
|
|
162
|
+
ctx.new_path()
|
|
163
|
+
ctx.rectangle(x - w2, y - h2, ap.width, ap.height)
|
|
164
|
+
ctx.fill()
|
|
165
|
+
if ap.hole_diameter is not None:
|
|
166
|
+
_draw_hole(ctx, x, y, ap.hole_diameter)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _draw_obround_flash(ctx: cairo.Context, x: float, y: float, ap: ObroundAperture) -> None:
|
|
170
|
+
"""Obround: rectangle with semicircular ends on the short axis."""
|
|
171
|
+
w, h = ap.width, ap.height
|
|
172
|
+
r = min(w, h) / 2.0
|
|
173
|
+
w2, h2 = w / 2.0, h / 2.0
|
|
174
|
+
ctx.new_path()
|
|
175
|
+
if w >= h:
|
|
176
|
+
# Horizontal obround
|
|
177
|
+
ctx.arc(x + w2 - r, y, r, -math.pi / 2.0, math.pi / 2.0)
|
|
178
|
+
ctx.arc(x - w2 + r, y, r, math.pi / 2.0, 3.0 * math.pi / 2.0)
|
|
179
|
+
else:
|
|
180
|
+
# Vertical obround
|
|
181
|
+
ctx.arc(x, y + h2 - r, r, 0.0, math.pi)
|
|
182
|
+
ctx.arc(x, y - h2 + r, r, math.pi, 2.0 * math.pi)
|
|
183
|
+
ctx.close_path()
|
|
184
|
+
ctx.fill()
|
|
185
|
+
if ap.hole_diameter is not None:
|
|
186
|
+
_draw_hole(ctx, x, y, ap.hole_diameter)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _draw_polygon_flash(ctx: cairo.Context, x: float, y: float, ap: PolygonAperture) -> None:
|
|
190
|
+
r = ap.outer_diameter / 2.0
|
|
191
|
+
n = ap.num_vertices
|
|
192
|
+
rot = math.radians(ap.rotation)
|
|
193
|
+
ctx.new_path()
|
|
194
|
+
for i in range(n):
|
|
195
|
+
angle = rot + 2.0 * math.pi * i / n
|
|
196
|
+
px = x + r * math.cos(angle)
|
|
197
|
+
py = y + r * math.sin(angle)
|
|
198
|
+
if i == 0:
|
|
199
|
+
ctx.move_to(px, py)
|
|
200
|
+
else:
|
|
201
|
+
ctx.line_to(px, py)
|
|
202
|
+
ctx.close_path()
|
|
203
|
+
ctx.fill()
|
|
204
|
+
if ap.hole_diameter is not None:
|
|
205
|
+
_draw_hole(ctx, x, y, ap.hole_diameter)
|