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,184 @@
|
|
|
1
|
+
"""Export diff results as a versioned JSON report.
|
|
2
|
+
|
|
3
|
+
Schemas
|
|
4
|
+
-------
|
|
5
|
+
- Version 1: raster pixel diff (``DiffResult``).
|
|
6
|
+
- Version 2: geometry diff (``GeometryDiffResult``), ``"mode": "geometry"``.
|
|
7
|
+
|
|
8
|
+
See ``docs/schema.md`` for canonical documentation.
|
|
9
|
+
|
|
10
|
+
Coordinate values are in **inches**; geometry areas are in **mm^2** and
|
|
11
|
+
displacements in **mm** (see ``gerberdiff/geometry/types.py``).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from gerberdiff.geometry.types import GeometryChange, GeometryDiffResult, LayerGeometryDiff
|
|
21
|
+
from gerberdiff.types import DiffResult, LayerDiffResult, LayerStatus, Region
|
|
22
|
+
|
|
23
|
+
_SCHEMA_VERSION = 1
|
|
24
|
+
_GEOMETRY_SCHEMA_VERSION = 2
|
|
25
|
+
_GENERATOR = "gerberdiff"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# Public API
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def build_report(diff_result: DiffResult) -> dict[str, Any]:
|
|
34
|
+
"""Serialize *diff_result* to a JSON-compatible dictionary.
|
|
35
|
+
|
|
36
|
+
The returned dict can be passed directly to ``json.dumps`` / ``json.dump``.
|
|
37
|
+
"""
|
|
38
|
+
changed_layers = sum(
|
|
39
|
+
1
|
|
40
|
+
for lr in diff_result.layers
|
|
41
|
+
if lr.changed_pixel_count > 0 or lr.status != LayerStatus.Matched
|
|
42
|
+
)
|
|
43
|
+
total_regions = sum(len(lr.regions) for lr in diff_result.layers)
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
"version": _SCHEMA_VERSION,
|
|
47
|
+
"generator": _GENERATOR,
|
|
48
|
+
"summary": {
|
|
49
|
+
"changed_layers": changed_layers,
|
|
50
|
+
"total_regions": total_regions,
|
|
51
|
+
"has_changes": diff_result.has_changes,
|
|
52
|
+
},
|
|
53
|
+
"layers": [_serialize_layer(lr) for lr in diff_result.layers],
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def write_report(diff_result: DiffResult, output_path: Path, overwrite: bool = False) -> None:
|
|
58
|
+
"""Write the JSON report to *output_path*.
|
|
59
|
+
|
|
60
|
+
Raises
|
|
61
|
+
------
|
|
62
|
+
FileExistsError
|
|
63
|
+
If *output_path* already exists and *overwrite* is ``False``.
|
|
64
|
+
"""
|
|
65
|
+
if output_path.exists() and not overwrite:
|
|
66
|
+
raise FileExistsError(f"output file already exists: {output_path}")
|
|
67
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
report = build_report(diff_result)
|
|
69
|
+
output_path.write_text(json.dumps(report, indent=2), encoding="utf-8")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
# Geometry report (schema version 2)
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def build_geometry_report(
|
|
78
|
+
result: GeometryDiffResult,
|
|
79
|
+
tolerances: dict[str, float] | None = None,
|
|
80
|
+
) -> dict[str, Any]:
|
|
81
|
+
"""Serialize a geometry diff result to a JSON-compatible dictionary.
|
|
82
|
+
|
|
83
|
+
*tolerances* (optional) records the classification thresholds used, for
|
|
84
|
+
reproducibility (keys: ``move_tol_mm``, ``gate_radius_mm``, ``area_tol``,
|
|
85
|
+
``dust_area_mm2``).
|
|
86
|
+
"""
|
|
87
|
+
report: dict[str, Any] = {
|
|
88
|
+
"version": _GEOMETRY_SCHEMA_VERSION,
|
|
89
|
+
"generator": _GENERATOR,
|
|
90
|
+
"mode": "geometry",
|
|
91
|
+
"summary": {
|
|
92
|
+
"changed_layers": sum(1 for layer in result.layers if layer.has_changes),
|
|
93
|
+
"total_changes": sum(len(layer.changes) for layer in result.layers),
|
|
94
|
+
"has_changes": result.has_changes,
|
|
95
|
+
},
|
|
96
|
+
"layers": [_serialize_geometry_layer(layer) for layer in result.layers],
|
|
97
|
+
}
|
|
98
|
+
if tolerances is not None:
|
|
99
|
+
report["tolerances"] = tolerances
|
|
100
|
+
return report
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def write_geometry_report(
|
|
104
|
+
result: GeometryDiffResult,
|
|
105
|
+
output_path: Path,
|
|
106
|
+
tolerances: dict[str, float] | None = None,
|
|
107
|
+
overwrite: bool = False,
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Write the geometry JSON report to *output_path*.
|
|
110
|
+
|
|
111
|
+
Raises
|
|
112
|
+
------
|
|
113
|
+
FileExistsError
|
|
114
|
+
If *output_path* already exists and *overwrite* is ``False``.
|
|
115
|
+
"""
|
|
116
|
+
if output_path.exists() and not overwrite:
|
|
117
|
+
raise FileExistsError(f"output file already exists: {output_path}")
|
|
118
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
report = build_geometry_report(result, tolerances)
|
|
120
|
+
output_path.write_text(json.dumps(report, indent=2), encoding="utf-8")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
# Internal helpers
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _serialize_geometry_layer(layer: LayerGeometryDiff) -> dict[str, Any]:
|
|
129
|
+
return {
|
|
130
|
+
"name": layer.name,
|
|
131
|
+
"status": layer.status,
|
|
132
|
+
"layer_type": layer.layer_type,
|
|
133
|
+
"unchanged_count": layer.unchanged_count,
|
|
134
|
+
"added_area_mm2": round(layer.added_area_mm2, 6),
|
|
135
|
+
"removed_area_mm2": round(layer.removed_area_mm2, 6),
|
|
136
|
+
"counts": {
|
|
137
|
+
"added": layer.count("added"),
|
|
138
|
+
"removed": layer.count("removed"),
|
|
139
|
+
"moved": layer.count("moved"),
|
|
140
|
+
"resized": layer.count("resized"),
|
|
141
|
+
},
|
|
142
|
+
"changes": [_serialize_geometry_change(c) for c in layer.changes],
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _serialize_geometry_change(c: GeometryChange) -> dict[str, Any]:
|
|
147
|
+
return {
|
|
148
|
+
"kind": c.kind,
|
|
149
|
+
"op_kind": c.op_kind,
|
|
150
|
+
"centroid_x": c.centroid_x,
|
|
151
|
+
"centroid_y": c.centroid_y,
|
|
152
|
+
"area_mm2": round(c.area_mm2, 6),
|
|
153
|
+
"dx_mm": round(c.dx_mm, 6) if c.dx_mm is not None else None,
|
|
154
|
+
"dy_mm": round(c.dy_mm, 6) if c.dy_mm is not None else None,
|
|
155
|
+
"net": c.net_name,
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _serialize_layer(lr: LayerDiffResult) -> dict[str, Any]:
|
|
160
|
+
return {
|
|
161
|
+
"name": lr.name,
|
|
162
|
+
"status": lr.status,
|
|
163
|
+
"layer_type": lr.layer_type,
|
|
164
|
+
"changed_pixel_count": lr.changed_pixel_count,
|
|
165
|
+
"total_pixel_count": lr.total_pixel_count,
|
|
166
|
+
"changed_fraction": round(lr.changed_fraction, 8),
|
|
167
|
+
"regions": [_serialize_region(r) for r in lr.regions],
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _serialize_region(r: Region) -> dict[str, Any]:
|
|
172
|
+
bb = r.bounding_box
|
|
173
|
+
return {
|
|
174
|
+
"id": r.id,
|
|
175
|
+
"centroid_x": r.centroid_x,
|
|
176
|
+
"centroid_y": r.centroid_y,
|
|
177
|
+
"bbox": {
|
|
178
|
+
"min_x": bb.min_x,
|
|
179
|
+
"min_y": bb.min_y,
|
|
180
|
+
"max_x": bb.max_x,
|
|
181
|
+
"max_y": bb.max_y,
|
|
182
|
+
},
|
|
183
|
+
"pixel_count": r.pixel_count,
|
|
184
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Export a diff overlay PNG using cairocffi surfaces and numpy.
|
|
2
|
+
|
|
3
|
+
Colour scheme
|
|
4
|
+
-------------
|
|
5
|
+
- **Red:** pixels present in image A but not B (geometry removed)
|
|
6
|
+
- **Green:** pixels present in image B but not A (geometry added)
|
|
7
|
+
- **Grey:** unchanged geometry from image A (only when *show_common=True*)
|
|
8
|
+
- **Black:** background (fully transparent in ARGB32)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import cairocffi as cairo
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# Public API
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def build_overlay_png(
|
|
24
|
+
arr_a: np.ndarray,
|
|
25
|
+
arr_b: np.ndarray,
|
|
26
|
+
xor: np.ndarray,
|
|
27
|
+
output_path: Path,
|
|
28
|
+
show_common: bool = False,
|
|
29
|
+
overwrite: bool = False,
|
|
30
|
+
) -> None:
|
|
31
|
+
"""Composite a red/green diff overlay and write it to *output_path*.
|
|
32
|
+
|
|
33
|
+
Parameters
|
|
34
|
+
----------
|
|
35
|
+
arr_a:
|
|
36
|
+
Rendered image A, shape ``(H, W, 4)`` uint8 BGRA (Cairo ARGB32 LE).
|
|
37
|
+
arr_b:
|
|
38
|
+
Rendered image B, shape ``(H, W, 4)`` uint8.
|
|
39
|
+
xor:
|
|
40
|
+
Channel-wise XOR of *arr_a* and *arr_b* (from ``SingleLayerDiff.xor``).
|
|
41
|
+
output_path:
|
|
42
|
+
Destination PNG file path. Parent directories are created if needed.
|
|
43
|
+
show_common:
|
|
44
|
+
When ``True``, unchanged geometry (present in both A and B) is drawn
|
|
45
|
+
in grey (128, 128, 128) in the output image.
|
|
46
|
+
overwrite:
|
|
47
|
+
When ``False`` (default), raises ``FileExistsError`` if *output_path*
|
|
48
|
+
already exists.
|
|
49
|
+
|
|
50
|
+
Raises
|
|
51
|
+
------
|
|
52
|
+
FileExistsError
|
|
53
|
+
If *output_path* exists and *overwrite* is ``False``.
|
|
54
|
+
"""
|
|
55
|
+
if output_path.exists() and not overwrite:
|
|
56
|
+
raise FileExistsError(f"output file already exists: {output_path}")
|
|
57
|
+
|
|
58
|
+
height, width = arr_a.shape[:2]
|
|
59
|
+
|
|
60
|
+
# --- build boolean masks ------------------------------------------------
|
|
61
|
+
# Changed wherever any of the first three channels of XOR is non-zero.
|
|
62
|
+
xor_mask: np.ndarray = np.any(xor[..., :3] > 0, axis=-1)
|
|
63
|
+
|
|
64
|
+
# Alpha channels: pixel is "lit" in A or B when alpha > 0.
|
|
65
|
+
alpha_a: np.ndarray = arr_a[..., 3] > 0
|
|
66
|
+
alpha_b: np.ndarray = arr_b[..., 3] > 0
|
|
67
|
+
|
|
68
|
+
removed = xor_mask & alpha_a & ~alpha_b # in A, not in B
|
|
69
|
+
added = xor_mask & alpha_b & ~alpha_a # in B, not in A
|
|
70
|
+
if show_common:
|
|
71
|
+
common = alpha_a & alpha_b & ~xor_mask
|
|
72
|
+
|
|
73
|
+
# --- build BGRA output buffer -------------------------------------------
|
|
74
|
+
out = np.zeros((height, width, 4), dtype=np.uint8)
|
|
75
|
+
|
|
76
|
+
# Removed -> red (B=0, G=0, R=255, A=255 -> BGRA: [0, 0, 255, 255])
|
|
77
|
+
out[removed] = [0, 0, 255, 255]
|
|
78
|
+
# Added -> green (B=0, G=255, R=0, A=255 -> BGRA: [0, 255, 0, 255])
|
|
79
|
+
out[added] = [0, 255, 0, 255]
|
|
80
|
+
if show_common:
|
|
81
|
+
out[common] = [128, 128, 128, 255]
|
|
82
|
+
|
|
83
|
+
# --- write via cairocffi ImageSurface -----------------------------------
|
|
84
|
+
surface = cairo.ImageSurface.create_for_data(
|
|
85
|
+
out,
|
|
86
|
+
cairo.FORMAT_ARGB32,
|
|
87
|
+
width,
|
|
88
|
+
height,
|
|
89
|
+
width * 4,
|
|
90
|
+
)
|
|
91
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
surface.write_to_png(str(output_path))
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Export a geometry diff overlay as an SVG (Cairo-free).
|
|
2
|
+
|
|
3
|
+
Colour semantics:
|
|
4
|
+
|
|
5
|
+
- **red** -- removed material (before-state of ``removed`` changes)
|
|
6
|
+
- **green** -- added material (after-state of ``added`` changes)
|
|
7
|
+
- **blue** -- ``moved`` objects: after-state fill plus a displacement line
|
|
8
|
+
from the before-centroid to the after-centroid
|
|
9
|
+
- **orange** -- ``resized`` objects: after-state fill, before-state outline
|
|
10
|
+
|
|
11
|
+
Y axis is flipped (Gerber +Y up -> SVG +Y down). Polygon interiors (holes)
|
|
12
|
+
are preserved via even-odd fill paths.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from shapely.geometry import MultiPolygon, Polygon
|
|
21
|
+
from shapely.geometry.base import BaseGeometry
|
|
22
|
+
|
|
23
|
+
from gerberdiff.geometry.types import GeometryChange, LayerGeometryDiff
|
|
24
|
+
|
|
25
|
+
_Sx = Callable[[float], float]
|
|
26
|
+
|
|
27
|
+
_COLOR_REMOVED = "#cc0000"
|
|
28
|
+
_COLOR_ADDED = "#00aa00"
|
|
29
|
+
_COLOR_MOVED = "#0066cc"
|
|
30
|
+
_COLOR_RESIZED = "#cc6600"
|
|
31
|
+
|
|
32
|
+
_FILL_OPACITY = 0.85
|
|
33
|
+
_PAD_FRACTION = 0.05
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def write_geometry_svg(
|
|
37
|
+
layer: LayerGeometryDiff,
|
|
38
|
+
output_path: Path,
|
|
39
|
+
*,
|
|
40
|
+
canvas_px: int = 1600,
|
|
41
|
+
overwrite: bool = False,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""Write an SVG overlay of *layer*'s changes to *output_path*.
|
|
44
|
+
|
|
45
|
+
Raises
|
|
46
|
+
------
|
|
47
|
+
FileExistsError
|
|
48
|
+
If *output_path* already exists and *overwrite* is ``False``.
|
|
49
|
+
"""
|
|
50
|
+
if output_path.exists() and not overwrite:
|
|
51
|
+
raise FileExistsError(f"output file already exists: {output_path}")
|
|
52
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
output_path.write_text(render_geometry_svg(layer, canvas_px=canvas_px), encoding="utf-8")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def render_geometry_svg(layer: LayerGeometryDiff, *, canvas_px: int = 1600) -> str:
|
|
57
|
+
"""Render *layer*'s changes to an SVG document string."""
|
|
58
|
+
geoms: list[BaseGeometry] = []
|
|
59
|
+
for c in layer.changes:
|
|
60
|
+
if c.before_geom is not None:
|
|
61
|
+
geoms.append(c.before_geom)
|
|
62
|
+
if c.after_geom is not None:
|
|
63
|
+
geoms.append(c.after_geom)
|
|
64
|
+
|
|
65
|
+
if not geoms:
|
|
66
|
+
return (
|
|
67
|
+
"<svg xmlns='http://www.w3.org/2000/svg' width='64' height='64'>"
|
|
68
|
+
f"<title>{_escape(layer.name)}: no changes</title></svg>"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
min_x = min(g.bounds[0] for g in geoms)
|
|
72
|
+
min_y = min(g.bounds[1] for g in geoms)
|
|
73
|
+
max_x = max(g.bounds[2] for g in geoms)
|
|
74
|
+
max_y = max(g.bounds[3] for g in geoms)
|
|
75
|
+
span = max(max_x - min_x, max_y - min_y, 1e-6)
|
|
76
|
+
pad = span * _PAD_FRACTION
|
|
77
|
+
min_x -= pad
|
|
78
|
+
min_y -= pad
|
|
79
|
+
max_x += pad
|
|
80
|
+
max_y += pad
|
|
81
|
+
|
|
82
|
+
scale = canvas_px / max(max_x - min_x, max_y - min_y)
|
|
83
|
+
width = (max_x - min_x) * scale
|
|
84
|
+
height = (max_y - min_y) * scale
|
|
85
|
+
|
|
86
|
+
def sx(x: float) -> float:
|
|
87
|
+
return (x - min_x) * scale
|
|
88
|
+
|
|
89
|
+
def sy(y: float) -> float: # Gerber +Y up -> SVG +Y down
|
|
90
|
+
return (max_y - y) * scale
|
|
91
|
+
|
|
92
|
+
parts: list[str] = [
|
|
93
|
+
f"<svg xmlns='http://www.w3.org/2000/svg' width='{width:.0f}' "
|
|
94
|
+
f"height='{height:.0f}' viewBox='0 0 {width:.0f} {height:.0f}'>",
|
|
95
|
+
f"<title>{_escape(layer.name)}</title>",
|
|
96
|
+
f"<rect width='{width:.0f}' height='{height:.0f}' fill='white'/>",
|
|
97
|
+
]
|
|
98
|
+
for c in layer.changes:
|
|
99
|
+
parts.extend(_change_svg(c, sx, sy))
|
|
100
|
+
parts.append(_legend())
|
|
101
|
+
parts.append("</svg>")
|
|
102
|
+
return "\n".join(parts)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
# Internal helpers
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _change_svg(c: GeometryChange, sx: _Sx, sy: _Sx) -> list[str]:
|
|
111
|
+
parts: list[str] = []
|
|
112
|
+
if c.kind == "removed" and c.before_geom is not None:
|
|
113
|
+
parts.append(_geom_path(c.before_geom, _COLOR_REMOVED, _FILL_OPACITY, sx, sy))
|
|
114
|
+
elif c.kind == "added" and c.after_geom is not None:
|
|
115
|
+
parts.append(_geom_path(c.after_geom, _COLOR_ADDED, _FILL_OPACITY, sx, sy))
|
|
116
|
+
elif c.kind == "moved" and c.after_geom is not None:
|
|
117
|
+
parts.append(_geom_path(c.after_geom, _COLOR_MOVED, _FILL_OPACITY, sx, sy))
|
|
118
|
+
if c.before_geom is not None:
|
|
119
|
+
bc = c.before_geom.centroid
|
|
120
|
+
ac = c.after_geom.centroid
|
|
121
|
+
parts.append(
|
|
122
|
+
f"<line x1='{sx(bc.x):.1f}' y1='{sy(bc.y):.1f}' "
|
|
123
|
+
f"x2='{sx(ac.x):.1f}' y2='{sy(ac.y):.1f}' "
|
|
124
|
+
f"stroke='{_COLOR_MOVED}' stroke-width='1.5'/>"
|
|
125
|
+
)
|
|
126
|
+
elif c.kind == "resized":
|
|
127
|
+
if c.after_geom is not None:
|
|
128
|
+
parts.append(_geom_path(c.after_geom, _COLOR_RESIZED, _FILL_OPACITY, sx, sy))
|
|
129
|
+
if c.before_geom is not None:
|
|
130
|
+
parts.append(_geom_path(c.before_geom, "none", 0.0, sx, sy, stroke=_COLOR_RESIZED))
|
|
131
|
+
return [p for p in parts if p]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _geom_path(
|
|
135
|
+
geom: BaseGeometry,
|
|
136
|
+
fill: str,
|
|
137
|
+
opacity: float,
|
|
138
|
+
sx: _Sx,
|
|
139
|
+
sy: _Sx,
|
|
140
|
+
stroke: str | None = None,
|
|
141
|
+
) -> str:
|
|
142
|
+
"""One even-odd <path> for all polygon rings (exteriors + holes)."""
|
|
143
|
+
polys: list[Polygon]
|
|
144
|
+
if isinstance(geom, Polygon):
|
|
145
|
+
polys = [geom]
|
|
146
|
+
elif isinstance(geom, MultiPolygon):
|
|
147
|
+
polys = list(geom.geoms)
|
|
148
|
+
else:
|
|
149
|
+
polys = [g for g in getattr(geom, "geoms", []) if isinstance(g, Polygon)]
|
|
150
|
+
|
|
151
|
+
d_parts: list[str] = []
|
|
152
|
+
for poly in polys:
|
|
153
|
+
if poly.is_empty:
|
|
154
|
+
continue
|
|
155
|
+
rings = [poly.exterior, *poly.interiors]
|
|
156
|
+
for ring in rings:
|
|
157
|
+
coords = " L ".join(f"{sx(x):.2f},{sy(y):.2f}" for x, y in ring.coords)
|
|
158
|
+
d_parts.append(f"M {coords} Z")
|
|
159
|
+
if not d_parts:
|
|
160
|
+
return ""
|
|
161
|
+
stroke_attr = f" stroke='{stroke}' stroke-width='1'" if stroke else " stroke='none'"
|
|
162
|
+
return (
|
|
163
|
+
f"<path d='{' '.join(d_parts)}' fill='{fill}' fill-opacity='{opacity}' "
|
|
164
|
+
f"fill-rule='evenodd'{stroke_attr}/>"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _legend() -> str:
|
|
169
|
+
rows = [
|
|
170
|
+
(_COLOR_REMOVED, "removed"),
|
|
171
|
+
(_COLOR_ADDED, "added"),
|
|
172
|
+
(_COLOR_MOVED, "moved"),
|
|
173
|
+
(_COLOR_RESIZED, "resized"),
|
|
174
|
+
]
|
|
175
|
+
items = [
|
|
176
|
+
"<g font-family='sans-serif' font-size='16'>",
|
|
177
|
+
"<rect x='8' y='8' width='120' height='98' fill='white' stroke='#888'/>",
|
|
178
|
+
]
|
|
179
|
+
for i, (color, label) in enumerate(rows):
|
|
180
|
+
y = 30 + i * 22
|
|
181
|
+
items.append(f"<text x='20' y='{y}' fill='{color}'>{label}</text>")
|
|
182
|
+
items.append("</g>")
|
|
183
|
+
return "".join(items)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _escape(text: str) -> str:
|
|
187
|
+
return text.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Geometry-aware diff engine: resolution-independent, attributed changes.
|
|
2
|
+
|
|
3
|
+
Public surface:
|
|
4
|
+
|
|
5
|
+
- :func:`compute_geometry_diff` -- directory-vs-directory geometry diff.
|
|
6
|
+
- :class:`GeometryChange`, :class:`LayerGeometryDiff`,
|
|
7
|
+
:class:`GeometryDiffResult` -- result types.
|
|
8
|
+
|
|
9
|
+
The geometry pipeline operates on the parsed IR and is Cairo-free; see
|
|
10
|
+
``docs/geometry-diff.md`` for the design.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from gerberdiff.geometry.driver import (
|
|
14
|
+
DEFAULT_AREA_TOL,
|
|
15
|
+
DEFAULT_DUST_AREA_MM2,
|
|
16
|
+
DEFAULT_GATE_RADIUS_MM,
|
|
17
|
+
DEFAULT_MOVE_TOL_MM,
|
|
18
|
+
compute_geometry_diff,
|
|
19
|
+
)
|
|
20
|
+
from gerberdiff.geometry.types import (
|
|
21
|
+
ChangeKind,
|
|
22
|
+
GeometryChange,
|
|
23
|
+
GeometryDiffResult,
|
|
24
|
+
LayerGeometryDiff,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"DEFAULT_AREA_TOL",
|
|
29
|
+
"DEFAULT_DUST_AREA_MM2",
|
|
30
|
+
"DEFAULT_GATE_RADIUS_MM",
|
|
31
|
+
"DEFAULT_MOVE_TOL_MM",
|
|
32
|
+
"ChangeKind",
|
|
33
|
+
"GeometryChange",
|
|
34
|
+
"GeometryDiffResult",
|
|
35
|
+
"LayerGeometryDiff",
|
|
36
|
+
"compute_geometry_diff",
|
|
37
|
+
]
|