اضافه شدن قابلیت رندر پی سی بی
Verify / test (push) Failing after 20s

This commit is contained in:
2026-11-09 12:34:50 +03:30
والد a599086c11
کامیت 4eb2cb3d6f
33فایلهای تغییر یافته به همراه2470 افزوده شده و 28 حذف شده
@@ -0,0 +1,947 @@
"""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,
)