948 خطوط
38 KiB
Python
948 خطوط
38 KiB
Python
"""Independent, bounded reader for the documented PcbDoc 6.0 binary record family."""
|
|
|
|
import hashlib
|
|
import io
|
|
import math
|
|
import re
|
|
import struct
|
|
from collections import Counter
|
|
import olefile
|
|
from app.models.schematic import Sheet, Point, Bounds, Diagnostic, BoardLayer, Net
|
|
from app.models.pcb import PCBData as Document, Footprint as Component, PCBObject as SchematicObject, PCBPrimitive as Primitive
|
|
from .record_decoder import ParseError, properties
|
|
from .pcb_binary import Reader, at, text_records, binary_records, wide_strings
|
|
from .pcb_geometry import (
|
|
raw_unit,
|
|
unit,
|
|
xy,
|
|
ellipse_path,
|
|
arc_path,
|
|
pad_shape,
|
|
property_outline,
|
|
)
|
|
from .geometry import bounds, union, poly_path, transform_point
|
|
|
|
MAX_TOTAL = 64 * 1024 * 1024
|
|
SPECIAL = {
|
|
32: ("Bottom Layer", "#5484ed", "copper"),
|
|
33: ("Top Overlay", "#f6e4b1", "silkscreen"),
|
|
34: ("Bottom Overlay", "#c29ef1", "silkscreen"),
|
|
35: ("Top Paste", "#92b8a0", "paste"),
|
|
36: ("Bottom Paste", "#81a0b5", "paste"),
|
|
37: ("Top Solder", "#966ac1", "mask"),
|
|
38: ("Bottom Solder", "#b78b99", "mask"),
|
|
55: ("Drill Guide", "#d5d8dc", "mechanical"),
|
|
56: ("Keep-Out Layer", "#d374dc", "mechanical"),
|
|
73: ("Drill Drawing", "#cccbd0", "mechanical"),
|
|
74: ("Multi-Layer", "#d9b660", "copper"),
|
|
81: ("Drill holes", "#08151b", "drill"),
|
|
0: ("Board Outline", "#67bc9d", "outline"),
|
|
}
|
|
|
|
|
|
def layer_number(name):
|
|
name = name.upper().replace(" ", "").replace("_", "")
|
|
fixed = {
|
|
"TOP": 1,
|
|
"TOPLAYER": 1,
|
|
"BOTTOM": 32,
|
|
"BOTTOMLAYER": 32,
|
|
"TOPOVERLAY": 33,
|
|
"BOTTOMOVERLAY": 34,
|
|
"TOPPASTE": 35,
|
|
"BOTTOMPASTE": 36,
|
|
"TOPSOLDER": 37,
|
|
"BOTTOMSOLDER": 38,
|
|
"KEEPOUT": 56,
|
|
"KEEPOUTLAYER": 56,
|
|
"MULTILAYER": 74,
|
|
"DRILLGUIDE": 55,
|
|
"DRILLDRAWING": 73,
|
|
}
|
|
if name in fixed:
|
|
return fixed[name]
|
|
m = re.fullmatch(r"(MID(?:LAYER)?|PLANE|MECHANICAL)(\d+)", name)
|
|
if m:
|
|
n = int(m[2])
|
|
if m[1].startswith("MID"):
|
|
return n + 1
|
|
if m[1] == "PLANE":
|
|
return n + 38
|
|
return n + 56 if n <= 16 else 0x01020000 + n
|
|
return 0
|
|
|
|
|
|
def resolve_layer(legacy, extended=0):
|
|
# The standard copper/overlay IDs remain authoritative in V6 records.
|
|
if 0x01020011 <= extended <= 0x0102FFFF:
|
|
return extended
|
|
return legacy
|
|
|
|
|
|
def parse_pcbdoc(data: bytes, filename: str, document_id: str, **options) -> Document:
|
|
if not data.startswith(b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"):
|
|
raise ParseError("PcbDoc must be an OLE compound document")
|
|
try:
|
|
ole = olefile.OleFileIO(
|
|
io.BytesIO(data), raise_defects=olefile.DEFECT_INCORRECT
|
|
)
|
|
except (OSError, ValueError, struct.error) as exc:
|
|
raise ParseError(f"Invalid PCB container: {exc}") from exc
|
|
with ole:
|
|
entries = ole.listdir(streams=True, storages=False)
|
|
if len(entries) > 1024:
|
|
raise ParseError("Excessive PCB stream count")
|
|
streams = [{"name": "/".join(e), "size": ole.get_size(e)} for e in entries]
|
|
if (
|
|
any(s["size"] > 32 * 1024 * 1024 for s in streams)
|
|
or sum(s["size"] for s in streams) > MAX_TOTAL
|
|
):
|
|
raise ParseError("PCB stream size limit exceeded")
|
|
|
|
def read(name, required=False):
|
|
if not ole.exists(name):
|
|
if required:
|
|
raise ParseError(f"Missing PCB stream {name}")
|
|
return b""
|
|
return ole.openstream(name).read()
|
|
|
|
header = read("FileHeaderSix", True)
|
|
hreader = Reader(header, "FileHeaderSix")
|
|
header_length = hreader.read("I")
|
|
envelope = hreader.pascal()
|
|
if header_length != len(envelope):
|
|
raise ParseError("PCB version string length mismatch")
|
|
if envelope != "PCB 6.0 Binary File":
|
|
raise ParseError(f"Unsupported PCB envelope: {envelope[:100]}")
|
|
board_records = text_records(read("Board6/Data", True), "Board6")
|
|
if len(board_records) != 1:
|
|
raise ParseError("Expected exactly one Board6 property record")
|
|
board = board_records[0]
|
|
if board.get("KIND") != "Protel_Advanced_PCB" or board.get("VERSION") not in (
|
|
"5.0",
|
|
"5.01",
|
|
"6.0",
|
|
):
|
|
raise ParseError(f"Unsupported PCB board version {board.get('VERSION')}")
|
|
diagnostics = []
|
|
grouped = {}
|
|
counts = {}
|
|
variants = {}
|
|
objects = []
|
|
primitives = []
|
|
layers = {}
|
|
component_boxes = {}
|
|
|
|
def report(code, message, record=None, severity="warning"):
|
|
key = (code, message)
|
|
if key in grouped:
|
|
grouped[key].count += 1
|
|
return
|
|
d = Diagnostic(
|
|
severity=severity, code=code, message=message, record_id=record
|
|
)
|
|
diagnostics.append(d)
|
|
grouped[key] = d
|
|
|
|
def ensure_layer(n):
|
|
key = str(n)
|
|
if key in layers:
|
|
return layers[key]
|
|
if n == 1:
|
|
name, col, category = "Top Layer", "#d5624e", "copper"
|
|
elif 2 <= n <= 31:
|
|
name, col, category = (
|
|
f"Mid Layer {n - 1}",
|
|
("#a5bd59", "#b67ac7", "#d19e4d", "#63bdb5")[(n - 2) % 4],
|
|
"copper",
|
|
)
|
|
elif n in SPECIAL:
|
|
name, col, category = SPECIAL[n]
|
|
elif 39 <= n <= 54:
|
|
name, col, category = f"Internal Plane {n - 38}", "#698a68", "plane"
|
|
elif 57 <= n <= 72 or n >= 0x01020000:
|
|
name, col, category = (
|
|
f"Mechanical {n - 56 if n < 100 else n - 0x01020000}",
|
|
"#8196a8",
|
|
"mechanical",
|
|
)
|
|
else:
|
|
name, col, category = f"Layer {n}", "#8196a8", "unknown"
|
|
name = board.get(f"LAYER{n}NAME", name)
|
|
if n >= 0x01020000:
|
|
for k, v in board.items():
|
|
if (
|
|
k.startswith("LAYERV7_")
|
|
and k.endswith("LAYERID")
|
|
and v == str(n)
|
|
):
|
|
name = board.get(k[:-7] + "NAME", name)
|
|
order = (
|
|
0
|
|
if n == 0
|
|
else 1000
|
|
if n == 81
|
|
else 900
|
|
if n == 33
|
|
else 890
|
|
if n == 34
|
|
else 800
|
|
if n == 74
|
|
else 700
|
|
if n == 1
|
|
else 600
|
|
if n == 32
|
|
else 500 - n
|
|
if 2 <= n <= 31
|
|
else 850
|
|
)
|
|
layer = BoardLayer(
|
|
id=key,
|
|
name=name,
|
|
color=col,
|
|
category=category,
|
|
visible=n in (0, 1, 33, 74, 81),
|
|
order=order,
|
|
)
|
|
layers[key] = layer
|
|
return layer
|
|
|
|
def props_records(name):
|
|
result = text_records(read(name + "/Data"), name)
|
|
check_count(name, len(result))
|
|
return result
|
|
|
|
def check_count(name, count):
|
|
counts[name] = count
|
|
hdr = read(name + "/Header")
|
|
if len(hdr) == 4 and at(hdr, 0, "I") != count:
|
|
report(
|
|
"record_count_mismatch",
|
|
f"{name}: header declares {at(hdr, 0, 'I')}, decoded {count}",
|
|
)
|
|
|
|
def typed(name, tag, blocks=1):
|
|
count = 0
|
|
for idx, parts in binary_records(read(name + "/Data"), name, tag, blocks):
|
|
count += 1
|
|
signature = ",".join(str(len(p)) for p in parts)
|
|
variants.setdefault(name, Counter())[signature] += 1
|
|
if sum(counts.values()) + count > 100000:
|
|
raise ParseError("Excessive PCB object count")
|
|
yield idx, parts
|
|
check_count(name, count)
|
|
|
|
uid_map = {}
|
|
uid_stream = text_records(read("UniqueIDPrimitiveInformation/Data"), "UniqueIDPrimitiveInformation")
|
|
for item in uid_stream:
|
|
key = (item.get("PRIMITIVEOBJECTID", "").lower(), int(item.get("PRIMITIVEINDEX", -1)))
|
|
if key in uid_map: raise ParseError("Duplicate primitive UID mapping")
|
|
uid_map[key] = item.get("UNIQUEID", "")
|
|
|
|
nets = [
|
|
Net(id=f"net:{i}", name=p.get("NAME", f"Net {i}"), properties=p)
|
|
for i, p in enumerate(props_records("Nets6"))
|
|
]
|
|
components = []
|
|
for i, p in enumerate(props_records("Components6")):
|
|
if not {"X","Y","LAYER"}.issubset(p):
|
|
raise ParseError(f"Components6:{i}: missing native placement fields")
|
|
pos = Point(x=unit(p.get("X", "0mil")), y=-unit(p.get("Y", "0mil")))
|
|
components.append(
|
|
Component(
|
|
id=f"Components6:{i}",
|
|
designator=p.get("SOURCEDESIGNATOR", ""),
|
|
name=p.get("SOURCELIBREFERENCE", p.get("PATTERN", "")),
|
|
value=p.get("COMMENT", ""),
|
|
comment=p.get("COMMENT", ""),
|
|
description=p.get("SOURCEDESCRIPTION", ""),
|
|
library_reference=p.get("PATTERN", ""),
|
|
source_library=p.get("SOURCEFOOTPRINTLIBRARY", ""),
|
|
position=pos,
|
|
rotation=float(p.get("ROTATION", 0)),
|
|
mirrored=layer_number(p.get("LAYER", "TOP")) == 32,
|
|
bounds=bounds([pos], 1),
|
|
parameters=p,
|
|
)
|
|
)
|
|
|
|
if any(not math.isfinite(c.rotation) for c in components):
|
|
raise ParseError("Non-finite footprint rotation")
|
|
|
|
def common(name, index, p, ext=0):
|
|
if len(p) < 13:
|
|
raise ParseError(f"{name}:{index}: short primitive header")
|
|
net, component = at(p, 3, "H"), at(p, 7, "H")
|
|
if net != 65535 and net >= len(nets):
|
|
raise ParseError(f"{name}:{index}: invalid net index {net}")
|
|
if component != 65535 and component >= len(components):
|
|
raise ParseError(f"{name}:{index}: invalid component index {component}")
|
|
lid = resolve_layer(p[0], ext)
|
|
layer = ensure_layer(lid)
|
|
c = components[component] if component != 65535 else None
|
|
props = {
|
|
"LAYER_ID": str(lid),
|
|
"NET_INDEX": str(net),
|
|
"NET_NAME": nets[net].name if net != 65535 else "",
|
|
"COMPONENT_INDEX": str(component),
|
|
"LOCKED": str(not bool(p[1] & 4)),
|
|
"FLAGS_1": str(p[1]),
|
|
"FLAGS_2": str(p[2]),
|
|
}
|
|
obj = SchematicObject(
|
|
id=f"{name}:{index}",
|
|
kind=name.removesuffix("6").lower(),
|
|
native_uid=uid_map.get(({"Tracks6":"track","Arcs6":"arc","Pads6":"pad","Vias6":"via","Texts6":"text","Fills6":"fill","Regions6":"region","ShapeBasedRegions6":"region"}.get(name,""),index), ""),
|
|
record_type={
|
|
"Tracks6": 4,
|
|
"Arcs6": 1,
|
|
"Pads6": 2,
|
|
"Vias6": 3,
|
|
"Texts6": 5,
|
|
"Fills6": 6,
|
|
"Regions6": 11,
|
|
"ShapeBasedRegions6": 11,
|
|
}.get(name, 0),
|
|
source_stream=name,
|
|
source_index=index,
|
|
owner_id=c.id if c else None,
|
|
component_id=c.id if c else None,
|
|
layer_id=layer.id,
|
|
net_id=nets[net].id if net != 65535 else None,
|
|
position=Point(x=0, y=0),
|
|
bounds=bounds([]),
|
|
properties=props,
|
|
)
|
|
objects.append(obj)
|
|
layer.object_count += 1
|
|
if c:
|
|
c.object_ids.append(obj.id)
|
|
return obj, c
|
|
|
|
def shape(
|
|
obj,
|
|
path,
|
|
box,
|
|
fill="none",
|
|
width=0.0,
|
|
layer_id=None,
|
|
role="",
|
|
stroke=None,
|
|
fill_rule="nonzero",
|
|
position=None,
|
|
text="",
|
|
rotation=0,
|
|
mirrored=False,
|
|
font_size=10,
|
|
font_family="Arial",
|
|
bold=False,
|
|
italic=False,
|
|
):
|
|
layer = ensure_layer(
|
|
int(layer_id if layer_id is not None else obj.layer_id or 0)
|
|
)
|
|
col = layer.color
|
|
primitive = Primitive(
|
|
id=f"{obj.id}:p{len(obj.primitive_ids)}",
|
|
kind="text" if text else "path",
|
|
object_id=obj.id,
|
|
component_id=obj.component_id,
|
|
layer_id=layer.id,
|
|
net_id=obj.net_id,
|
|
role=role,
|
|
path=path,
|
|
bounds=box,
|
|
stroke=col if stroke is None else stroke,
|
|
fill=col if fill == "layer" else fill,
|
|
width=width,
|
|
fill_rule=fill_rule,
|
|
position=position or obj.position,
|
|
text=text,
|
|
rotation=rotation,
|
|
mirrored=mirrored,
|
|
font_size=font_size,
|
|
font_family=font_family,
|
|
bold=bold,
|
|
italic=italic,
|
|
)
|
|
if len(primitives) >= 250000: raise ParseError("PCB drawable limit exceeded")
|
|
primitives.append(primitive)
|
|
obj.primitive_ids.append(primitive.id)
|
|
obj.bounds = union([obj.bounds, box]) if len(obj.primitive_ids) > 1 else box
|
|
if obj.component_id:
|
|
ci = int(obj.component_id.split(":")[1])
|
|
components[ci].primitive_ids.append(primitive.id)
|
|
if layer.category not in ("mechanical", "paste", "mask"):
|
|
component_boxes.setdefault(obj.component_id, []).append(box)
|
|
return primitive
|
|
|
|
# Outline is extracted from actual Board6 vertices, including rounded corners.
|
|
outline, board_bounds = property_outline(board)
|
|
if not outline:
|
|
raise ParseError("PCB has no supported Board6 outline")
|
|
outline_obj = SchematicObject(
|
|
id="Board6:0",
|
|
kind="board_outline",
|
|
record_type=0,
|
|
source_stream="Board6",
|
|
source_index=0,
|
|
layer_id="0",
|
|
position=Point(x=board_bounds.x, y=board_bounds.y),
|
|
bounds=board_bounds,
|
|
properties=board,
|
|
)
|
|
objects.append(outline_obj)
|
|
shape(
|
|
outline_obj, outline, board_bounds, fill="#13312e", width=0.18, role="board"
|
|
)
|
|
counts["Board6"] = 1
|
|
# Preserve polygon definitions as outlines only; the filled copper is read from Regions6.
|
|
polygons = props_records("Polygons6")
|
|
for i, p in enumerate(polygons):
|
|
lid = layer_number(p.get("LAYER", "TOP"))
|
|
layer = ensure_layer(lid)
|
|
path, box = property_outline(p)
|
|
net = int(p.get("NET", 65535))
|
|
if net != 65535 and not 0 <= net < len(nets): raise ParseError("Invalid polygon net reference")
|
|
o = SchematicObject(
|
|
id=f"Polygons6:{i}",
|
|
kind="polygon_definition",
|
|
native_uid=p.get("UNIQUEID", ""),
|
|
record_type=0,
|
|
source_stream="Polygons6",
|
|
source_index=i,
|
|
layer_id=layer.id,
|
|
net_id=nets[net].id if 0 <= net < len(nets) else None,
|
|
visible=False,
|
|
position=Point(x=box.x, y=box.y),
|
|
bounds=box,
|
|
properties=p,
|
|
)
|
|
objects.append(o)
|
|
for i, (p,) in typed("Tracks6", 4):
|
|
obj, c = common("Tracks6", i, p, at(p, 41, "I", 0))
|
|
a, b = xy(*at(p, 13, "ii")), xy(*at(p, 21, "ii"))
|
|
w = raw_unit(at(p, 29, "i"))
|
|
if w < 0:
|
|
raise ParseError("Negative track width")
|
|
obj.position = a
|
|
obj.connection_points = [a, b]
|
|
obj.kind = "track"
|
|
obj.properties["WIDTH_MIL"] = str(w * 10)
|
|
polygon = at(p, 5, "H")
|
|
obj.properties["POLYGON_INDEX"] = str(polygon)
|
|
shape(
|
|
obj,
|
|
poly_path([a, b]),
|
|
bounds([a, b], w / 2),
|
|
width=w,
|
|
role="polygon_outline" if p[1] & 2 else "track",
|
|
)
|
|
for i, (p,) in typed("Arcs6", 1):
|
|
obj, c = common("Arcs6", i, p, at(p, 52, "I", 0))
|
|
pos = xy(*at(p, 13, "ii"))
|
|
r = raw_unit(at(p, 21, "i"))
|
|
start, end = at(p, 25, "dd")
|
|
w = raw_unit(at(p, 41, "i"))
|
|
if w < 0: raise ParseError("Negative arc width")
|
|
obj.kind = "arc"
|
|
obj.position = pos
|
|
obj.properties.update(
|
|
{
|
|
"RADIUS_MIL": str(r * 10),
|
|
"START_ANGLE": str(start),
|
|
"END_ANGLE": str(end),
|
|
"WIDTH_MIL": str(w * 10),
|
|
}
|
|
)
|
|
shape(
|
|
obj,
|
|
arc_path(pos, r, start, end),
|
|
Bounds(
|
|
x=pos.x - r - w / 2,
|
|
y=pos.y - r - w / 2,
|
|
width=2 * r + w,
|
|
height=2 * r + w,
|
|
),
|
|
width=w,
|
|
role="track",
|
|
)
|
|
for i, (p,) in typed("Fills6", 6):
|
|
obj, c = common("Fills6", i, p, at(p, 42, "I", 0))
|
|
a, b = xy(*at(p, 13, "ii")), xy(*at(p, 21, "ii"))
|
|
rotation = at(p, 29, "d")
|
|
center = Point(x=(a.x + b.x) / 2, y=(a.y + b.y) / 2)
|
|
obj.position = center
|
|
obj.kind = "fill"
|
|
path, box = pad_shape(center, abs(b.x - a.x), abs(b.y - a.y), 2, rotation)
|
|
obj.properties["ROTATION"] = str(rotation)
|
|
shape(obj, path, box, fill="layer", role="fill")
|
|
|
|
# Render saved tessellated regions with exact interior holes. Keep definition streams separate.
|
|
def regions(name, extended):
|
|
for i, (p,) in typed(name, 11):
|
|
obj, c = common(name, i, p)
|
|
obj.kind = "region"
|
|
reader = Reader(p, name)
|
|
reader.take(14)
|
|
hole_count = reader.read("H")
|
|
reader.take(2)
|
|
props = properties(bytes(reader.block()))
|
|
obj.properties.update(props)
|
|
n = reader.read("I") + (1 if extended else 0)
|
|
if n > 50000 or hole_count > 20000:
|
|
raise ParseError("Excessive region geometry")
|
|
vertices = []
|
|
commands = []
|
|
all_points = []
|
|
for _ in range(n):
|
|
if extended:
|
|
curved = reader.read("B")
|
|
pos = xy(*reader.read("ii"))
|
|
center = xy(*reader.read("ii"))
|
|
r = raw_unit(reader.read("i"))
|
|
sa, ea = reader.read("dd")
|
|
vertices.append((pos, curved, center, r, sa, ea))
|
|
else:
|
|
vertices.append((xy(*reader.read("dd")), 0, None, 0, 0, 0))
|
|
for j, (pos, curved, center, r, sa, ea) in enumerate(vertices):
|
|
commands.append(("M" if j == 0 else "L") + f"{pos.x:.15g},{pos.y:.15g}")
|
|
all_points.append(pos)
|
|
if curved and r > 0:
|
|
arc = arc_path(center, r, sa, ea)
|
|
if " " in arc:
|
|
commands.extend(
|
|
["L" + arc.split(" ", 1)[0][1:], arc.split(" ", 1)[1]]
|
|
)
|
|
all_points.extend(
|
|
[
|
|
Point(x=center.x - r, y=center.y - r),
|
|
Point(x=center.x + r, y=center.y + r),
|
|
]
|
|
)
|
|
commands.append("Z")
|
|
for _ in range(hole_count):
|
|
count = reader.read("I")
|
|
if count > 50000:
|
|
raise ParseError("Excessive region hole points")
|
|
pts = [xy(*reader.read("dd")) for _ in range(count)]
|
|
commands.append(poly_path(pts, True))
|
|
all_points.extend(pts)
|
|
obj.properties["OUTLINE_PATH"] = " ".join(commands)
|
|
obj.bounds = bounds(all_points)
|
|
obj.position = all_points[0] if all_points else Point(x=0, y=0)
|
|
obj.properties["HOLE_COUNT"] = str(hole_count)
|
|
obj.properties["VERTEX_COUNT"] = str(n)
|
|
obj.properties["POLYGON_INDEX"] = str(at(p, 5, "H"))
|
|
kind = int(props.get("KIND", 0))
|
|
polygon = at(p, 5, "H")
|
|
is_cutout = props.get("ISBOARDCUTOUT", "FALSE").upper() == "TRUE"
|
|
if extended:
|
|
# When saved fills exist, shape definitions must not cover clearances twice.
|
|
if polygon != 65535 and not is_cutout:
|
|
obj.visible = False
|
|
continue
|
|
# Standalone shapes are taken from this stream, with circular arcs intact.
|
|
role = "region"
|
|
else:
|
|
if polygon == 65535 and not is_cutout:
|
|
obj.visible = False
|
|
continue
|
|
role = "pour"
|
|
if kind != 0 and not is_cutout:
|
|
obj.visible = False
|
|
report(
|
|
"region_definition",
|
|
f"Region kind {kind} is retained as a definition; saved copper holes govern the fill",
|
|
obj.id,
|
|
"info",
|
|
)
|
|
continue
|
|
if is_cutout:
|
|
shape(
|
|
obj,
|
|
" ".join(commands),
|
|
obj.bounds,
|
|
fill="#08151b",
|
|
layer_id="0",
|
|
role="board_cutout",
|
|
fill_rule="evenodd",
|
|
)
|
|
elif len(vertices) >= 3:
|
|
shape(
|
|
obj,
|
|
" ".join(commands),
|
|
obj.bounds,
|
|
fill="layer",
|
|
role=role,
|
|
fill_rule="evenodd",
|
|
)
|
|
if reader.remaining:
|
|
report(
|
|
"region_extension",
|
|
f"{name} contains uninterpreted region extension bytes",
|
|
obj.id,
|
|
)
|
|
|
|
regions("Regions6", False)
|
|
regions("ShapeBasedRegions6", True)
|
|
poured = {o.properties.get("POLYGON_INDEX") for o in objects if o.source_stream == "Regions6" and o.visible}
|
|
for index, definition in enumerate(polygons):
|
|
if str(index) not in poured:
|
|
report("polygon_without_saved_pour", "Polygons6 definition has no decoded saved copper pour; no fill is invented", f"Polygons6:{index}")
|
|
# Active copper layers are obtained from legacy stack links, not from LAYERSETSCOUNT (view presets).
|
|
copper_ids = [1]
|
|
while True:
|
|
nxt = int(board.get(f"LAYER{copper_ids[-1]}NEXT", "0"))
|
|
if not nxt: break
|
|
if nxt in copper_ids or nxt not in range(1,55) or 33 <= nxt <= 38:
|
|
raise ParseError("Invalid copper stack links")
|
|
copper_ids.append(nxt)
|
|
if 32 not in copper_ids:
|
|
copper_ids = [1,32]
|
|
report("copper_stack", "No complete layer chain; only explicit top/bottom copper stack supported")
|
|
for index, lid in enumerate(copper_ids):
|
|
ensure_layer(lid).order = 700 - index * 10
|
|
for i, parts in typed("Pads6", 2, 6):
|
|
name = Reader(parts[0], "Pad name").pascal()
|
|
p, stack = parts[4], parts[5]
|
|
if len(p) < 110:
|
|
raise ParseError("Unsupported short pad geometry")
|
|
obj, c = common("Pads6", i, p)
|
|
obj.kind = "pad"
|
|
pos = xy(*at(p, 13, "ii"))
|
|
obj.position = pos
|
|
sizes = [
|
|
(raw_unit(x), raw_unit(y))
|
|
for x, y in [at(p, 21, "ii"), at(p, 29, "ii"), at(p, 37, "ii")]
|
|
]
|
|
hole = raw_unit(at(p, 45, "i"))
|
|
if hole < 0: raise ParseError("Negative pad drill diameter")
|
|
shapes = list(at(p, 49, "BBB"))
|
|
rotation = at(p, 52, "d")
|
|
mode = at(p, 62, "B")
|
|
obj.properties.update(
|
|
{
|
|
"PAD_NAME": name,
|
|
"HOLE_MIL": str(hole * 10),
|
|
"ROTATION": str(rotation),
|
|
"PLATED": str(bool(p[60])),
|
|
"BOTTOM_SIZE_MIL": f"{sizes[2][0]*10:.15g} x {sizes[2][1]*10:.15g}",
|
|
"MID_SIZE_MIL": f"{sizes[1][0]*10:.15g} x {sizes[1][1]*10:.15g}",
|
|
"TOP_SIZE_MIL": f"{sizes[0][0] * 10:.15g} x {sizes[0][1] * 10:.15g}",
|
|
"SHAPE": str(shapes[0]),
|
|
"PAD_MODE": str(mode),
|
|
}
|
|
)
|
|
target_layers = (
|
|
copper_ids if int(obj.layer_id) == 74 else [int(obj.layer_id)]
|
|
)
|
|
obj.layer_ids = [str(lid) for lid in target_layers]
|
|
for lid in target_layers:
|
|
if mode not in (0,1,2) or (mode == 2 and len(stack) < 596):
|
|
report("pad_stack", "Unsupported pad stack mode or truncated full-stack variant", obj.id)
|
|
continue
|
|
which = 0 if lid == 1 else 2 if lid == 32 else 1
|
|
w, h = sizes[which]
|
|
s = shapes[which]
|
|
radius = 0
|
|
if mode == 2 and len(stack) >= 596 and 2 <= lid <= 30:
|
|
w, h = (
|
|
raw_unit(at(stack, 4 * (lid - 2), "i")),
|
|
raw_unit(at(stack, 116 + 4 * (lid - 2), "i")),
|
|
)
|
|
s = stack[232 + lid - 2]
|
|
stack_index = 0 if lid == 1 else 31 if lid == 32 else lid - 1
|
|
if mode == 2 and lid > 32:
|
|
report("pad_plane_stack", "Full-stack pad geometry on internal plane layers is not decoded", obj.id)
|
|
continue
|
|
if len(stack) >= 596 and lid <= 32:
|
|
alt = stack[532 + stack_index]
|
|
radius = stack[564 + stack_index]
|
|
if alt in (1, 2, 3, 9):
|
|
s = alt
|
|
elif alt != 0:
|
|
report(
|
|
"pad_alt_shape",
|
|
f"Unsupported alternative pad shape {alt}",
|
|
obj.id,
|
|
)
|
|
continue
|
|
try:
|
|
path, box = pad_shape(pos, w, h, s, rotation, radius)
|
|
except ParseError as exc:
|
|
report("pad_shape", str(exc), obj.id)
|
|
continue
|
|
shape(obj, path, box, fill="layer", layer_id=str(lid), role="pad")
|
|
if hole > 0:
|
|
hole_shape = stack[262] if len(stack) >= 596 else 0
|
|
slot = raw_unit(at(stack, 263, "i")) if len(stack) >= 596 else 0
|
|
hole_angle = at(stack, 267, "d") if len(stack) >= 596 else 0
|
|
dx = raw_unit(at(stack, 275, "i")) if len(stack) >= 596 else 0
|
|
dy = -raw_unit(at(stack, 403, "i")) if len(stack) >= 596 else 0
|
|
hpos = transform_point(Point(x=dx, y=dy), pos, rotation)
|
|
if hole_shape == 2 and slot > hole:
|
|
path, box = pad_shape(hpos, slot, hole, 1, rotation + hole_angle)
|
|
elif hole_shape in (0, 1):
|
|
path, box = pad_shape(
|
|
hpos, hole, hole, 2 if hole_shape == 1 else 1, rotation
|
|
)
|
|
else:
|
|
report(
|
|
"drill_shape",
|
|
f"Unsupported pad hole shape {hole_shape}",
|
|
obj.id,
|
|
)
|
|
continue
|
|
drill = shape(
|
|
obj,
|
|
path,
|
|
box,
|
|
fill="#08151b",
|
|
layer_id="81",
|
|
role="drill",
|
|
stroke="none",
|
|
)
|
|
drill.layer_ids = obj.layer_ids
|
|
for i, (p,) in typed("Vias6", 3):
|
|
obj, c = common("Vias6", i, p)
|
|
obj.kind = "via"
|
|
pos = xy(*at(p, 13, "ii"))
|
|
obj.position = pos
|
|
diameter, hole = raw_unit(at(p, 21, "i")), raw_unit(at(p, 25, "i"))
|
|
if diameter <= 0 or hole < 0:
|
|
raise ParseError("Invalid via diameter")
|
|
obj.properties.update(
|
|
{
|
|
"DIAMETER_MIL": str(diameter * 10),
|
|
"HOLE_MIL": str(hole * 10),
|
|
"START_LAYER": str(at(p, 29, "B")),
|
|
"END_LAYER": str(at(p, 30, "B")),
|
|
}
|
|
)
|
|
start_layer, end_layer = int(obj.properties["START_LAYER"]), int(obj.properties["END_LAYER"])
|
|
if start_layer not in copper_ids or end_layer not in copper_ids:
|
|
raise ParseError("Via span refers to an inactive/unsupported copper layer")
|
|
a, b = sorted((copper_ids.index(start_layer), copper_ids.index(end_layer)))
|
|
span = copper_ids[a:b+1]
|
|
obj.layer_ids = [str(lid) for lid in span]
|
|
obj.properties["LAYER_SPAN"] = ",".join(obj.layer_ids)
|
|
diameters = at(p,75,"32i") if len(p) >= 203 else None
|
|
for lid in span:
|
|
if diameters and lid > 32 and len(set(diameters)) > 1:
|
|
report("via_plane_stack", "Nonuniform via diameter on internal plane layers is not decoded", obj.id)
|
|
continue
|
|
size = raw_unit(diameters[lid-1]) if diameters and lid <= 32 else diameter
|
|
if size <= 0: raise ParseError("Invalid per-layer via diameter")
|
|
shape(obj, ellipse_path(pos.x,pos.y,size/2),bounds([pos],size/2),fill="layer",layer_id=str(lid),role="via")
|
|
if hole:
|
|
drill = shape(obj,ellipse_path(pos.x,pos.y,hole/2),bounds([pos],hole/2),fill="#08151b",layer_id="81",role="drill",stroke="none")
|
|
drill.layer_ids = obj.layer_ids
|
|
strings = wide_strings(read("WideStrings6/Data"))
|
|
for i, parts in typed("Texts6", 5, 2):
|
|
p = parts[0]
|
|
obj, c = common("Texts6", i, p, at(p, 226, "I", 0))
|
|
obj.kind = "text"
|
|
pos = xy(*at(p, 13, "ii"))
|
|
obj.position = pos
|
|
height = raw_unit(at(p, 21, "i"))
|
|
if height < 0: raise ParseError("Negative text height")
|
|
rotation = at(p, 27, "d")
|
|
mirror = bool(p[35])
|
|
font = "Arial"
|
|
bold = italic = False
|
|
is_comment = len(p) >= 123 and bool(p[40])
|
|
is_designator = len(p) >= 123 and bool(p[41])
|
|
if len(p) >= 123:
|
|
font = (
|
|
bytes(p[46:110]).decode("utf-16le", errors="replace").split("\0")[0]
|
|
or "Arial"
|
|
)
|
|
bold = bool(p[44])
|
|
italic = bool(p[45])
|
|
legacy = Reader(parts[1], "PCB text").pascal() if parts[1] else ""
|
|
value = (
|
|
strings.get(at(p, 115, "I", 4294967295), legacy)
|
|
if len(p) >= 123
|
|
else legacy
|
|
)
|
|
if c and is_designator:
|
|
c.designator = value or c.designator
|
|
if c.parameters.get("NAMEON", "TRUE") == "FALSE":
|
|
obj.visible = False
|
|
if c and is_comment:
|
|
c.value = value
|
|
c.comment = value
|
|
if c.parameters.get("COMMENTON", "FALSE") == "FALSE":
|
|
obj.visible = False
|
|
obj.properties.update(
|
|
{
|
|
"TEXT_HEIGHT": str(height),
|
|
"TEXT_MIRRORED": str(mirror),
|
|
"TEXT": value,
|
|
"ROTATION": str(rotation),
|
|
"FONT_NAME": font,
|
|
"DESIGNATOR": str(is_designator),
|
|
"COMMENT": str(is_comment),
|
|
}
|
|
)
|
|
if not obj.visible or not value:
|
|
continue
|
|
if len(p) > 123 and (p[43] == 2 or p[110] or p[123]):
|
|
report(
|
|
"text_style",
|
|
"PCB barcode/inverted text is approximated by readable text",
|
|
obj.id,
|
|
)
|
|
# Native font substitution is explicitly diagnosed below. Geometry remains vector text.
|
|
size = max(height * 1.35, 0.01)
|
|
w = max(size * 0.5, len(value) * size * 0.75)
|
|
corners = [
|
|
transform_point(Point(x=x, y=y), pos, rotation, mirror)
|
|
for x, y in [(0, 0), (w, 0), (w, -size), (0, -size)]
|
|
]
|
|
shape(
|
|
obj,
|
|
"",
|
|
bounds(corners),
|
|
position=pos,
|
|
text=value,
|
|
rotation=-rotation,
|
|
mirrored=mirror,
|
|
font_size=size,
|
|
font_family=font,
|
|
bold=bold,
|
|
italic=italic,
|
|
role="text",
|
|
)
|
|
for c in components:
|
|
if c.id in component_boxes:
|
|
c.bounds = union(component_boxes[c.id])
|
|
if not c.primitive_ids:
|
|
report(
|
|
"empty_footprint",
|
|
f"No supported 2D geometry for {c.designator or c.id}",
|
|
)
|
|
if len(primitives) > 250000:
|
|
raise ParseError("PCB drawable limit exceeded")
|
|
# Group layers once; stable within-layer priority keeps pours behind tracks/pads and drills above all.
|
|
priority = {
|
|
"board": 0,
|
|
"pour": 1,
|
|
"region": 2,
|
|
"fill": 3,
|
|
"track": 4,
|
|
"polygon_outline": 4,
|
|
"pad": 5,
|
|
"via": 6,
|
|
"text": 7,
|
|
"board_cutout": 8,
|
|
"drill": 9,
|
|
}
|
|
primitives.sort(
|
|
key=lambda p: (
|
|
1001 if p.role == "board_cutout" else layers[p.layer_id].order,
|
|
priority.get(p.role, 4),
|
|
)
|
|
)
|
|
supported = {
|
|
"FileHeader",
|
|
"FileHeaderSix",
|
|
"UniqueIDPrimitiveInformation",
|
|
"Board6",
|
|
"Components6",
|
|
"Nets6",
|
|
"Tracks6",
|
|
"Arcs6",
|
|
"Pads6",
|
|
"Vias6",
|
|
"Fills6",
|
|
"Texts6",
|
|
"WideStrings6",
|
|
"Regions6",
|
|
"ShapeBasedRegions6",
|
|
"Polygons6",
|
|
}
|
|
omitted = [
|
|
s["name"]
|
|
for s in streams
|
|
if s["size"]
|
|
and s["name"].endswith("/Data")
|
|
and s["name"].split("/")[0] not in supported
|
|
]
|
|
if omitted:
|
|
for stream in omitted:
|
|
header = read(stream.removesuffix('/Data')+'/Header')
|
|
count = at(header,0,'I') if len(header)==4 else 1
|
|
report("uninterpreted_stream", f"Not interpreted for this 2D viewer: {stream}", stream)
|
|
grouped[("uninterpreted_stream",f"Not interpreted for this 2D viewer: {stream}")].count = count
|
|
report(
|
|
"embedded_resources",
|
|
"Embedded STEP/3D models and fonts are not loaded or executed. This is a 2D PCB viewer.",
|
|
"",
|
|
"info",
|
|
)
|
|
report(
|
|
"pcb_text_metrics",
|
|
"PCB stroke/TrueType text uses browser font substitution; glyph metrics and special text styles can differ.",
|
|
)
|
|
report(
|
|
"pcb_colors",
|
|
"Layer colors use a viewer palette; stored layer identity, geometry, net and footprint ownership are retained.",
|
|
"",
|
|
"info",
|
|
)
|
|
report(
|
|
"pcb_scope",
|
|
"Saved copper fills are shown without repouring or executing design rules. Negative internal planes, mask-rule expansions and 3D bodies are not reconstructed.",
|
|
)
|
|
return Document(
|
|
schema_version="1.1",
|
|
document_type="pcb",
|
|
id=document_id,
|
|
filename=filename,
|
|
metadata={
|
|
"format": "ole",
|
|
"header": envelope,
|
|
"minor_version": board.get("VERSION"),
|
|
"sha256": hashlib.sha256(data).hexdigest(),
|
|
"input_bytes": len(data),
|
|
"streams": streams,
|
|
"record_counts": counts,
|
|
"record_count": sum(counts.values()),
|
|
"object_count": len(objects),
|
|
"record_variants": {k: dict(v) for k, v in variants.items()},
|
|
"component_count": len(components),
|
|
"primitive_count": len(primitives),
|
|
"units": "10mil",
|
|
"coordinates": "PCB integer /100000; X right, Y down",
|
|
"compatibility": "partial",
|
|
"header_properties": {
|
|
"ENVELOPE": envelope,
|
|
"BOARD_VERSION": board.get("VERSION", ""),
|
|
},
|
|
"net_count": len(nets),
|
|
"copper_stack": [str(lid) for lid in copper_ids],
|
|
"palette_source": "viewer",
|
|
"board_size_mm": {
|
|
"width": board_bounds.width * 0.254,
|
|
"height": board_bounds.height * 0.254,
|
|
},
|
|
"uninterpreted_streams": omitted,
|
|
},
|
|
sheet=Sheet(
|
|
name=filename,
|
|
background="#08151b",
|
|
bounds=board_bounds,
|
|
properties=board,
|
|
),
|
|
bounds=board_bounds,
|
|
components=components,
|
|
symbols=[],
|
|
objects=objects,
|
|
primitives=primitives,
|
|
diagnostics=diagnostics,
|
|
layers=sorted(layers.values(), key=lambda l: l.order, reverse=True),
|
|
nets=nets,
|
|
)
|