+2
-2
@@ -1,9 +1,9 @@
|
||||
{$DOMAIN} {
|
||||
@comparison path /api/compare /api/v1/comparisons
|
||||
@comparison path /api/compare /api/v1/comparisons /api/v1/pcbs/comparisons
|
||||
request_body @comparison {
|
||||
max_size 65MiB
|
||||
}
|
||||
@single not path /api/compare /api/v1/comparisons
|
||||
@single not path /api/compare /api/v1/comparisons /api/v1/pcbs/comparisons
|
||||
request_body @single {
|
||||
max_size 33MB
|
||||
}
|
||||
|
||||
+1
-1
@@ -148,4 +148,4 @@ The fixture downloader checksum-pins an actual upstream KiCad SchDoc. Unit-test
|
||||
|
||||
## Architecture and PCB extension
|
||||
|
||||
See [architecture](docs/ARCHITECTURE.md). `app/documents.py` is the parser registry, the schema preserves source records separately from vector primitives, `app/render.py` is the SVG serializer, and workers/storage are independent of schematic record decoding. A future PCB adapter can introduce layer-aware primitives and an appropriate viewer through these boundaries; no PCB parser or PCB rendering is shipped here. `.PcbDoc` uploads are rejected.
|
||||
See [architecture](docs/ARCHITECTURE.md). `app/documents.py` is the parser registry, the schema preserves source records separately from vector primitives, `app/render.py` is the SVG serializer, and workers/storage are independent of schematic record decoding. Native PCB support uses separate `POST /api/v1/pcbs/render` and `POST /api/v1/pcbs/comparisons` endpoints, with layer-aware fullscreen viewers and structural comparison. Existing SchDoc endpoints remain unchanged and reject PCB uploads. See [PCB API, supported format subset and deployment](docs/PCB.md).
|
||||
|
||||
@@ -7,3 +7,5 @@ Runtime packages are installed with upstream package metadata/license files: Fas
|
||||
Format research is linked from `docs/COMPATIBILITY.md`. No GPL KiCad importer code is incorporated. The checksum-pinned KiCad QA fixture is downloaded to an ignored directory and excluded from Docker/source distribution; consult upstream repository and fixture licensing before redistributing it. The user's local designs are not included in project source or the image.
|
||||
|
||||
Administrator-supplied fonts must have appropriate web-serving licenses. Embedded document images and symbols retain their owners' rights; uploading does not grant redistribution rights.
|
||||
|
||||
The native PCB binary/geometry/parser modules also originate from that independently written local checkout and are adapted to this service. Format layouts were rechecked against the [KiCad developer format reference](https://dev-docs.kicad.org/en/import-formats/altium/index.html). No KiCad GPL importer implementation or proprietary PCB sample is incorporated. See [PCB provenance and compatibility](docs/PCB.md).
|
||||
|
||||
@@ -4,6 +4,7 @@ import time
|
||||
from sqlalchemy import select, delete, or_
|
||||
from .db import Job, sessions, engine
|
||||
from .storage import storage
|
||||
from .job_types import comparison_sources
|
||||
|
||||
log=logging.getLogger(__name__)
|
||||
|
||||
@@ -15,8 +16,7 @@ def cleanup(batch=100):
|
||||
for job in db.scalars(q):
|
||||
try:
|
||||
storage().delete(job.input_key)
|
||||
if job.input_key.endswith('/comparison.json'):
|
||||
for side in ('old','new'): storage().delete(f'{job.id}/{side}.SchDoc')
|
||||
for source in comparison_sources(job.input_key): storage().delete(source)
|
||||
for attempt in (1,2):
|
||||
for name in ('data.json','drawing.svg','old.svg','new.svg'):
|
||||
storage().delete(f'{job.id}/attempt-{attempt}/{name}')
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
from typing import Protocol
|
||||
from .models.schematic import Document
|
||||
from .parser.schdoc_parser import parse_schdoc
|
||||
from .parser.pcb_parser import parse_pcbdoc
|
||||
from .parser.record_decoder import ParseError
|
||||
|
||||
class DocumentParser(Protocol):
|
||||
def __call__(self, data: bytes, filename: str, identifier: str, **options) -> Document: ...
|
||||
|
||||
PARSERS: dict[str, DocumentParser] = {'schematic': parse_schdoc}
|
||||
PARSERS: dict[str, DocumentParser] = {'schematic': parse_schdoc, 'pcb': parse_pcbdoc}
|
||||
|
||||
def parse_document(data: bytes, filename: str, identifier: str, document_type='schematic', **options) -> Document:
|
||||
parser=PARSERS.get(document_type)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Server-owned immutable input names classify jobs, including legacy uploads."""
|
||||
INPUT_KINDS = {
|
||||
'input.SchDoc': 'schematic_render',
|
||||
'comparison.json': 'schematic_comparison',
|
||||
'input.PcbDoc': 'pcb_render',
|
||||
'pcb-comparison.json': 'pcb_comparison',
|
||||
}
|
||||
|
||||
|
||||
def job_kind(input_key):
|
||||
return INPUT_KINDS.get(input_key.rsplit('/', 1)[-1])
|
||||
|
||||
|
||||
def comparison_sources(input_key):
|
||||
kind = job_kind(input_key)
|
||||
if kind not in ('schematic_comparison', 'pcb_comparison'):
|
||||
return ()
|
||||
prefix = input_key.rsplit('/', 1)[0]
|
||||
suffix = 'PcbDoc' if kind == 'pcb_comparison' else 'SchDoc'
|
||||
return tuple(f'{prefix}/{side}.{suffix}' for side in ('old', 'new'))
|
||||
@@ -22,6 +22,7 @@ from .fonts import fonts, stylesheet
|
||||
from .models.api import UploadResponse, RenderStatus, ErrorEnvelope
|
||||
from .models.schematic import Document
|
||||
from .uploads import read_schematic
|
||||
from .job_types import job_kind
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[2] / 'frontend'
|
||||
ID = re.compile(r'^[a-f0-9]{48}$')
|
||||
@@ -54,7 +55,7 @@ class UploadLimit:
|
||||
def __init__(self, app): self.app = app
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope['type'] != 'http': return await self.app(scope, receive, send)
|
||||
file_count = 2 if scope['path'] in ('/api/compare','/api/v1/comparisons') else 1
|
||||
file_count = 2 if scope['path'] in ('/api/compare','/api/v1/comparisons','/api/v1/pcbs/comparisons') else 1
|
||||
limit = file_count * settings().max_upload_bytes + 1024 * 1024
|
||||
received = 0
|
||||
async def bounded_receive():
|
||||
@@ -71,7 +72,7 @@ app.add_middleware(UploadLimit)
|
||||
@app.middleware('http')
|
||||
async def security(request, call_next):
|
||||
# Reject unauthorized POST bodies before the multipart parser reads them.
|
||||
if request.method == 'POST' and request.url.path in ('/api/v1/schematics/render','/api/compare','/api/v1/comparisons'):
|
||||
if request.method == 'POST' and request.url.path in ('/api/v1/schematics/render','/api/compare','/api/v1/comparisons','/api/v1/pcbs/render','/api/v1/pcbs/comparisons'):
|
||||
if settings().api_key and not authenticated(request.headers.get('x-api-key')):
|
||||
return JSONResponse({'success': False, 'error': {'code': 'unauthorized', 'message': 'Provide a valid X-API-Key header'}}, 401)
|
||||
response = await call_next(request)
|
||||
@@ -109,10 +110,11 @@ def health():
|
||||
with sessions()() as db: db.execute(text('SELECT 1'))
|
||||
return {'status': 'ok'}
|
||||
|
||||
def authorize(identifier, key, token, *, comparison=False):
|
||||
def authorize(identifier, key, token, *, comparison=False, resource_kind=None):
|
||||
if not ID.fullmatch(identifier): error('not_found', 'Schematic not found', 404)
|
||||
with sessions()() as db: job = db.get(Job, identifier)
|
||||
if job is None or job.input_key.endswith('/comparison.json') != comparison:
|
||||
expected_kind = resource_kind or ('schematic_comparison' if comparison else 'schematic_render')
|
||||
if job is None or job_kind(job.input_key) != expected_kind:
|
||||
error('not_found', 'Schematic not found', 404)
|
||||
# Private existence and expiry are not disclosed to unauthenticated callers.
|
||||
if job.access == 'private' and not (authenticated(key) or (token and hmac.compare_digest(job.token_hash, digest(token)))):
|
||||
@@ -201,7 +203,7 @@ def index(): return FileResponse(FRONTEND / 'index.html')
|
||||
|
||||
@app.get('/assets/{name}', include_in_schema=False)
|
||||
def asset(name: str):
|
||||
if name not in {'viewer.js','viewer.css','upload.js','upload.css','camera.js','compare.js','compare.css','compare-upload.js','interactions.js'}: error('not_found', 'Asset not found', 404)
|
||||
if name not in {'viewer.js','viewer.css','upload.js','upload.css','camera.js','compare.js','compare.css','compare-upload.js','interactions.js','pcb.js','pcb.css','pcb-layers.js'}: error('not_found', 'Asset not found', 404)
|
||||
return FileResponse(FRONTEND / name)
|
||||
|
||||
@app.get('/docs', include_in_schema=False)
|
||||
@@ -224,3 +226,5 @@ async def database_error(request, exc):
|
||||
# Comparison jobs share the existing queue, access policy and error envelope.
|
||||
from .compare_api import register_comparison_routes
|
||||
register_comparison_routes(app, authorize=authorize, require_api=require_api, error=error)
|
||||
from .pcb_api import register_pcb_routes
|
||||
register_pcb_routes(app, authorize=authorize, require_api=require_api, error=error)
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""PCB contracts extend geometry without changing the schematic wire schema."""
|
||||
from typing import Literal
|
||||
from pydantic import BaseModel, Field
|
||||
from .schematic import Document, Primitive, SchematicObject, Point, Bounds
|
||||
from .api import RenderStatus
|
||||
|
||||
|
||||
class PCBPrimitive(Primitive):
|
||||
layer_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PCBObject(SchematicObject):
|
||||
native_uid: str = ''
|
||||
layer_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class Footprint(BaseModel):
|
||||
id: str
|
||||
designator: str = ''
|
||||
name: str = ''
|
||||
value: str = ''
|
||||
comment: str = ''
|
||||
description: str = ''
|
||||
library_reference: str = ''
|
||||
source_library: str = ''
|
||||
position: Point
|
||||
rotation: float = 0
|
||||
mirrored: bool = False
|
||||
bounds: Bounds
|
||||
parameters: dict[str, str] = Field(default_factory=dict)
|
||||
primitive_ids: list[str] = Field(default_factory=list)
|
||||
object_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PCBData(Document):
|
||||
document_type: Literal['pcb'] = 'pcb'
|
||||
components: list[Footprint]
|
||||
primitives: list[PCBPrimitive]
|
||||
objects: list[PCBObject]
|
||||
|
||||
|
||||
class PCBStatus(RenderStatus):
|
||||
document_type: Literal['pcb', 'pcb_comparison']
|
||||
status_url: str
|
||||
layers: list[dict] | None = None
|
||||
net_count: int | None = None
|
||||
|
||||
|
||||
class PCBUploadResponse(PCBStatus):
|
||||
viewer_token: str | None = None
|
||||
|
||||
|
||||
class PCBComparisonStatus(PCBStatus):
|
||||
comparison_id: str
|
||||
url: str
|
||||
summary: dict[Literal['added','removed','modified','moved'], int] | None = None
|
||||
total_changed: int | None = None
|
||||
changes: list[dict] | None = None
|
||||
old: dict | None = None
|
||||
new: dict | None = None
|
||||
nets: dict | None = None
|
||||
matching: dict | None = None
|
||||
limitations: list[str] | None = None
|
||||
|
||||
|
||||
class PCBComparisonUploadResponse(PCBComparisonStatus):
|
||||
viewer_token: str | None = None
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Checked Altium PCB framing. No embedded content is decompressed or executed."""
|
||||
|
||||
import math
|
||||
import struct
|
||||
from .record_decoder import ParseError, properties
|
||||
|
||||
MAX_ITEMS = 100_000
|
||||
MAX_BLOCK = 16 * 1024 * 1024
|
||||
|
||||
|
||||
class Reader:
|
||||
def __init__(self, data, label="PCB"):
|
||||
self.data = memoryview(data)
|
||||
self.offset = 0
|
||||
self.label = label
|
||||
|
||||
@property
|
||||
def remaining(self):
|
||||
return len(self.data) - self.offset
|
||||
|
||||
def take(self, length):
|
||||
if length < 0 or length > self.remaining:
|
||||
raise ParseError(
|
||||
f"{self.label}: truncated data at byte {self.offset}, need {length}"
|
||||
)
|
||||
start = self.offset
|
||||
self.offset += length
|
||||
return self.data[start : self.offset]
|
||||
|
||||
def read(self, fmt):
|
||||
result = struct.unpack("<" + fmt, self.take(struct.calcsize("<" + fmt)))
|
||||
if any(isinstance(v, float) and not math.isfinite(v) for v in result):
|
||||
raise ParseError(f"{self.label}: non-finite binary number")
|
||||
return result[0] if len(result) == 1 else result
|
||||
|
||||
def block(self):
|
||||
length = self.read("I")
|
||||
if length > MAX_BLOCK:
|
||||
raise ParseError(f"{self.label}: subrecord exceeds {MAX_BLOCK} bytes")
|
||||
return self.take(length)
|
||||
|
||||
def pascal(self):
|
||||
return bytes(self.take(self.read("B"))).decode("cp1252", errors="replace")
|
||||
|
||||
|
||||
def text_records(data, name):
|
||||
reader = Reader(data, name)
|
||||
result = []
|
||||
while reader.remaining:
|
||||
if len(result) >= MAX_ITEMS:
|
||||
raise ParseError(f"{name}: excessive record count")
|
||||
result.append(properties(bytes(reader.block())))
|
||||
return result
|
||||
|
||||
|
||||
def binary_records(data, name, tag, blocks=1):
|
||||
reader = Reader(data, name)
|
||||
index = 0
|
||||
while reader.remaining:
|
||||
if index >= MAX_ITEMS:
|
||||
raise ParseError(f"{name}: excessive record count")
|
||||
actual = reader.read("B")
|
||||
if actual != tag:
|
||||
raise ParseError(
|
||||
f"{name}: unsupported tag {actual} at record {index}, expected {tag}"
|
||||
)
|
||||
yield index, [bytes(reader.block()) for _ in range(blocks)]
|
||||
index += 1
|
||||
|
||||
|
||||
def wide_strings(data):
|
||||
reader = Reader(data, "WideStrings6")
|
||||
result = {}
|
||||
while reader.remaining:
|
||||
if len(result) >= MAX_ITEMS:
|
||||
raise ParseError("Excessive wide string count")
|
||||
index = reader.read("I")
|
||||
length = reader.read("I")
|
||||
# Altium writes empty strings with length=2 but no payload in some exports.
|
||||
if (
|
||||
length == 2
|
||||
and bytes(reader.data[reader.offset : reader.offset + 2]) != b"\0\0"
|
||||
):
|
||||
payload = b""
|
||||
else:
|
||||
if length > MAX_BLOCK:
|
||||
raise ParseError("Wide string exceeds size limit")
|
||||
payload = reader.take(length)
|
||||
if len(payload) % 2:
|
||||
raise ParseError("Odd UTF-16 string length")
|
||||
result[index] = (
|
||||
bytes(payload).decode("utf-16le", errors="replace").rstrip("\x00")
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def at(data, offset, fmt, default=None):
|
||||
size = struct.calcsize("<" + fmt)
|
||||
if offset < 0 or offset + size > len(data):
|
||||
if default is not None:
|
||||
return default
|
||||
raise ParseError(f"Truncated PCB field at offset {offset}, need {size} bytes")
|
||||
values = struct.unpack_from("<" + fmt, data, offset)
|
||||
if any(isinstance(v, float) and not math.isfinite(v) for v in values):
|
||||
raise ParseError("Non-finite PCB field")
|
||||
return values[0] if len(values) == 1 else values
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Cheap bounded PCB envelope validation; complete decoding stays in the worker."""
|
||||
import io
|
||||
import struct
|
||||
import olefile
|
||||
from .record_decoder import ParseError
|
||||
from .pcb_binary import Reader, text_records
|
||||
|
||||
SIGNATURE = b'\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1'
|
||||
|
||||
|
||||
def identify_pcb(data):
|
||||
if not data.startswith(SIGNATURE):
|
||||
raise ParseError('PcbDoc must be an OLE compound document')
|
||||
try:
|
||||
with olefile.OleFileIO(io.BytesIO(data), raise_defects=olefile.DEFECT_INCORRECT) as ole:
|
||||
if len(ole.listdir()) > 1024:
|
||||
raise ParseError('Excessive PCB stream count')
|
||||
for name, limit in [('FileHeaderSix', 4096), ('Board6/Data', 4 * 1024**2)]:
|
||||
if not ole.exists(name) or ole.get_type(name) != 2:
|
||||
raise ParseError(f'Missing PCB stream {name}')
|
||||
if ole.get_size(name) > limit:
|
||||
raise ParseError(f'PCB stream {name} exceeds size limit')
|
||||
reader = Reader(ole.openstream('FileHeaderSix').read(), 'FileHeaderSix')
|
||||
length = reader.read('I'); envelope = reader.pascal()
|
||||
if length != len(envelope) or envelope != 'PCB 6.0 Binary File':
|
||||
raise ParseError('Unsupported PCB file envelope')
|
||||
records = text_records(ole.openstream('Board6/Data').read(), 'Board6')
|
||||
if len(records) != 1 or records[0].get('KIND') != 'Protel_Advanced_PCB':
|
||||
raise ParseError('Invalid Board6 identification')
|
||||
if records[0].get('VERSION') not in ('5.0', '5.01', '6.0'):
|
||||
raise ParseError('Unsupported PCB board version')
|
||||
except (OSError, IOError, ValueError, IndexError, struct.error) as exc:
|
||||
if isinstance(exc, ParseError): raise
|
||||
raise ParseError('Invalid PCB compound document') from exc
|
||||
|
||||
|
||||
def contains_pcb(data):
|
||||
"""Reject PCB data renamed to SchDoc without altering legacy corrupt-file handling."""
|
||||
if not data.startswith(SIGNATURE): return False
|
||||
try:
|
||||
with olefile.OleFileIO(io.BytesIO(data)) as ole:
|
||||
return ole.exists('Board6/Data')
|
||||
except (OSError, ValueError, IndexError, struct.error):
|
||||
return False
|
||||
@@ -0,0 +1,155 @@
|
||||
import math
|
||||
import re
|
||||
from app.models.schematic import Point, Bounds
|
||||
from .geometry import bounds, poly_path, transform_point
|
||||
from .record_decoder import ParseError
|
||||
|
||||
# PCB integer coordinate unit = 1/10000 mil. Common viewer unit = 10 mil.
|
||||
BINARY_SCALE = 100000
|
||||
|
||||
|
||||
def raw_unit(value):
|
||||
n = value / BINARY_SCALE
|
||||
if not math.isfinite(n) or abs(n) > 100000:
|
||||
raise ParseError("Invalid or out-of-range PCB coordinate")
|
||||
return n
|
||||
|
||||
|
||||
def unit(value):
|
||||
match = re.fullmatch(r"\s*([-+\d.eE]+)\s*(mil|mm|in|inch)?\s*", str(value), re.I)
|
||||
if not match:
|
||||
raise ParseError(f"Invalid PCB unit: {str(value)[:80]}")
|
||||
n = float(match[1])
|
||||
suffix = (match[2] or "mil").lower()
|
||||
factor = {"mil": 0.1, "mm": 1 / 0.254, "in": 100, "inch": 100}[suffix]
|
||||
result = n * factor
|
||||
if not math.isfinite(result) or abs(result) > 100000:
|
||||
raise ParseError("PCB unit out of bounds")
|
||||
return result
|
||||
|
||||
|
||||
def xy(x, y):
|
||||
return Point(x=raw_unit(x), y=-raw_unit(y))
|
||||
|
||||
|
||||
def ellipse_path(cx, cy, rx, ry=None):
|
||||
ry = rx if ry is None else ry
|
||||
return f"M{cx - rx:.15g},{cy:.15g} A{rx:.15g},{ry:.15g} 0 1 0 {cx + rx:.15g},{cy:.15g} A{rx:.15g},{ry:.15g} 0 1 0 {cx - rx:.15g},{cy:.15g} Z"
|
||||
|
||||
|
||||
def arc_path(center, radius, start, end):
|
||||
if not all(math.isfinite(v) for v in (radius,start,end)): raise ParseError("Non-finite arc geometry")
|
||||
if radius <= 0:
|
||||
raise ParseError("Arc radius must be positive")
|
||||
if abs(end - start) >= 359.99999 or start == end:
|
||||
return ellipse_path(center.x, center.y, radius)
|
||||
a, b = math.radians(start), math.radians(end)
|
||||
x1, y1 = center.x + radius * math.cos(a), center.y - radius * math.sin(a)
|
||||
x2, y2 = center.x + radius * math.cos(b), center.y - radius * math.sin(b)
|
||||
return f"M{x1:.15g},{y1:.15g} A{radius:.15g},{radius:.15g} 0 {int((end - start) % 360 > 180)} 0 {x2:.15g},{y2:.15g}"
|
||||
|
||||
|
||||
def pad_shape(position, w, h, shape, rotation, radius_percent=0):
|
||||
if w <= 0 or h <= 0:
|
||||
raise ParseError("Pad dimensions must be positive")
|
||||
# Build in local coordinates, then place absolute points. Exact arcs use a path transform.
|
||||
angle = math.radians(-rotation)
|
||||
|
||||
def q(x, y):
|
||||
return Point(
|
||||
x=position.x + x * math.cos(angle) - y * math.sin(angle),
|
||||
y=position.y + x * math.sin(angle) + y * math.cos(angle),
|
||||
)
|
||||
|
||||
def pair(p):
|
||||
return f"{p.x:.15g},{p.y:.15g}"
|
||||
|
||||
if (
|
||||
shape == 1
|
||||
): # Circle or oval/obround; unequal sizes form a capsule, not an ellipse.
|
||||
r = min(w, h) / 2
|
||||
elif shape == 9:
|
||||
r = min(w, h) * radius_percent / 200
|
||||
else:
|
||||
r = 0
|
||||
if shape == 3:
|
||||
# Altium octagon chamfers at 45 degrees.
|
||||
c = min(w, h) / (2 + math.sqrt(2))
|
||||
pts = [
|
||||
q(x, y)
|
||||
for x, y in [
|
||||
(-w / 2 + c, -h / 2),
|
||||
(w / 2 - c, -h / 2),
|
||||
(w / 2, -h / 2 + c),
|
||||
(w / 2, h / 2 - c),
|
||||
(w / 2 - c, h / 2),
|
||||
(-w / 2 + c, h / 2),
|
||||
(-w / 2, h / 2 - c),
|
||||
(-w / 2, -h / 2 + c),
|
||||
]
|
||||
]
|
||||
return poly_path(pts, True), bounds(pts)
|
||||
if shape not in (1, 2, 9):
|
||||
raise ParseError(f"Unsupported pad shape {shape}")
|
||||
corners = [
|
||||
q(x, y)
|
||||
for x, y in [(-w / 2, -h / 2), (w / 2, -h / 2), (w / 2, h / 2), (-w / 2, h / 2)]
|
||||
]
|
||||
if r <= 0:
|
||||
return poly_path(corners, True), bounds(corners)
|
||||
pts = [
|
||||
q(x, y)
|
||||
for x, y in [
|
||||
(-w / 2 + r, -h / 2),
|
||||
(w / 2 - r, -h / 2),
|
||||
(w / 2, -h / 2 + r),
|
||||
(w / 2, h / 2 - r),
|
||||
(w / 2 - r, h / 2),
|
||||
(-w / 2 + r, h / 2),
|
||||
(-w / 2, h / 2 - r),
|
||||
(-w / 2, -h / 2 + r),
|
||||
]
|
||||
]
|
||||
d = f"M{pair(pts[0])} L{pair(pts[1])} A{r:.15g},{r:.15g} {-rotation:.15g} 0 1 {pair(pts[2])} L{pair(pts[3])} A{r:.15g},{r:.15g} 0 0 1 {pair(pts[4])} L{pair(pts[5])} A{r:.15g},{r:.15g} 0 0 1 {pair(pts[6])} L{pair(pts[7])} A{r:.15g},{r:.15g} 0 0 1 {pair(pts[0])} Z"
|
||||
return d, bounds(corners)
|
||||
|
||||
|
||||
def property_outline(p):
|
||||
vertices = []
|
||||
for i in range(20000):
|
||||
if f"VX{i}" not in p:
|
||||
break
|
||||
if f"VY{i}" not in p: raise ParseError("PCB outline vertex is missing Y")
|
||||
if int(p.get(f"KIND{i}",0)) and not all(f"{key}{i}" in p for key in ("CX","CY","R","SA","EA")):
|
||||
raise ParseError("PCB outline arc is missing native geometry")
|
||||
vertices.append(
|
||||
(
|
||||
Point(x=unit(p[f"VX{i}"]), y=-unit(p.get(f"VY{i}", "0mil"))),
|
||||
int(p.get(f"KIND{i}", 0)),
|
||||
Point(
|
||||
x=unit(p.get(f"CX{i}", "0mil")), y=-unit(p.get(f"CY{i}", "0mil"))
|
||||
),
|
||||
unit(p.get(f"R{i}", "0mil")),
|
||||
float(p.get(f"SA{i}", 0)),
|
||||
float(p.get(f"EA{i}", 0)),
|
||||
)
|
||||
)
|
||||
if not vertices:
|
||||
return "", bounds([])
|
||||
pts = [v[0] for v in vertices]
|
||||
commands = [f"M{pts[0].x:.15g},{pts[0].y:.15g}"]
|
||||
for i, (pos, kind, center, r, sa, ea) in enumerate(vertices):
|
||||
if kind and r > 0:
|
||||
# Arc belongs to its starting vertex, ending at the next vertex.
|
||||
arc = arc_path(center, r, sa, ea)
|
||||
commands.append("L" + arc.split(" ", 1)[0][1:])
|
||||
commands.append(arc.split(" ", 1)[1])
|
||||
pts.extend(
|
||||
[
|
||||
Point(x=center.x - r, y=center.y - r),
|
||||
Point(x=center.x + r, y=center.y + r),
|
||||
]
|
||||
)
|
||||
nxt = vertices[(i + 1) % len(vertices)][0]
|
||||
commands.append(f"L{nxt.x:.15g},{nxt.y:.15g}")
|
||||
return " ".join(commands) + " Z", bounds(pts)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Two PCB creation endpoints using the existing durable queue and capabilities."""
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Literal
|
||||
from fastapi import File, Form, Header, UploadFile
|
||||
from fastapi.responses import FileResponse, JSONResponse, Response
|
||||
from sqlalchemy import select, func, text
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
from .config import settings
|
||||
from .db import Job, sessions
|
||||
from .storage import storage
|
||||
from .uploads import read_pcb
|
||||
from .models.pcb import PCBData, PCBStatus, PCBUploadResponse, PCBComparisonStatus, PCBComparisonUploadResponse
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[2] / 'frontend'
|
||||
|
||||
|
||||
def register_pcb_routes(app, *, authorize, require_api, error):
|
||||
def state(job, comparison=False):
|
||||
base = settings().public_url
|
||||
api = f'/api/v1/pcbs/comparisons/{job.id}' if comparison else f'/api/v1/pcbs/{job.id}'
|
||||
view = f'/compare/pcb/{job.id}' if comparison else f'/view/pcb/{job.id}'
|
||||
result = dict(success=job.status != 'failed', id=job.id, status=job.status,
|
||||
document_type='pcb_comparison' if comparison else 'pcb',
|
||||
viewer_url=base+view, status_url=base+api+('' if comparison else '/status'),
|
||||
access=job.access, expires_at=job.expires_at)
|
||||
if comparison: result.update(comparison_id=job.id, url=base+view)
|
||||
if job.summary: result.update(json.loads(job.summary))
|
||||
if job.error_code: result['error'] = dict(code=job.error_code, message=job.error_message)
|
||||
return result
|
||||
|
||||
async def create(files, access, expires_in, comparison):
|
||||
ttl = expires_in or settings().link_ttl
|
||||
if ttl > settings().link_ttl: error('invalid_expiry','expires_in exceeds configured link lifetime',422)
|
||||
sources = {}
|
||||
try:
|
||||
for field, file in files.items(): sources[field] = await read_pcb(file)
|
||||
finally:
|
||||
for file in files.values(): await file.close()
|
||||
identifier = secrets.token_hex(24); token = secrets.token_urlsafe(32); now = time.time()
|
||||
input_name = 'pcb-comparison.json' if comparison else 'input.PcbDoc'
|
||||
job = Job(id=identifier, filename='PCB comparison' if comparison else sources['file'][0],
|
||||
status='queued', access=access, token_hash=hashlib.sha256(token.encode()).hexdigest(),
|
||||
created_at=now, updated_at=now, expires_at=now+ttl, attempt=0, input_key=f'{identifier}/{input_name}')
|
||||
if comparison:
|
||||
bodies = {f'{identifier}/{side}.PcbDoc':sources[side+'_file'][1] for side in ('old','new')}
|
||||
bodies[job.input_key] = json.dumps(dict(schema_version='1.0',document_type='pcb_comparison',
|
||||
old_filename=sources['old_file'][0],new_filename=sources['new_file'][0])).encode()
|
||||
else: bodies = {job.input_key:sources['file'][1]}
|
||||
def persist():
|
||||
written = []
|
||||
try:
|
||||
with sessions().begin() as db:
|
||||
if db.bind.dialect.name == 'postgresql': db.execute(text('SELECT pg_advisory_xact_lock(84721309)'))
|
||||
pending = db.scalar(select(func.count()).select_from(Job).where(Job.status.in_(['queued','processing']),Job.expires_at>now))
|
||||
if pending >= settings().max_pending: error('queue_full','Rendering queue is full; try again later',503)
|
||||
for key, body in bodies.items():
|
||||
# Include the attempted key so a write-then-raise backend is also cleaned up.
|
||||
written.append(key)
|
||||
try: storage().put(key,body)
|
||||
except Exception: error('storage_unavailable','Upload storage unavailable',503)
|
||||
db.add(job); db.flush()
|
||||
except Exception:
|
||||
for key in written:
|
||||
try: storage().delete(key)
|
||||
except Exception: pass
|
||||
raise
|
||||
await run_in_threadpool(persist)
|
||||
result = state(job,comparison)
|
||||
if access == 'private':
|
||||
result['viewer_token'] = token; result['viewer_url'] += '#token='+token
|
||||
if comparison: result['url'] = result['viewer_url']
|
||||
return JSONResponse(result,status_code=202,headers={'Location':result['status_url'],'Retry-After':'1'})
|
||||
|
||||
@app.post('/api/v1/pcbs/render', status_code=202, response_model=PCBUploadResponse, response_model_exclude_none=True, tags=['PCB'])
|
||||
async def render(file:Annotated[UploadFile,File()], access:Annotated[Literal['private','unlisted'],Form()]='private',
|
||||
expires_in:Annotated[int|None,Form(gt=0)]=None, x_api_key:Annotated[str|None,Header()]=None):
|
||||
require_api(x_api_key)
|
||||
return await create({'file':file},access,expires_in,False)
|
||||
|
||||
@app.post('/api/v1/pcbs/comparisons',status_code=202,response_model=PCBComparisonUploadResponse,response_model_exclude_none=True,tags=['PCB'])
|
||||
async def compare(old_file:Annotated[UploadFile,File()],new_file:Annotated[UploadFile,File()],
|
||||
access:Annotated[Literal['private','unlisted'],Form()]='private',
|
||||
expires_in:Annotated[int|None,Form(gt=0)]=None,x_api_key:Annotated[str|None,Header()]=None):
|
||||
require_api(x_api_key)
|
||||
return await create({'old_file':old_file,'new_file':new_file},access,expires_in,True)
|
||||
|
||||
def get_job(identifier,key,token,comparison=False):
|
||||
return authorize(identifier,key,token,resource_kind='pcb_comparison' if comparison else 'pcb_render')
|
||||
|
||||
def artifact(job,name):
|
||||
if job.status == 'failed': error(job.error_code,job.error_message,422)
|
||||
if job.status != 'completed': error('not_ready','PCB processing is still in progress',409)
|
||||
try: body = storage().get(job.output_prefix+'/'+name)
|
||||
except Exception: error('storage_unavailable','PCB artifact unavailable',503)
|
||||
return Response(body,media_type='application/json' if name.endswith('.json') else 'image/svg+xml')
|
||||
|
||||
@app.get('/api/v1/pcbs/comparisons/{identifier}',response_model=PCBComparisonStatus,response_model_exclude_none=True,tags=['PCB'])
|
||||
def comparison_status(identifier:str,x_api_key:Annotated[str|None,Header()]=None,x_viewer_token:Annotated[str|None,Header()]=None):
|
||||
job=get_job(identifier,x_api_key,x_viewer_token,True); result=state(job,True)
|
||||
if job.status == 'completed':
|
||||
try: result.update(json.loads(storage().get(job.output_prefix+'/data.json')))
|
||||
except Exception: error('storage_unavailable','PCB comparison unavailable',503)
|
||||
return result
|
||||
|
||||
@app.get('/api/v1/pcbs/comparisons/{identifier}/svg/{side}',response_class=Response,tags=['PCB'])
|
||||
def comparison_svg(identifier:str,side:Literal['old','new'],x_api_key:Annotated[str|None,Header()]=None,x_viewer_token:Annotated[str|None,Header()]=None):
|
||||
return artifact(get_job(identifier,x_api_key,x_viewer_token,True),side+'.svg')
|
||||
|
||||
@app.get('/api/v1/pcbs/{identifier}/status',response_model=PCBStatus,response_model_exclude_none=True,tags=['PCB'])
|
||||
def status(identifier:str,x_api_key:Annotated[str|None,Header()]=None,x_viewer_token:Annotated[str|None,Header()]=None):
|
||||
return state(get_job(identifier,x_api_key,x_viewer_token))
|
||||
|
||||
@app.get('/api/v1/pcbs/{identifier}/data',response_model=PCBData,tags=['PCB'])
|
||||
def data(identifier:str,x_api_key:Annotated[str|None,Header()]=None,x_viewer_token:Annotated[str|None,Header()]=None):
|
||||
return artifact(get_job(identifier,x_api_key,x_viewer_token),'data.json')
|
||||
|
||||
@app.get('/api/v1/pcbs/{identifier}/svg',response_class=Response,tags=['PCB'])
|
||||
def svg(identifier:str,x_api_key:Annotated[str|None,Header()]=None,x_viewer_token:Annotated[str|None,Header()]=None):
|
||||
return artifact(get_job(identifier,x_api_key,x_viewer_token),'drawing.svg')
|
||||
|
||||
@app.get('/view/pcb/{identifier}',include_in_schema=False)
|
||||
@app.get('/compare/pcb/{identifier}',include_in_schema=False)
|
||||
def page(identifier:str):
|
||||
if not re.fullmatch('[a-f0-9]{48}',identifier): error('not_found','PCB not found',404)
|
||||
return FileResponse(FRONTEND/'pcb.html')
|
||||
@@ -0,0 +1,134 @@
|
||||
"""PCB object comparison; reuses only the format-independent entity matcher."""
|
||||
import math
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from .diff import Entity, digest, match_entities
|
||||
|
||||
TOKEN = re.compile(r'[MLAZ]|[-+]?(?:\d*\.\d+|\d+\.?\d*)(?:[eE][-+]?\d+)?')
|
||||
IGNORE = {'NET_INDEX','COMPONENT_INDEX','POLYGON_INDEX','INDEX','UNIQUEID','SOURCEUNIQUEID',
|
||||
'LOCKED','FLAGS_1','FLAGS_2','OUTLINE_PATH','X','Y','ROTATION','LAYER'}
|
||||
|
||||
|
||||
def number(n):
|
||||
return round(float(n), 7) or 0.0
|
||||
|
||||
|
||||
def point(x, y, origin, rotation=0, mirrored=False):
|
||||
x,y = x-origin[0],y-origin[1]
|
||||
a=math.radians(rotation)
|
||||
x,y=x*math.cos(a)-y*math.sin(a),x*math.sin(a)+y*math.cos(a)
|
||||
return (number(-x if mirrored else x),number(y))
|
||||
|
||||
|
||||
def path_signature(path, origin, rotation=0, mirrored=False):
|
||||
"""Canonical local geometry for the M/L/A/Z vocabulary emitted by the PCB parser."""
|
||||
tokens=TOKEN.findall(path); out=[]; i=0
|
||||
while i<len(tokens):
|
||||
cmd=tokens[i]; i+=1
|
||||
if cmd in ('M','L'):
|
||||
out.append((cmd,*point(float(tokens[i]),float(tokens[i+1]),origin,rotation,mirrored)));i+=2
|
||||
elif cmd=='A':
|
||||
rx,ry,angle,large,sweep,x,y=map(float,tokens[i:i+7]);i+=7
|
||||
angle=(angle+rotation)%180
|
||||
if mirrored: angle=(-angle)%180;sweep=1-sweep
|
||||
out.append(('A',number(rx),number(ry),number(angle),int(large),int(sweep),*point(x,y,origin,rotation,mirrored)))
|
||||
elif cmd=='Z':out.append(('Z',))
|
||||
else: raise ValueError('Unexpected PCB SVG path command')
|
||||
# A straight track has no electrical direction.
|
||||
if len(out)==2 and out[0][0]=='M' and out[1][0]=='L':
|
||||
ends=sorted([out[0][1:],out[1][1:]])
|
||||
out=[('M',*ends[0]),('L',*ends[1])]
|
||||
return out
|
||||
|
||||
|
||||
def entities(doc):
|
||||
primitive_map={p.id:p for p in doc.primitives};children=defaultdict(list)
|
||||
for o in doc.objects:
|
||||
if o.component_id:children[o.component_id].append(o)
|
||||
|
||||
def properties(o,origin,rotation=0,mirrored=False):
|
||||
p={k:v for k,v in o.properties.items() if k not in IGNORE and not k.endswith('_INDEX')}
|
||||
# Region definitions may be invisible; their real retained boundary still participates.
|
||||
if 'OUTLINE_PATH' in o.properties:
|
||||
p['outline']=path_signature(o.properties['OUTLINE_PATH'],origin,rotation,mirrored)
|
||||
if 'ROTATION' in o.properties:p['rotation']=number((float(o.properties['ROTATION'])-rotation)%360)
|
||||
if o.kind=='board_outline':p={}
|
||||
if o.kind=='polygon_definition':
|
||||
p={k:v for k,v in p.items() if not re.fullmatch(r'(?:VX|VY|CX|CY|SA|EA|R|KIND)\d+',k) and k!='NET'}
|
||||
p['net']=next((n.name for n in doc.nets if n.id==o.net_id),None)
|
||||
from .parser.pcb_geometry import property_outline
|
||||
p['outline']=path_signature(property_outline(o.properties)[0],origin,rotation,mirrored)
|
||||
geometry=[]
|
||||
for pid in o.primitive_ids:
|
||||
primitive=primitive_map[pid]
|
||||
item=dict(kind=primitive.kind,layer=primitive.layer_id,layers=getattr(primitive,'layer_ids',[]),
|
||||
width=primitive.width,fill_rule=primitive.fill_rule,role=primitive.role,
|
||||
net=next((n.name for n in doc.nets if n.id==primitive.net_id),None))
|
||||
if primitive.kind=='path':item['path']=path_signature(primitive.path,origin,rotation,mirrored)
|
||||
else:item.update(text=primitive.text,position=point(primitive.position.x,primitive.position.y,origin,rotation,mirrored),
|
||||
rotation=number((primitive.rotation+rotation)%360),mirrored=primitive.mirrored != mirrored,
|
||||
font=primitive.font_family,size=primitive.font_size,bold=primitive.bold,italic=primitive.italic)
|
||||
geometry.append(item)
|
||||
p['geometry']=sorted(geometry,key=digest)
|
||||
p['visible']=o.visible
|
||||
return p
|
||||
|
||||
result=[]
|
||||
for c in doc.components:
|
||||
origin=(c.position.x,c.position.y)
|
||||
p={k:v for k,v in c.parameters.items() if k not in IGNORE}
|
||||
p.update(designator=c.designator,pattern=c.library_reference,value=c.value,
|
||||
rotation=number(c.rotation%360),mirrored=c.mirrored)
|
||||
p['children']=sorted([dict(kind=o.kind,properties=properties(o,origin,c.rotation,c.mirrored))
|
||||
for o in children[c.id]],key=digest)
|
||||
result.append(Entity(c.id,'footprint',c.parameters.get('UNIQUEID',c.parameters.get('SOURCEUNIQUEID','')),
|
||||
c.designator,origin,c.bounds.model_dump(),p,digest(p),c.primitive_ids,0))
|
||||
for o in doc.objects:
|
||||
if o.component_id:continue
|
||||
origin=(o.position.x,o.position.y)
|
||||
if o.kind=='track' and o.connection_points:
|
||||
origin=min((p.x,p.y) for p in o.connection_points)
|
||||
p=properties(o,origin)
|
||||
result.append(Entity(o.id,o.kind,getattr(o,'native_uid',''),'',origin,
|
||||
o.bounds.model_dump() if o.primitive_ids else None,p,digest(p),o.primitive_ids,o.record_type))
|
||||
# The layer stack is independent of source record order and object counts.
|
||||
layer_props={k:v for k,v in doc.sheet.properties.items() if re.fullmatch(r'LAYER\d+(?:NAME|PREV|NEXT)|LAYERV7_\d+(?:NAME|LAYERID|PREV|NEXT)',k)}
|
||||
layer_props['copper_stack']=doc.metadata.get('copper_stack',[])
|
||||
result.append(Entity('pcb-layer-stack','layer_stack','board-layer-stack','',(0,0),None,layer_props,digest(layer_props),[],0))
|
||||
return result
|
||||
|
||||
|
||||
def compare_pcbs(old,new):
|
||||
if old.document_type!='pcb' or new.document_type!='pcb':raise ValueError('PCB documents required')
|
||||
before,after=entities(old),entities(new)
|
||||
pairs,removed,added,ambiguous=match_entities(before,after);changes=[]
|
||||
def change(status,a,b,method='',fields=None):
|
||||
e=b or a
|
||||
changes.append(dict(id=f'change-{len(changes)}',status=status,kind=e.kind,
|
||||
label=e.properties.get('designator') or e.properties.get('PAD_NAME') or e.properties.get('TEXT') or e.kind,
|
||||
old_id=a.id if a else None,new_id=b.id if b else None,
|
||||
old_bounds=a.bounds if a else None,new_bounds=b.bounds if b else None,
|
||||
old_primitive_ids=a.primitive_ids if a else [],new_primitive_ids=b.primitive_ids if b else [],match=method,
|
||||
moved=bool(a and b and a.position!=b.position),
|
||||
movement=dict(dx=number(b.position[0]-a.position[0]),dy=number(b.position[1]-a.position[1])) if a and b else None,
|
||||
rotation_change=number(b.properties.get('rotation',0)-a.properties.get('rotation',0)) if a and b else None,
|
||||
fields=fields or []))
|
||||
for a,b,method in pairs:
|
||||
fields=[dict(property=k,old=a.properties.get(k),new=b.properties.get(k)) for k in sorted(a.properties.keys()|b.properties.keys()) if a.properties.get(k)!=b.properties.get(k)]
|
||||
if fields or a.position!=b.position:change('modified' if fields else 'moved',a,b,method,fields)
|
||||
for e in removed:change('removed',e,None)
|
||||
for e in added:change('added',None,e)
|
||||
def info(doc):
|
||||
return dict(filename=doc.filename,sha256=doc.metadata['sha256'],compatibility=doc.metadata['compatibility'],
|
||||
sheet=doc.sheet.bounds.model_dump(),bounds=doc.bounds.model_dump(),layers=[l.model_dump() for l in doc.layers],
|
||||
diagnostics=[d.model_dump() for d in doc.diagnostics])
|
||||
a={n.name for n in old.nets};b={n.name for n in new.nets}
|
||||
return dict(schema_version='1.0',document_type='pcb_comparison',old=info(old),new=info(new),changes=changes,
|
||||
summary={s:sum(c['status']==s for c in changes) for s in ('added','removed','modified','moved')},total_changed=len(changes),
|
||||
nets=dict(added=sorted(b-a),removed=sorted(a-b)),
|
||||
matching=dict(matched=len(pairs),unmatched_old=len(removed),unmatched_new=len(added),ambiguous_groups=ambiguous),
|
||||
limitations=['PCB object/net-assignment comparison; no DRC or proof of electrical equivalence.',
|
||||
'Ambiguous identities remain added/removed; non-UID matches are heuristic.',
|
||||
'Footprint children are compared locally; independent routes remain separate.',
|
||||
'Unsupported native streams are not compared; inspect both parser diagnostic lists.',
|
||||
'Saved pours may be stale; no repouring or negative-plane reconstruction.'])
|
||||
@@ -0,0 +1,42 @@
|
||||
"""PCB render and comparison inside the existing parser resource boundary."""
|
||||
from . import parse_task # Apply CPU/address-space/output/FD limits before parsing.
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from .documents import parse_document
|
||||
from .pcb_diff import compare_pcbs
|
||||
from .parser.record_decoder import ParseError
|
||||
from .render import svg_document
|
||||
|
||||
|
||||
def run():
|
||||
source,destination,filename,identifier,kind=sys.argv[1:]
|
||||
dest=Path(destination)
|
||||
try:
|
||||
if kind=='pcb_comparison':
|
||||
manifest=json.loads(Path(source).read_text())
|
||||
if manifest.get('schema_version')!='1.0' or manifest.get('document_type')!='pcb_comparison':
|
||||
raise ParseError('Unsupported PCB comparison manifest')
|
||||
old=parse_document((dest/'old.PcbDoc').read_bytes(),manifest['old_filename'],identifier+'-old',document_type='pcb')
|
||||
new=parse_document((dest/'new.PcbDoc').read_bytes(),manifest['new_filename'],identifier+'-new',document_type='pcb')
|
||||
report=compare_pcbs(old,new)
|
||||
(dest/'data.json').write_text(json.dumps(report,ensure_ascii=False),encoding='utf-8')
|
||||
for side,doc in [('old',old),('new',new)]:
|
||||
annotations={c[f'{side}_id']:dict(id=c['id'],status=c['status'],bounds=c[f'{side}_bounds']) for c in report['changes'] if c[f'{side}_id'] is not None}
|
||||
(dest/f'{side}.svg').write_bytes(svg_document(doc,annotations,id_prefix=side+'-'))
|
||||
summary=dict(summary=report['summary'],total_changed=report['total_changed'])
|
||||
elif kind=='pcb_render':
|
||||
doc=parse_document(Path(source).read_bytes(),filename,identifier,document_type='pcb')
|
||||
(dest/'data.json').write_text(doc.model_dump_json(),encoding='utf-8')
|
||||
(dest/'drawing.svg').write_bytes(svg_document(doc))
|
||||
summary={k:doc.metadata[k] for k in ('compatibility','record_count','component_count','primitive_count','net_count')}
|
||||
summary.update(schema_version=doc.schema_version,diagnostics=[d.model_dump() for d in doc.diagnostics],layers=[l.model_dump() for l in doc.layers])
|
||||
else:raise ParseError('Unsupported PCB job kind')
|
||||
result=dict(ok=True,summary=summary)
|
||||
except (ParseError,ValueError) as exc:
|
||||
result=dict(ok=False,code='invalid_pcb',message=str(exc)[:500])
|
||||
except Exception:
|
||||
result=dict(ok=False,code='pcb_processing_error',message='PCB processing could not be completed')
|
||||
(dest/'result.json').write_text(json.dumps(result),encoding='utf-8')
|
||||
|
||||
if __name__=='__main__':run()
|
||||
@@ -11,8 +11,10 @@ def node(parent, tag, **attrs):
|
||||
|
||||
def svg_document(doc: Document, annotations=None, id_prefix="") -> bytes:
|
||||
b = doc.sheet.bounds
|
||||
pcb = doc.document_type == 'pcb'
|
||||
layer_visibility = {layer.id: layer.visible for layer in doc.layers}
|
||||
root = Element('svg', {'xmlns': 'http://www.w3.org/2000/svg', 'viewBox': f'{b.x} {b.y} {b.width} {b.height}',
|
||||
'preserveAspectRatio': 'xMidYMid meet', 'role': 'img', 'aria-label': 'Schematic'})
|
||||
'preserveAspectRatio': 'xMidYMid meet', 'role': 'img', 'aria-label': 'PCB' if pcb else 'Schematic'})
|
||||
node(root, 'rect', x=b.x, y=b.y, width=b.width, height=b.height, fill=doc.sheet.background)
|
||||
defs = node(root, 'defs')
|
||||
for i, p in enumerate(doc.primitives):
|
||||
@@ -20,6 +22,12 @@ def svg_document(doc: Document, annotations=None, id_prefix="") -> bytes:
|
||||
if annotations is not None:
|
||||
annotation = annotations.get(p.component_id or p.object_id)
|
||||
target = node(root, 'g', data_object_id=p.component_id or p.object_id, data_change_id=annotation['id'] if annotation else '')
|
||||
if pcb:
|
||||
span = getattr(p, "layer_ids", [])
|
||||
visible = layer_visibility.get(p.layer_id, True) and (p.role != "drill" or not span or any(layer_visibility.get(lid, False) for lid in span))
|
||||
target = node(target, 'g', data_layer_id=p.layer_id or '',
|
||||
data_layer_span=' '.join(getattr(p, 'layer_ids', [])), data_role=p.role,
|
||||
display='inline' if visible else 'none')
|
||||
if p.clip:
|
||||
clip = node(defs, 'clipPath', id=f'{id_prefix}clip-{i}', clipPathUnits='userSpaceOnUse')
|
||||
c = p.clip
|
||||
|
||||
@@ -6,7 +6,7 @@ from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from .config import settings
|
||||
|
||||
KEY = re.compile(r'^[a-f0-9]{48}/(?:input\.SchDoc|(?:old|new)\.SchDoc|comparison\.json|attempt-[12]/(?:data\.json|drawing\.svg|old\.svg|new\.svg))$')
|
||||
KEY = re.compile(r'^[a-f0-9]{48}/(?:input\.(?:SchDoc|PcbDoc)|(?:old|new)\.(?:SchDoc|PcbDoc)|comparison\.json|pcb-comparison\.json|attempt-[12]/(?:data\.json|drawing\.svg|old\.svg|new\.svg))$')
|
||||
|
||||
class Storage:
|
||||
def __init__(self):
|
||||
|
||||
@@ -3,18 +3,36 @@ from fastapi import HTTPException
|
||||
from .config import settings
|
||||
from .parser.record_decoder import ParseError,detect_format
|
||||
|
||||
async def read_schematic(file):
|
||||
async def read_native(file, document_type):
|
||||
try:
|
||||
name=(file.filename or '').replace('\\','/').rsplit('/',1)[-1]
|
||||
name=''.join(c for c in name if c.isprintable())[:255]
|
||||
if not name.lower().endswith('.schdoc'):
|
||||
raise HTTPException(415,{'code':'unsupported_file_type','message':'Upload a .SchDoc file'})
|
||||
suffix = '.PcbDoc' if document_type == 'pcb' else '.SchDoc'
|
||||
if not name.lower().endswith(suffix.lower()):
|
||||
raise HTTPException(415,{'code':'unsupported_file_type','message':f'Upload a {suffix} file'})
|
||||
body=bytearray()
|
||||
while chunk:=await file.read(1024*1024):
|
||||
body.extend(chunk)
|
||||
if len(body)>settings().max_upload_bytes:
|
||||
raise HTTPException(413,{'code':'upload_too_large','message':'Schematic exceeds configured upload size'})
|
||||
try:detect_format(bytes(body))
|
||||
except ParseError as exc:raise HTTPException(422,{'code':'invalid_schematic','message':str(exc)}) from exc
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
from .parser.pcb_container import identify_pcb, contains_pcb
|
||||
try:
|
||||
if document_type == 'pcb':
|
||||
await run_in_threadpool(identify_pcb, bytes(body))
|
||||
else:
|
||||
detect_format(bytes(body))
|
||||
if await run_in_threadpool(contains_pcb, bytes(body)):
|
||||
raise ParseError('PCB files are not accepted by schematic endpoints')
|
||||
except ParseError as exc:
|
||||
raise HTTPException(422,{'code':'invalid_pcb' if document_type == 'pcb' else 'invalid_schematic','message':str(exc)}) from exc
|
||||
return name,bytes(body)
|
||||
finally:await file.close()
|
||||
|
||||
|
||||
async def read_schematic(file):
|
||||
return await read_native(file, "schematic")
|
||||
|
||||
|
||||
async def read_pcb(file):
|
||||
return await read_native(file, "pcb")
|
||||
|
||||
@@ -13,6 +13,7 @@ from .config import settings
|
||||
from .cleanup import cleanup
|
||||
from .db import Job, claim, initialize, sessions
|
||||
from .storage import storage
|
||||
from .job_types import job_kind, comparison_sources
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
stopping = False
|
||||
@@ -23,19 +24,24 @@ def process(job):
|
||||
try:
|
||||
with tempfile.TemporaryDirectory(prefix='schdoc-') as directory:
|
||||
root = Path(directory)
|
||||
source = root / 'input.SchDoc'
|
||||
comparison = job['input_key'].endswith('/comparison.json')
|
||||
kind = job_kind(job['input_key'])
|
||||
if kind is None: raise ValueError('Unknown job input kind')
|
||||
pcb = kind.startswith('pcb_')
|
||||
source = root / ('input.PcbDoc' if pcb else 'input.SchDoc')
|
||||
comparison = kind.endswith('_comparison')
|
||||
if comparison:
|
||||
manifest = json.loads(storage().get(job['input_key']))
|
||||
source = root / 'comparison.json'
|
||||
source.write_text(json.dumps(manifest), encoding='utf-8')
|
||||
for side in ('old','new'):
|
||||
(root / f'{side}.SchDoc').write_bytes(storage().get(f"{job['id']}/{side}.SchDoc"))
|
||||
for key in comparison_sources(job['input_key']):
|
||||
(root / key.rsplit('/', 1)[-1]).write_bytes(storage().get(key))
|
||||
else:
|
||||
source.write_bytes(storage().get(job['input_key']))
|
||||
env = {k: v for k, v in os.environ.items() if k in ('PATH', 'LANG', 'LC_ALL', 'PYTHONPATH')}
|
||||
# Drain no untrusted output into logs. Kill/wait is handled by subprocess.run.
|
||||
subprocess.run([sys.executable, '-m', 'app.compare_task' if comparison else 'app.parse_task', str(source), str(root), job['filename'], job['id']],
|
||||
task = 'app.pcb_task' if pcb else 'app.compare_task' if comparison else 'app.parse_task'
|
||||
args = [kind] if pcb else []
|
||||
subprocess.run([sys.executable, '-m', task, str(source), str(root), job['filename'], job['id'], *args],
|
||||
timeout=config.parse_timeout, check=True, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
result = json.loads((root / 'result.json').read_text())
|
||||
if result['ok']:
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Tiny CFB writer solely for bounded native-format unit fixtures (never application data)."""
|
||||
import math
|
||||
import struct
|
||||
|
||||
FREE=0xffffffff; END=0xfffffffe; FAT=0xfffffffd
|
||||
|
||||
|
||||
def cfb(streams):
|
||||
entries=[('Root Entry',5,b'',[])]
|
||||
storages={}
|
||||
for name,data in streams.items():
|
||||
parts=name.split('/'); parent=0
|
||||
for part in parts[:-1]:
|
||||
key=(parent,part)
|
||||
if key not in storages:
|
||||
storages[key]=len(entries);entries.append((part,1,b'',[]));entries[parent][3].append(storages[key])
|
||||
parent=storages[key]
|
||||
entries[parent][3].append(len(entries));entries.append((parts[-1],2,data,[]))
|
||||
# This fixture deliberately uses normal >=4096-byte streams, with padding INSIDE native records.
|
||||
assert all(len(e[2])>=4096 for e in entries if e[1]==2)
|
||||
sectors=[];links=[];starts={}
|
||||
def allocate(data):
|
||||
start=len(sectors); count=math.ceil(len(data)/512)
|
||||
for i in range(count):sectors.append(data[i*512:(i+1)*512].ljust(512,b'\0'));links.append(start+i+1 if i+1<count else END)
|
||||
return start
|
||||
for i,e in enumerate(entries):
|
||||
if e[1]==2:starts[i]=allocate(e[2])
|
||||
directory=bytearray(math.ceil(len(entries)/4)*512)
|
||||
for i,(name,kind,data,children) in enumerate(entries):
|
||||
offset=i*128;encoded=(name+'\0').encode('utf-16le');directory[offset:offset+len(encoded)]=encoded
|
||||
struct.pack_into('<HBB',directory,offset+64,len(encoded),kind,1)
|
||||
siblings=[]
|
||||
for parent in entries:
|
||||
if i in parent[3]:siblings=parent[3];break
|
||||
right=siblings[siblings.index(i)+1] if siblings and siblings.index(i)+1<len(siblings) else FREE
|
||||
struct.pack_into('<III',directory,offset+68,FREE,right,children[0] if children else FREE)
|
||||
struct.pack_into('<IQ',directory,offset+116,starts.get(i,END),len(data))
|
||||
dir_start=allocate(directory)
|
||||
fat_id=len(sectors); assert fat_id<128
|
||||
links.append(FAT)
|
||||
fat=struct.pack('<128I',*(links+[FREE]*(128-len(links))))
|
||||
sectors.append(fat)
|
||||
header=bytearray(512);header[:8]=bytes.fromhex('d0cf11e0a1b11ae1')
|
||||
struct.pack_into('<HHHHH',header,24,0x3e,3,0xfffe,9,6)
|
||||
struct.pack_into('<IIIIIIIII',header,40,0,1,dir_start,0,4096,END,0,END,0)
|
||||
struct.pack_into('<109I',header,76,fat_id,*([FREE]*108))
|
||||
return bytes(header)+b''.join(sectors)
|
||||
|
||||
|
||||
def text(props):
|
||||
data=('|'+'|'.join(f'{k}={v}' for k,v in props.items())+'|_PADDING=').encode()
|
||||
data=data.ljust(4091,b' ')+b'\0'
|
||||
return struct.pack('<I',len(data))+data
|
||||
|
||||
|
||||
def record(tag,data):
|
||||
data=bytes(data).ljust(4091,b'\0')
|
||||
return bytes([tag])+struct.pack('<I',len(data))+data
|
||||
|
||||
|
||||
def pcb(*, width=100000, x=100000, net='GND', version='5.01', track=True, corrupt=False):
|
||||
header=b'PCB 6.0 Binary File'
|
||||
board={'KIND':'Protel_Advanced_PCB','VERSION':version,'LAYER1NEXT':'32','LAYER32PREV':'1',
|
||||
'VX0':'0mil','VY0':'0mil','VX1':'100mil','VY1':'0mil','VX2':'100mil','VY2':'100mil','VX3':'0mil','VY3':'100mil'}
|
||||
streams={'FileHeaderSix':(struct.pack('<IB',len(header),len(header))+header).ljust(4096,b'\0'),
|
||||
'Board6/Data':text(board),'Nets6/Data':text({'NAME':net})}
|
||||
if track:
|
||||
p=bytearray(45);p[0]=1
|
||||
struct.pack_into('<HHH',p,3,0,65535,65535)
|
||||
struct.pack_into('<iiiii',p,13,x,100000,x+200000,100000,width)
|
||||
streams['Tracks6/Data']=record(4,p)
|
||||
if corrupt:streams['Tracks6/Data']=b'\x01'+streams['Tracks6/Data'][1:]
|
||||
return cfb(streams)
|
||||
|
||||
|
||||
def rich_pcb():
|
||||
"""Pad stack and blind via records follow the documented V6 field offsets."""
|
||||
import io
|
||||
import olefile
|
||||
with olefile.OleFileIO(io.BytesIO(pcb())) as ole:
|
||||
streams={'/'.join(e):ole.openstream(e).read() for e in ole.listdir()}
|
||||
streams['Components6/Data']=text({'SOURCEDESIGNATOR':'U1','PATTERN':'QFN','SOURCEUNIQUEID':'FOOTPRINT_UID',
|
||||
'X':'10mil','Y':'10mil','LAYER':'TOP','ROTATION':'0'})
|
||||
p=bytearray(110);p[0]=74
|
||||
struct.pack_into('<HHH',p,3,0,65535,0)
|
||||
struct.pack_into('<ii',p,13,100000,100000)
|
||||
struct.pack_into('<iiiiiii',p,21,100000,200000,150000,250000,200000,300000,50000)
|
||||
struct.pack_into('<BBB',p,49,1,2,3);p[60]=1
|
||||
stack=bytearray(3959);stack[262]=2
|
||||
struct.pack_into('<i',stack,263,100000) # slotted hole
|
||||
parts=[b'\x011',b'',b'',b'',bytes(p),bytes(stack)]
|
||||
streams['Pads6/Data']=b'\x02'+b''.join(struct.pack('<I',len(part))+part for part in parts)
|
||||
v=bytearray(203);v[0]=74
|
||||
struct.pack_into('<HHH',v,3,0,65535,65535)
|
||||
struct.pack_into('<iiii',v,13,500000,500000,100000,50000)
|
||||
v[29]=1;v[30]=32;struct.pack_into('<32i',v,75,*([100000]*31+[200000]))
|
||||
streams['Vias6/Data']=record(3,v)
|
||||
streams['UniqueIDPrimitiveInformation/Data']=text({'PRIMITIVEINDEX':'0','PRIMITIVEOBJECTID':'Pad','UNIQUEID':'PAD_UID'})
|
||||
return cfb(streams)
|
||||
@@ -0,0 +1,298 @@
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
from pathlib import Path
|
||||
from xml.etree.ElementTree import fromstring
|
||||
import pytest
|
||||
from sqlalchemy import update
|
||||
from app.documents import parse_document
|
||||
from app.parser.record_decoder import ParseError
|
||||
from app.parser.pcb_binary import Reader, at, binary_records, wide_strings
|
||||
from app.parser.pcb_container import identify_pcb
|
||||
from app.parser.pcb_geometry import unit,raw_unit,pad_shape
|
||||
from app.models.schematic import Point
|
||||
from app.render import svg_document
|
||||
from app.pcb_diff import compare_pcbs
|
||||
from app.db import Job,claim,sessions
|
||||
from app.storage import storage
|
||||
from app.worker import process
|
||||
from pcb_fixture import pcb
|
||||
from test_platform import client,KEY,upload
|
||||
|
||||
|
||||
def parse(raw):return parse_document(raw,'board.PcbDoc','a'*48,document_type='pcb')
|
||||
def create(client,raw,compare=False,**fields):
|
||||
files={'old_file':('old.PcbDoc',raw),'new_file':('new.PcbDoc',fields.pop('new',raw))} if compare else {'file':('board.PcbDoc',raw)}
|
||||
return client.post('/api/v1/pcbs/comparisons' if compare else '/api/v1/pcbs/render',headers={'X-API-Key':KEY},files=files,data=fields)
|
||||
|
||||
|
||||
def test_pcb_units_and_checked_framing():
|
||||
assert raw_unit(100000)==1 and unit('2.54mm')==pytest.approx(10)
|
||||
for bad in ['nan','infinity','999999999mm']:
|
||||
with pytest.raises(ValueError):unit(bad)
|
||||
with pytest.raises(ParseError):Reader(b'\x01').read('I')
|
||||
with pytest.raises(ParseError):at(struct.pack('<d',float('nan')),0,'d')
|
||||
with pytest.raises(ParseError):list(binary_records(b'\x04\xff\xff\xff\x7f','Tracks6',4))
|
||||
value='مقاومت Ω\0'.encode('utf-16le')
|
||||
assert wide_strings(struct.pack('<II',1,len(value))+value)[1]=='مقاومت Ω'
|
||||
for shape in (1,2,3,9):
|
||||
path,box=pad_shape(Point(x=10,y=-20),8,4,shape,90,30)
|
||||
assert path.endswith('Z') and box.width==pytest.approx(4) and box.height==pytest.approx(8)
|
||||
|
||||
|
||||
def test_native_fixture_structure_geometry_and_layers():
|
||||
raw=pcb();identify_pcb(raw);doc=parse(raw)
|
||||
track=next(o for o in doc.objects if o.kind=='track')
|
||||
assert track.connection_points==[Point(x=1,y=-1),Point(x=3,y=-1)]
|
||||
assert track.net_id==doc.nets[0].id and track.properties['NET_NAME']=='GND'
|
||||
assert doc.metadata['copper_stack']==['1','32']
|
||||
tree=fromstring(svg_document(doc))
|
||||
assert tree.attrib['aria-label']=='PCB'
|
||||
assert any(node.attrib.get('data-layer-id')=='1' for node in tree.iter())
|
||||
|
||||
|
||||
@pytest.mark.parametrize('raw',[b'garbage',b'\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1'+b'\0'*504,pcb(version='9.0')])
|
||||
def test_invalid_envelopes(raw):
|
||||
with pytest.raises(ParseError):identify_pcb(raw)
|
||||
|
||||
|
||||
def test_structural_diff_movement_width_and_no_mutation():
|
||||
old=parse(pcb());same=parse(pcb());before=old.model_dump_json()
|
||||
assert compare_pcbs(old,same)['total_changed']==0
|
||||
moved=compare_pcbs(old,parse(pcb(x=200000)))
|
||||
assert moved['summary']==dict(added=0,removed=0,modified=0,moved=1)
|
||||
assert moved['changes'][0]['movement']==dict(dx=1,dy=0)
|
||||
modified=compare_pcbs(old,parse(pcb(width=200000)))
|
||||
assert modified['summary']['modified']==1
|
||||
assert compare_pcbs(old,parse(pcb(track=False)))['summary']['removed']==1
|
||||
assert compare_pcbs(old,parse(pcb(net='VCC')))['summary']['modified']==1
|
||||
assert old.model_dump_json()==before
|
||||
|
||||
|
||||
@pytest.mark.parametrize('comparison',[False,True])
|
||||
def test_pcb_api_worker_capabilities_expiry_cleanup(client,comparison):
|
||||
response=create(client,pcb(),comparison,new=pcb(width=200000)) if comparison else create(client,pcb())
|
||||
assert response.status_code==202,response.text
|
||||
result=response.json();identifier=result['id'];headers={'X-Viewer-Token':result['viewer_token']}
|
||||
base=f'/api/v1/pcbs/comparisons/{identifier}' if comparison else f'/api/v1/pcbs/{identifier}'
|
||||
status=base if comparison else base+'/status'
|
||||
svg=base+'/svg/old' if comparison else base+'/svg'
|
||||
assert 'summary' not in result and '#token=' in result['viewer_url']
|
||||
assert client.get(status).status_code==404
|
||||
assert client.get(status,headers={'X-Viewer-Token':'wrong'}).status_code==404
|
||||
assert client.get(svg,headers=headers).status_code==409
|
||||
process(claim());result=client.get(status,headers=headers).json()
|
||||
assert result['status']=='completed',result
|
||||
assert client.get(svg,headers=headers).status_code==200
|
||||
if comparison:assert result['summary']['modified']==1
|
||||
else:
|
||||
data=client.get(base+'/data',headers=headers).json()
|
||||
assert data['document_type']=='pcb'
|
||||
for path in (f'/view/pcb/{identifier}',f'/compare/pcb/{identifier}'):
|
||||
response=client.get(path);assert response.status_code==200
|
||||
assert len(response.headers.get_list('content-security-policy'))==1
|
||||
assert "frame-ancestors 'self' https://git.sadentel.net" in response.headers['content-security-policy']
|
||||
assert 'x-frame-options' not in response.headers
|
||||
with sessions().begin() as db:db.execute(update(Job).where(Job.id==identifier).values(expires_at=time.time()-1))
|
||||
assert client.get(status,headers=headers).status_code==410
|
||||
from app.cleanup import cleanup
|
||||
assert cleanup()==1
|
||||
with pytest.raises(FileNotFoundError):storage().get(identifier+('/pcb-comparison.json' if comparison else '/input.PcbDoc'))
|
||||
if comparison:
|
||||
with pytest.raises(FileNotFoundError):storage().get(identifier+'/old.PcbDoc')
|
||||
|
||||
|
||||
@pytest.mark.parametrize('access',['private','unlisted'])
|
||||
def test_all_four_job_kinds_are_isolated_even_for_service_key(client,ascii_schematic,access):
|
||||
from test_comparison import create as schematic_compare
|
||||
schematic=upload(client,ascii_schematic,access=access).json()
|
||||
sch_compare=schematic_compare(client,ascii_schematic,ascii_schematic,access=access).json()
|
||||
board=create(client,pcb(),access=access).json();comparison=create(client,pcb(),True,access=access).json()
|
||||
routes=[lambda id:f'/api/v1/schematics/{id}/status',lambda id:f'/api/v1/comparisons/{id}',
|
||||
lambda id:f'/api/v1/pcbs/{id}/status',lambda id:f'/api/v1/pcbs/comparisons/{id}']
|
||||
for i,item in enumerate([schematic,sch_compare,board,comparison]):
|
||||
for j,route in enumerate(routes):
|
||||
for headers in ({'X-API-Key':KEY},{'X-Viewer-Token':item.get('viewer_token','')},{}):
|
||||
response=client.get(route(item['id']),headers=headers)
|
||||
if i!=j:assert response.status_code==404,(i,j,response.text)
|
||||
elif 'X-API-Key' in headers or access=='unlisted' or headers.get('X-Viewer-Token'):assert response.status_code==200
|
||||
|
||||
|
||||
def test_auth_cors_upload_validation_and_body_limits(client,ascii_schematic):
|
||||
for path in ('/api/v1/pcbs/render','/api/v1/pcbs/comparisons'):
|
||||
assert client.post(path,content=b'invalid multipart').status_code==401
|
||||
preflight=client.options(path,headers={'Origin':'https://git.sadentel.net','Access-Control-Request-Method':'POST','Access-Control-Request-Headers':'x-api-key'})
|
||||
assert preflight.status_code==200 and preflight.headers['access-control-allow-origin']=='https://git.sadentel.net'
|
||||
assert client.post(path,content=b'x'*(4*1048576),headers={'X-API-Key':KEY,'Content-Type':'multipart/form-data; boundary=x'}).status_code==413
|
||||
assert create(client,ascii_schematic).status_code==422
|
||||
assert upload(client,pcb()).status_code==422
|
||||
assert create(client,pcb(version='9.0')).status_code==422
|
||||
assert create(client,pcb(),expires_in=999999999).status_code==422
|
||||
assert client.post('/api/v1/pcbs/render',headers={'X-API-Key':KEY},files={'file':('wrong.SchDoc',pcb())}).status_code==415
|
||||
|
||||
|
||||
def test_failed_decode_and_second_storage_write_cleanup(client,monkeypatch):
|
||||
created=create(client,pcb(corrupt=True)).json();process(claim())
|
||||
result=client.get(created['status_url'],headers={'X-Viewer-Token':created['viewer_token']}).json()
|
||||
assert result['status']=='failed' and result['error']['code']=='invalid_pcb'
|
||||
original=storage().put;keys=[]
|
||||
def fail(key,body,*args):
|
||||
keys.append(key);original(key,body,*args)
|
||||
if len(keys)==2:raise OSError('write acknowledged late')
|
||||
monkeypatch.setattr(storage(),'put',fail)
|
||||
assert create(client,pcb(),True).status_code==503
|
||||
assert all(not storage().path(key).exists() for key in keys)
|
||||
assert claim() is None
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def real_board():
|
||||
sample=os.getenv('PCBDOC_SAMPLE')
|
||||
if not sample:pytest.skip('Set PCBDOC_SAMPLE to a private real PCB fixture')
|
||||
path=Path(sample)
|
||||
return parse_document(path.read_bytes(),path.name,'a'*48,document_type='pcb')
|
||||
|
||||
|
||||
def test_real_board_and_self_comparison(real_board):
|
||||
doc=real_board
|
||||
assert len(doc.components)>0 and len(doc.primitives)>0
|
||||
assert len({p.id for p in doc.primitives})==len(doc.primitives)
|
||||
assert compare_pcbs(doc,doc)['total_changed']==0
|
||||
assert any(p.role=='pad' for p in doc.primitives)
|
||||
assert any(p.role=='drill' for p in doc.primitives)
|
||||
assert all(set(o.layer_ids)<={layer.id for layer in doc.layers} for o in doc.objects)
|
||||
|
||||
|
||||
def test_pad_stack_slot_via_layers_and_native_ids():
|
||||
from pcb_fixture import rich_pcb
|
||||
doc=parse(rich_pcb());pad=next(o for o in doc.objects if o.kind=='pad');via=next(o for o in doc.objects if o.kind=='via')
|
||||
assert pad.native_uid=='PAD_UID' and pad.component_id==doc.components[0].id
|
||||
assert pad.layer_ids==['1','32'] and via.layer_ids==['1','32']
|
||||
pads=[p for p in doc.primitives if p.object_id==pad.id and p.role=='pad']
|
||||
assert {p.layer_id for p in pads}=={'1','32'}
|
||||
assert next(p for p in pads if p.layer_id=='1').bounds.width==1
|
||||
assert next(p for p in pads if p.layer_id=='32').bounds.width==2
|
||||
slot=next(p for p in doc.primitives if p.object_id==pad.id and p.role=='drill')
|
||||
assert slot.bounds.width==1 and slot.bounds.height==.5 and slot.layer_ids==['1','32']
|
||||
bottom_via=next(p for p in doc.primitives if p.object_id==via.id and p.layer_id=='32')
|
||||
assert bottom_via.bounds.width==2
|
||||
|
||||
|
||||
def test_footprint_move_rotation_and_child_edits_are_one_change():
|
||||
from pcb_fixture import rich_pcb
|
||||
from app.parser.pcb_geometry import pad_shape
|
||||
old=parse(rich_pcb());new=old.model_copy(deep=True)
|
||||
component=new.components[0];component.position.x+=4
|
||||
for o in new.objects:
|
||||
if o.component_id==component.id:o.position.x+=4;o.bounds.x+=4
|
||||
# Paths translated independently from their existing SVG string.
|
||||
for p in new.primitives:
|
||||
if p.component_id==component.id:
|
||||
if p.role=='pad':
|
||||
w,h=(1,2) if p.layer_id=='1' else (2,3)
|
||||
p.path,p.bounds=pad_shape(Point(x=5,y=-1),w,h,1 if p.layer_id=='1' else 3,0)
|
||||
elif p.role=='drill':p.path,p.bounds=pad_shape(Point(x=5,y=-1),1,.5,1,0)
|
||||
# Source parameters retain physical placement too.
|
||||
component.parameters['X']='50mil'
|
||||
result=compare_pcbs(old,new)
|
||||
assert result['summary']==dict(added=0,removed=0,modified=0,moved=1),result['changes']
|
||||
component.value='new value'
|
||||
result=compare_pcbs(old,new)
|
||||
assert result['summary']['modified']==1 and result['changes'][0]['moved']
|
||||
# Reparse native revision with a rotated footprint/pad: only one footprint modification.
|
||||
rotated=parse(rich_pcb());rotated.components[0].rotation=90
|
||||
assert compare_pcbs(old,rotated)['summary']['modified']==1
|
||||
|
||||
|
||||
def test_record_reordering_net_renumber_and_duplicate_ambiguity():
|
||||
import io
|
||||
import olefile
|
||||
from pcb_fixture import cfb,text,record
|
||||
def native(reverse):
|
||||
with olefile.OleFileIO(io.BytesIO(pcb())) as ole:streams={'/'.join(e):ole.openstream(e).read() for e in ole.listdir()}
|
||||
streams['Nets6/Data']=text({'NAME':'VCC'})+text({'NAME':'GND'}) if reverse else text({'NAME':'GND'})+text({'NAME':'VCC'})
|
||||
records=[]
|
||||
for x,net in [(100000,0),(900000,1)]:
|
||||
p=bytearray(45);p[0]=1
|
||||
struct.pack_into('<HHH',p,3,(1-net) if reverse else net,65535,65535)
|
||||
struct.pack_into('<iiiii',p,13,x,100000,x+200000,100000,100000)
|
||||
records.append(record(4,p))
|
||||
streams['Tracks6/Data']=b''.join(reversed(records) if reverse else records)
|
||||
return parse(cfb(streams))
|
||||
assert compare_pcbs(native(False),native(True))['total_changed']==0
|
||||
old=native(False);new=old.model_copy(deep=True)
|
||||
track=next(o for o in new.objects if o.kind=='track').model_copy(deep=True);track.id+='-duplicate';new.objects.append(track)
|
||||
assert compare_pcbs(old,new)['summary']['added']==1
|
||||
|
||||
|
||||
@pytest.mark.parametrize('path',['/view/pcb/invalid','/compare/pcb/invalid'])
|
||||
def test_pcb_error_page_csp(client,path):
|
||||
r=client.get(path);assert r.status_code==404
|
||||
assert len(r.headers.get_list('content-security-policy'))==1
|
||||
assert "frame-ancestors 'self' https://git.sadentel.net" in r.headers['content-security-policy']
|
||||
assert 'x-frame-options' not in r.headers
|
||||
|
||||
|
||||
def test_pcb_admission_limit_and_stale_attempt_fencing(client,monkeypatch):
|
||||
from app.config import settings
|
||||
monkeypatch.setenv('MAX_PENDING_JOBS','1');settings.cache_clear()
|
||||
result=create(client,pcb(),access='unlisted').json()
|
||||
assert create(client,pcb()).status_code==503
|
||||
old=claim()
|
||||
with sessions().begin() as db:db.execute(update(Job).where(Job.id==result['id']).values(lease_until=time.time()-1))
|
||||
new=claim();assert new['attempt']==2
|
||||
process(new);process(old)
|
||||
with sessions()() as db:
|
||||
job=db.get(Job,result['id']);assert job.status=='completed' and job.output_prefix.endswith('/attempt-2')
|
||||
|
||||
|
||||
def test_cutout_is_not_dropped_as_an_unowned_region():
|
||||
import io
|
||||
import olefile
|
||||
from pcb_fixture import cfb,record
|
||||
with olefile.OleFileIO(io.BytesIO(pcb())) as ole:streams={'/'.join(e):ole.openstream(e).read() for e in ole.listdir()}
|
||||
p=bytearray(18);p[0]=1;struct.pack_into('<HHH',p,3,65535,65535,65535)
|
||||
props=b'|KIND=0|ISBOARDCUTOUT=TRUE\0'
|
||||
p+=struct.pack('<I',len(props))+props+struct.pack('<I',3)
|
||||
for x,y in [(200000,200000),(400000,200000),(400000,400000),(200000,400000)]:
|
||||
p+=struct.pack('<Biiiiidd',0,x,y,0,0,0,0.,0.)
|
||||
streams['ShapeBasedRegions6/Data']=record(11,p)
|
||||
doc=parse(cfb(streams));cutout=next(p for p in doc.primitives if p.role=='board_cutout')
|
||||
assert cutout.layer_id=='0' and cutout.bounds.width==2 and cutout.bounds.height==2
|
||||
assert cutout.fill_rule=='evenodd'
|
||||
|
||||
|
||||
def test_pcb_storage_keys_do_not_allow_traversal_or_arbitrary_formats(client):
|
||||
for name in ['input.PcbDoc','old.PcbDoc','new.PcbDoc','pcb-comparison.json','attempt-1/old.svg']:
|
||||
assert storage().path('a'*48+'/'+name).name==name.split('/')[-1]
|
||||
for name in ['../input.PcbDoc','x.PcbDoc','input.PcbDoc.exe','pcb-comparison.json/extra','attempt-3/drawing.svg']:
|
||||
with pytest.raises(ValueError):storage().path('a'*48+'/'+name)
|
||||
|
||||
|
||||
def test_comparison_accepts_each_file_limit_and_invalid_side(client,monkeypatch):
|
||||
from app.config import settings
|
||||
monkeypatch.setenv('MAX_UPLOAD_BYTES','20000');settings.cache_clear()
|
||||
# Each is 18 KiB; combined exceeds one per-file limit and is nevertheless a valid pair.
|
||||
raw=pcb();assert 20000<len(raw)*2 and len(raw)<20000
|
||||
created=create(client,raw,True).json()
|
||||
identifier=created['id'];headers={'X-Viewer-Token':created['viewer_token']}
|
||||
assert client.get(f'/api/v1/pcbs/comparisons/{identifier}/svg/other',headers=headers).status_code==422
|
||||
assert client.get(f'/api/v1/pcbs/comparisons/{identifier}/svg/../old',headers=headers).status_code==404
|
||||
assert create(client,raw+b'x'*20000).status_code==413
|
||||
|
||||
|
||||
@pytest.mark.parametrize('prefix',['/view/pcb/','/compare/pcb/'])
|
||||
@pytest.mark.parametrize('frame_option',['DENY','SAMEORIGIN'])
|
||||
def test_pcb_preview_replaces_duplicate_csp_and_removes_xfo(client,monkeypatch,prefix,frame_option):
|
||||
from fastapi.responses import Response
|
||||
def page(*args,**kwargs):
|
||||
response=Response('preview',media_type='text/html',headers={'X-Frame-Options':frame_option,'Content-Security-Policy':"frame-ancestors 'none'"})
|
||||
response.headers.append('Content-Security-Policy',"frame-ancestors 'none'")
|
||||
return response
|
||||
monkeypatch.setattr('app.pcb_api.FileResponse',page)
|
||||
response=client.get(prefix+'a'*48)
|
||||
assert len(response.headers.get_list('content-security-policy'))==1
|
||||
assert "frame-ancestors 'self' https://git.sadentel.net" in response.headers['content-security-policy']
|
||||
assert 'x-frame-options' not in response.headers
|
||||
@@ -1,19 +1,21 @@
|
||||
"""Protocol-level S3 contract check with botocore's validation stub (no live S3)."""
|
||||
import io
|
||||
import pytest
|
||||
import boto3
|
||||
from botocore.response import StreamingBody
|
||||
from botocore.stub import Stubber
|
||||
from app.storage import Storage
|
||||
|
||||
|
||||
def test_s3_private_round_trip_contract(tmp_path,monkeypatch):
|
||||
@pytest.mark.parametrize('name', ['input.SchDoc', 'input.PcbDoc', 'old.PcbDoc', 'new.PcbDoc', 'pcb-comparison.json', 'attempt-1/old.svg'])
|
||||
def test_s3_private_round_trip_contract(tmp_path,monkeypatch,name):
|
||||
from app.config import settings
|
||||
from app.storage import storage
|
||||
monkeypatch.setenv('STORAGE_DIR',str(tmp_path)); monkeypatch.setenv('S3_BUCKET','test-private')
|
||||
settings.cache_clear(); storage.cache_clear()
|
||||
client=boto3.client('s3',region_name='us-east-1',aws_access_key_id='test',aws_secret_access_key='test')
|
||||
monkeypatch.setattr(boto3,'client',lambda *a,**kw:client)
|
||||
key='a'*48+'/input.SchDoc'; payload=b'actual bytes'
|
||||
key='a'*48+'/'+name; payload=b'actual bytes'
|
||||
with Stubber(client) as stub:
|
||||
stub.add_response('put_object',{}, {'Bucket':'test-private','Key':key,'Body':payload,'ContentType':'application/octet-stream'})
|
||||
stub.add_response('get_object',{'Body':StreamingBody(io.BytesIO(payload),len(payload))},{'Bucket':'test-private','Key':key})
|
||||
|
||||
@@ -45,3 +45,7 @@ A worker crash is retried after its lease expires, at most twice total. A parse
|
||||
## Schematic comparisons
|
||||
|
||||
`POST /api/v1/comparisons` (alias `/api/compare`) accepts `old_file` and `new_file`, with the same API key, private tokens, expiry, durable queue and error conventions. `GET /api/v1/comparisons/{id}` (alias `/api/compare/{id}`) returns status and the completed structural diff. `/compare/{id}` opens the side-by-side workspace. See [the comparison contract](SCHEMATIC_DIFF.md).
|
||||
|
||||
## PCB preview and comparison
|
||||
|
||||
`POST /api/v1/pcbs/render` accepts `file`; `POST /api/v1/pcbs/comparisons` accepts `old_file` and `new_file`. Both require native `.PcbDoc` and reuse the existing asynchronous status, authentication, access and expiry conventions. They do not change schematic routes. See [PCB API and read/viewer endpoints](PCB.md).
|
||||
|
||||
@@ -27,7 +27,7 @@ Local object paths are application-generated and validated. Files are mode 0600
|
||||
|
||||
The normalized document keeps original property maps, owner relationships and active parts separately from drawables. This is necessary for diagnostics and future parser improvements: the SVG is an output, not the only stored interpretation. Source paint order is preserved. Geometry is not laid out by a graph library.
|
||||
|
||||
`app/documents.py` registers format adapters. To support PCB later, add a PCB adapter and PCB-specific data/layer types, reuse queue/storage/capability APIs, and provide layer-aware renderer/viewer behavior. The current endpoint intentionally accepts only SchDoc. Existing optional layer/net schema fields reserve extension points; no saved PCB data is fabricated, and no PCB functionality is implemented.
|
||||
`app/documents.py` registers separate schematic and native PCB adapters. PCB-specific models preserve footprints, native object identity and layer/drill spans. The PCB routes reuse the same durable queue, capability checks, storage and SVG serializer; a separate PCB structural comparator handles native geometry and net assignments. Existing SchDoc routes remain format-specific. See [PCB architecture, API and limitations](PCB.md).
|
||||
|
||||
The viewer is dependency-free JavaScript with a pure tested camera module. It manipulates one SVG viewBox, schedules updates with requestAnimationFrame, and delegates vector rendering to the browser. There is no raster redraw loop or symbol substitution. Large native records are processed off the API process. Browser SVG performance at very large primitive counts has not been benchmarked; viewport culling or a hybrid renderer may be warranted after profiling, while preserving the same primitive model.
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ This service is an independent, read-only native-format implementation. There is
|
||||
- Little-endian length/kind record framing, original physical record indexes, source properties, UTF-8 companion properties, legacy Windows-1252 decoding.
|
||||
- Unknown binary payloads are preserved as base64, with diagnostics, rather than misinterpreted as text.
|
||||
- Major unknown headers, missing sheets, broken CFB/record lengths and files without supported geometry fail. Minor header revisions are reported, not treated as certified Altium application releases.
|
||||
- `.PcbDoc`, libraries without an instantiated schematic, encrypted/protected variants and unknown document envelopes are not supported.
|
||||
- The schematic endpoints reject `.PcbDoc`. Separate PCB endpoints support a diagnosed native V6 subset; see [PCB compatibility](PCB.md). Libraries without an instantiated schematic, encrypted/protected variants and unknown document envelopes are not supported by the schematic parser.
|
||||
|
||||
## Geometry
|
||||
|
||||
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
# Native PCB preview and structural comparison
|
||||
|
||||
Two new creation endpoints extend the existing FastAPI application. Existing schematic API paths, private capabilities and SVG output remain supported. No database migration, Redis, frontend framework or new runtime dependency is introduced. The original `schematics` table stores all four job kinds.
|
||||
|
||||
## API
|
||||
|
||||
| Method | Path | Purpose |
|
||||
| --- | --- | --- |
|
||||
| POST | `/api/v1/pcbs/render` | Upload `file` as `.PcbDoc` |
|
||||
| POST | `/api/v1/pcbs/comparisons` | Upload `old_file` and `new_file` as `.PcbDoc` |
|
||||
| GET | `/api/v1/pcbs/{id}/status` | Render status and completed layer inventory |
|
||||
| GET | `/api/v1/pcbs/{id}/data` | Completed PCB document |
|
||||
| GET | `/api/v1/pcbs/{id}/svg` | Completed layer-aware SVG |
|
||||
| GET | `/api/v1/pcbs/comparisons/{id}` | Status and completed structural report |
|
||||
| GET | `/api/v1/pcbs/comparisons/{id}/svg/{side}` | Annotated SVG; side is `old` or `new` |
|
||||
| GET | `/view/pcb/{id}` | Fullscreen single PCB shell |
|
||||
| GET | `/compare/pcb/{id}` | Fullscreen side-by-side PCB shell |
|
||||
|
||||
```bash
|
||||
curl -X POST https://render.sadentel.net/api/v1/pcbs/render \
|
||||
-H "X-API-Key: $API_KEY" \
|
||||
-F 'file=@board.PcbDoc' -F 'access=private' -F 'expires_in=3600'
|
||||
|
||||
curl -X POST https://render.sadentel.net/api/v1/pcbs/comparisons \
|
||||
-H "X-API-Key: $API_KEY" \
|
||||
-F 'old_file=@original.PcbDoc' -F 'new_file=@modified.PcbDoc' \
|
||||
-F 'access=private' -F 'expires_in=3600'
|
||||
```
|
||||
|
||||
Both creation requests return HTTP **202**, `Location: status_url` and `Retry-After: 1`. `access` defaults to `private`; `unlisted` is supported. Expiry is measured from admission, including queue time, and cannot exceed `LINK_TTL_SECONDS`.
|
||||
|
||||
Example render response (identifiers, timestamps and capabilities are illustrative):
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"id": "0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
"status": "queued",
|
||||
"document_type": "pcb",
|
||||
"viewer_url": "https://render.sadentel.net/view/pcb/0123456789abcdef0123456789abcdef0123456789abcdef#token=READ_TOKEN",
|
||||
"status_url": "https://render.sadentel.net/api/v1/pcbs/0123456789abcdef0123456789abcdef0123456789abcdef/status",
|
||||
"viewer_token": "READ_TOKEN",
|
||||
"access": "private",
|
||||
"expires_at": 1794818000
|
||||
}
|
||||
```
|
||||
|
||||
Comparison responses use `document_type: pcb_comparison` and add `comparison_id` and `url`, equal to the initial `viewer_url`. The viewer path is `/compare/pcb/{id}` and status path is `/api/v1/pcbs/comparisons/{id}`. Queued responses do not invent a summary. Completed reports contain `summary` (added/removed/modified/moved), `total_changed`, `changes`, `old`, `new`, `nets`, `matching` and `limitations`.
|
||||
|
||||
Retain the initial private URL and token. Status responses never return the capability. Reads accept `X-Viewer-Token` or the service `X-API-Key`. Unauthorized private reads return 404; authorized expired reads return 410 until cleanup. Pending artifacts return 409; failed artifacts return 422. Failed jobs retain their error in an HTTP 200 status response. PCB validation/decoding errors use `invalid_pcb`; resource-limit and infrastructure errors use the existing queue conventions. Filenames and geometry are not disclosed by unauthenticated status requests.
|
||||
|
||||
Viewer shells themselves are public static shells, like the schematic viewer; drawing data is fetched with the capability header. Token fragments are removed from browser history and stored in a resource-specific sessionStorage key. Browser storage restrictions in embedded contexts still require browser acceptance testing. No API key is shipped to the browser.
|
||||
|
||||
## Queue and storage compatibility
|
||||
|
||||
Immutable application-owned input names classify jobs:
|
||||
|
||||
| Input name | Job kind |
|
||||
| --- | --- |
|
||||
| `input.SchDoc` | schematic_render (legacy) |
|
||||
| `comparison.json` | schematic_comparison (legacy) |
|
||||
| `input.PcbDoc` | pcb_render |
|
||||
| `pcb-comparison.json` | pcb_comparison |
|
||||
|
||||
A PCB comparison stores two sources `old.PcbDoc` and `new.PcbDoc`, plus a versioned manifest. The worker dispatches PCB work to `app.pcb_task`, inheriting the existing subprocess CPU, address-space, output, FD and wall-clock limits. Attempts write the same `attempt-{1|2}/data.json`, `drawing.svg` or `old.svg`/`new.svg` artifact names. Completion uses the existing attempt/status fence. API routes require the exact resource kind even for unlisted jobs and service-key reads. The storage regex permits only these named additions. Cleanup removes both source files and all attempts.
|
||||
|
||||
Each input remains bounded by `MAX_UPLOAD_BYTES`. Only the new comparison POST joins the existing two-file multipart allowance (`2 * MAX_UPLOAD_BYTES + 1 MiB`). The Caddy comparison matcher includes that exact new path with 65 MiB; other request limits remain unchanged. Adjust external CDN caps if required by your deployment.
|
||||
|
||||
PCB filenames are sanitized, MIME is not trusted, and the API checks the actual OLE header/Board6 identification with bounded stream reads. Full binary decoding remains asynchronous. Renaming PCB bytes to `.SchDoc` does not admit them to the schematic API; SchDoc bytes are rejected by PCB validation.
|
||||
|
||||
## Native format and geometry
|
||||
|
||||
`pcb_binary.py`, `pcb_geometry.py` and `pcb_parser.py` adapt an independently written reader from the user's local Altium viewer checkout; no other application/frontend/service is imported. The binary layouts and units were cross-checked with the [KiCad developer format reference](https://dev-docs.kicad.org/en/import-formats/altium/index.html). No GPL importer implementation is copied, and no Altium SDK is required.
|
||||
|
||||
Supported envelope: `PCB 6.0 Binary File` in `FileHeaderSix`, with `Board6/Data` identifying `Protel_Advanced_PCB`. Board format revisions 5.0, 5.01 and 6.0 are recognized; **only 5.01 is verified on the local native files**. These are format revisions, not supported Altium application release promises. Other envelopes/revisions fail explicitly.
|
||||
|
||||
Readers validate framing, block sizes, field boundaries, finite numbers, references, stream counts and cumulative stream sizes. They never resynchronize after a bad tag, execute embedded resources or fetch external URLs. Limits include 1024 streams, 64 MiB cumulative stream bytes, 16 MiB subrecords and 250,000 primitives, in addition to subprocess limits. Very large boards may exceed these limits.
|
||||
|
||||
Binary coordinates are integer 1/10000 mil. The shared world unit is 10 mil:
|
||||
|
||||
```text
|
||||
world_x = raw_x / 100000
|
||||
world_y = -raw_y / 100000
|
||||
```
|
||||
|
||||
Placed child geometry is already global and is not rotated twice. PCB-specific models retain footprint metadata, object provenance, optional native UID, layer memberships and drill spans, without turning PCB pads into schematic pins. Source physical indexes remain provenance only.
|
||||
|
||||
| Native streams | Current treatment |
|
||||
| --- | --- |
|
||||
| Board6 | Real boundary vertices, arcs, dimensions and stack-link metadata |
|
||||
| Components6 / Nets6 | Footprint placement, parameters and actual net references |
|
||||
| Tracks6 / Arcs6 | Centerline geometry, widths and layers |
|
||||
| Pads6 | Top/mid/bottom and supported full-stack shapes, rotation, plated state, round/square/slot drill and supported offsets |
|
||||
| Vias6 | Recorded span, primary and supported per-layer diameters, drill |
|
||||
| Fills6 | Rotated filled rectangles |
|
||||
| Regions6 | Saved polygon copper with interior holes |
|
||||
| ShapeBasedRegions6 | Standalone solid/curved regions and supported board cutouts; duplicate polygon definitions are not painted twice |
|
||||
| Polygons6 | Retained definition boundaries; never treated as freshly poured copper |
|
||||
| Texts6 / WideStrings6 | Stored strings and placement; font/style limitations are diagnosed |
|
||||
| UniqueIDPrimitiveInformation | Native primitive identity where present |
|
||||
|
||||
Copper membership follows actual Board6 links, including plane-layer identities. Pads and vias keep multiple layer memberships; per-layer geometry is drawn separately. Drill visibility follows its recorded span. The safe SVG serializer emits layer identifiers and inert geometry, with top-side visibility initially selected. Layer controls allow Top/Bottom/All and individual selection. Outline is a separate layer. Changing the preset does not geometrically mirror or rearrange the board.
|
||||
|
||||
## Fidelity boundaries
|
||||
|
||||
Compatibility is explicitly `partial`:
|
||||
|
||||
- Layer colors are a viewer palette; Altium application display preferences are not claimed to be embedded or reproduced.
|
||||
- Browser fonts replace native stroke/TrueType rendering; metrics, barcodes, inverted text and special styles may differ. Font licensing/availability still applies.
|
||||
- Saved pours are rendered as stored, possibly stale. There is no repour engine, rule execution, negative-plane reconstruction or solder/paste-mask rule expansion.
|
||||
- Supported board cutouts are rendered as visual cutouts; this is not a fabrication geometry boolean engine.
|
||||
- Advanced/unknown pad shapes are omitted with diagnostics. Undecoded full-stack pad or nonuniform via dimensions on internal plane layers are reported rather than assigned an invented shape.
|
||||
- Embedded STEP models, 3D bodies, rules and unsupported streams are not rendered. Diagnostics identify omitted Data streams and declared counts when present.
|
||||
- Footprints without supported native geometry remain in metadata with diagnostics; substitute symbols are never generated.
|
||||
- Geometry outside the board bounds remains pannable but may start outside the initial viewBox.
|
||||
|
||||
No proprietary sample, Altium software, SDK, font or compiled library is included in the repository or Docker image. No pixel-perfect claim is made.
|
||||
|
||||
## Diff interpretation
|
||||
|
||||
The PCB diff reuses the format-independent entity matcher, not schematic record-number semantics or schematic coordinate-key normalization. It matches unique native IDs, unique footprint designators, exact geometry/position, unique local structure, then unique same-kind position. Ambiguous objects remain additions/removals; identical duplicates are treated as multisets. Each match records its method.
|
||||
|
||||
Footprint-owned geometry is aggregated and compared in local coordinates, while independent tracks/vias remain independent. Layer, net name, width, rotation, drill, rendered geometry and properties participate. Net indexes and record/component/polygon indexes are excluded as cross-file identities. Reversed straight tracks and net renumbering do not create changes by themselves. The stack is a separate semantic entity. Unsupported stream bytes are not semantically compared, and this limitation is part of every report.
|
||||
|
||||
Primary added/removed/modified/moved counts are disjoint. A modified-and-moved object has primary `modified` plus `moved: true` and a movement delta. Rotation changes are reported in fields and `rotation_change`. An anchor delta is not proof of rigid translation when geometry also changed.
|
||||
|
||||
Net results report observed names added/removed; object fields reveal changed stored assignments. The result is not DRC, ERC, an electrical-equivalence proof, a manufacturing comparison or a project-wide compiled netlist.
|
||||
|
||||
## Viewer and framing
|
||||
|
||||
The dependency-free PCB shell uses the existing camera/interactions module and spinner. The default is a fullscreen board or two boards, plus a menu button. Layers, diagnostics and comparison controls appear only when the menu opens; there is no new dashboard or upload page. Comparison supports synchronized pan/zoom/pinch, fit, change filters, changes-only, highlights and previous/next focus.
|
||||
|
||||
Existing security middleware covers `/view/*` and `/compare/*`, including the new nested PCB pages. Those responses have one CSP with `frame-ancestors 'self' https://git.sadentel.net`; conflicting X-Frame-Options is removed. Other directives and API policies stay intact. Parent-site CSP and any deployed CDN overrides still need environment-specific checking.
|
||||
|
||||
## Validation and deployment
|
||||
|
||||
```bash
|
||||
PCBDOC_SAMPLE='/path/to/private-board.PcbDoc' ./scripts/check.sh
|
||||
API_KEY="$API_KEY" .venv/bin/python scripts/pcb-smoke.py original.PcbDoc modified.PcbDoc \
|
||||
--url http://127.0.0.1:8000
|
||||
```
|
||||
|
||||
Without a configured native file the real-PCB test is explicitly skipped. Synthetic native unit fixtures are test-only, never fallback viewer content. The HTTP smoke creates private resources, polls the real worker, checks SVGs, denies unauthenticated reads, and checks preview security headers without printing capabilities. Omit `modified.PcbDoc` to verify a same-file comparison has zero changes.
|
||||
|
||||
Rebuild/restart both API and worker:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build api worker
|
||||
```
|
||||
|
||||
Reload/recreate Caddy if using the production overlay so its two-file upload matcher is updated. No schema migration is required. No automatic production deployment or unrelated service restart is performed by this feature.
|
||||
|
||||
### Local verification evidence (2026-11-09)
|
||||
|
||||
The current `/home/nearology/LTE 6/LTE 6.PcbDoc` (SHA-256 `2afb5b464cff6c4b780386bf14f39c810230d5625d7277ff01fe4823999ea561`) decoded 694 footprints, 479 nets, 12,916 tracks, 311 arcs, 2,329 pads, 1,059 vias, 1,822 texts, 13 fills, 45 polygon definitions and 48 records in each region stream. It produced 26,471 layer-aware primitives. A self-comparison reported zero changes. Its native SVG was rasterized locally and the top-side image inspected; this is not an Altium-reference visual regression.
|
||||
|
||||
The other local PCB file (SHA-256 `00b812958357b12f62f8d3cca12486f716925b2655d35258115b01c44eaeb314`) decoded 729 footprints and produced 41,285 primitives. Comparing those two real local documents completed with 8,073 added, 12,248 removed, 1,300 modified and 469 moved primary entities; 102 ambiguous signature groups remained. These counts demonstrate a completed structural comparison, not human verification of every pair or design equivalence. No claim is made about the chronological relationship of these files.
|
||||
|
||||
Real HTTP smoke checks passed against an isolated loopback Uvicorn API with SQLite and a separate real worker: private single rendering, self-comparison, two-file comparison, both SVG reads, unauthorized reads and preview headers. The test processes and their temporary database/storage were stopped/removed afterwards. This run is not evidence of live PostgreSQL/S3 integration or production deployment. S3 coverage uses botocore protocol stubs. Caddy configuration adaptation passed with 65 MiB on the three comparison upload paths and the original 33 MB elsewhere.
|
||||
|
||||
No connected browser was available, so actual pan/pinch/menu/iframe appearance remains unverified in a browser. No trusted Altium export was supplied, so geometric and font fidelity has not been certified against Altium output.
|
||||
|
||||
The final automated run includes native SchDoc and PCB fixtures, 154 passing Python tests and three passing frontend test files (camera, pointer interaction adapter and PCB layer policies). The standard Docker build was attempted with both normal and host networking, but dependency download stalled on `Temporary failure in name resolution` for PyPI's package host; those build attempts were stopped. A new Docker image was therefore not validated or deployed in this environment. The existing Dockerfile and dependency lock remain unchanged.
|
||||
@@ -4,7 +4,7 @@ The comparison feature reuses native SchDoc parsing, the original vector geometr
|
||||
|
||||
Open `/compare` to select an original and modified file, or follow the comparison link on the main upload page. After processing, `/compare/{id}#token=…` shows the original on the left and modified document on the right. Original schematic primitives keep their colors and geometry; optional boxes annotate changes. Added objects are green, removed red, modified amber and moved blue. A changes list provides property details, previous/next navigation and click-to-focus. Zoom/pan/pinch, synchronized world coordinates, fit, category filtering, changes-only mode and highlight toggling are available. Both drawings have unique SVG clip identifiers to avoid collisions in the same DOM.
|
||||
|
||||
The single-file viewer and its fullscreen interface remain unchanged. The new comparison page is a separate workspace. Existing iframe policy remains limited to `/view/*`; `/compare/*` is a standalone page and is not additionally granted cross-origin framing permission by this feature.
|
||||
The single-file viewer and its fullscreen interface remain unchanged. The new comparison page is a separate workspace. The security middleware permits preview embedding for `/view/*` and `/compare/*` only by the service itself and `https://git.sadentel.net`; other CSP directives remain unchanged.
|
||||
|
||||
## API
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/** Native layer identities; drill visibility also depends on its recorded copper span. */
|
||||
export function layerVisible(id, span, selected) {
|
||||
if (!selected.has(id)) return false;
|
||||
return id !== '81' || !span.length || span.some(layer => selected.has(layer));
|
||||
}
|
||||
export function presetLayers(layers, preset) {
|
||||
return new Set(layers.filter(layer => preset === 'all' || layer.id === '0' || layer.id === '81' ||
|
||||
(preset === 'top' ? ['1','33'].includes(layer.id) : ['32','34'].includes(layer.id))).map(layer => layer.id));
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {test} from 'node:test';
|
||||
import {layerVisible,presetLayers} from './pcb-layers.js';
|
||||
test('blind via drills follow actual selected copper span',()=>{
|
||||
assert.equal(layerVisible('81',['2','3'],new Set(['1','81'])),false);
|
||||
assert.equal(layerVisible('81',['2','3'],new Set(['2','81'])),true);
|
||||
assert.equal(layerVisible('81',['1'],new Set(['1'])),false);
|
||||
assert.equal(layerVisible('0',[],new Set(['0'])),true);
|
||||
});
|
||||
test('layer presets preserve native IDs and outline',()=>{
|
||||
const layers=['0','1','2','32','33','34','81'].map(id=>({id}));
|
||||
assert.deepEqual([...presetLayers(layers,'top')],['0','1','33','81']);
|
||||
assert.deepEqual([...presetLayers(layers,'bottom')],['0','32','34','81']);
|
||||
assert.equal(presetLayers(layers,'all').size,7);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
[hidden] { display: none !important; }
|
||||
#pcb-workspace { flex: 1; min-height: 0; display: grid; grid-template-columns: minmax(0,1fr); }
|
||||
body.pcb-comparison #pcb-workspace { grid-template-columns: minmax(0,1fr) minmax(0,1fr); }
|
||||
body.menu-open #pcb-workspace { grid-template-columns: minmax(0,1fr) 270px; }
|
||||
body.pcb-comparison.menu-open #pcb-workspace { grid-template-columns: minmax(0,1fr) minmax(0,1fr) 270px; }
|
||||
#pcb-menu { padding: 10px; overflow: auto; font-size: 12px; }
|
||||
#pcb-menu h2 { padding: 5px 0; background: transparent; }
|
||||
#layers { display: grid; gap: 5px; margin: 12px 0; }
|
||||
#layer-presets { display: flex; gap: 5px; }
|
||||
#compatibility { color: #60716b; font-size: 11px; overflow-wrap: anywhere; }
|
||||
#diff-controls { min-height: 100px; }
|
||||
#changes { max-height: 35vh; padding: 0; margin-top: 10px; }
|
||||
#summary { font-size: 12px; }
|
||||
.surface { background: #08151b; }
|
||||
@media (max-width: 700px) {
|
||||
body.pcb-comparison #pcb-workspace { grid-template-columns: 1fr; grid-template-rows: minmax(0,1fr) minmax(0,1fr); }
|
||||
body.menu-open #pcb-workspace { grid-template-columns: minmax(0,1fr) 210px; }
|
||||
body.pcb-comparison.menu-open #pcb-workspace { grid-template-columns: minmax(0,1fr) 210px; grid-template-rows: minmax(0,1fr) minmax(0,1fr); }
|
||||
#pcb-menu { grid-column: 2; grid-row: 1 / -1; }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>PCB Viewer</title><link rel="stylesheet" href="/fonts.css"><link rel="stylesheet" href="/assets/viewer.css"><link rel="stylesheet" href="/assets/compare.css?v=pcb-1"><link rel="stylesheet" href="/assets/pcb.css?v=1"><script type="module" src="/assets/pcb.js?v=1"></script></head><body>
|
||||
<button id="menu-toggle" type="button" aria-label="Show menu" aria-expanded="false" aria-controls="pcb-toolbar pcb-menu"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h16"/></svg></button>
|
||||
<header id="pcb-toolbar" data-pcb-menu hidden><strong>PCB</strong><div id="summary"></div><div class="controls"><label id="sync-control"><input id="sync" type="checkbox" checked> Sync views</label><label id="highlight-control"><input id="highlights" type="checkbox" checked> Highlights</label><button id="fit" type="button">Fit</button></div></header>
|
||||
<main id="pcb-workspace"><section class="pane"><div id="old-surface" class="surface" tabindex="0" aria-label="PCB"></div></section><section id="new-pane" class="pane" hidden><div id="new-surface" class="surface" tabindex="0" aria-label="Modified PCB"></div></section><aside id="pcb-menu" data-pcb-menu hidden><section><h2>Layers</h2><div id="layer-presets"><button data-preset="top" type="button">Top</button><button data-preset="bottom" type="button">Bottom</button><button data-preset="all" type="button">All</button></div><div id="layers"></div></section><section id="diff-controls" hidden><div class="change-controls"><select id="filter" aria-label="Change filter"><option value="all">All changes</option><option value="added">Added</option><option value="removed">Removed</option><option value="modified">Modified</option><option value="moved">Moved</option></select><button id="previous" aria-label="Previous change">↑</button><button id="next" aria-label="Next change">↓</button></div><label><input id="only-changes" type="checkbox"> Changes only</label><nav id="changes" aria-label="PCB changes"></nav><pre id="details"></pre></section><p id="compatibility"></p></aside></main><output id="state" class="loading" role="status">Loading PCB…</output></body></html>
|
||||
@@ -0,0 +1,85 @@
|
||||
import { bindCamera } from './interactions.js';
|
||||
import { layerVisible, presetLayers } from './pcb-layers.js';
|
||||
const comparison=location.pathname.startsWith('/compare/pcb/');
|
||||
const id=location.pathname.split('/').pop(), state=document.querySelector('#state');
|
||||
const key=`pcb:${comparison?'comparison':'render'}:${id}`;
|
||||
const token=new URLSearchParams(location.hash.slice(1)).get('token')||sessionStorage.getItem(key);
|
||||
if(token)sessionStorage.setItem(key,token);
|
||||
if(location.hash)history.replaceState(null,'',location.pathname);
|
||||
const headers=token?{'X-Viewer-Token':token}:{}, controller=new AbortController(), views=[];
|
||||
const base=comparison?`/api/v1/pcbs/comparisons/${id}`:`/api/v1/pcbs/${id}`;
|
||||
const menu=document.querySelector('#menu-toggle');
|
||||
function setMenu(open){
|
||||
document.body.classList.toggle('menu-open',open);
|
||||
for(const panel of document.querySelectorAll('[data-pcb-menu]'))panel.hidden=!open;
|
||||
menu.setAttribute('aria-expanded',String(open));menu.setAttribute('aria-label',open?'Hide menu':'Show menu');
|
||||
}
|
||||
setMenu(false);
|
||||
menu.addEventListener('click',()=>setMenu(menu.getAttribute('aria-expanded')!=='true'));
|
||||
addEventListener('keydown',e=>{if(e.key==='Escape'){setMenu(false);menu.focus();}});
|
||||
addEventListener('pagehide',()=>{controller.abort();for(const view of views)view.destroy();},{once:true});
|
||||
document.body.classList.toggle('pcb-comparison',comparison);
|
||||
document.querySelector('#new-pane').hidden=!comparison;
|
||||
for(const selector of ['#sync-control','#highlight-control','#diff-controls'])document.querySelector(selector).hidden=!comparison;
|
||||
async function request(path='',text=false){
|
||||
const r=await fetch(base+path,{headers,signal:controller.signal,cache:'no-store'});
|
||||
if(!r.ok){const b=await r.json();throw new Error(b.error?.message||'PCB unavailable');}
|
||||
return text?r.text():r.json();
|
||||
}
|
||||
function union(boxes){
|
||||
const x=Math.min(...boxes.map(b=>b.x)),y=Math.min(...boxes.map(b=>b.y));
|
||||
return {x,y,width:Math.max(...boxes.map(b=>b.x+b.width))-x,height:Math.max(...boxes.map(b=>b.y+b.height))-y};
|
||||
}
|
||||
async function start(){
|
||||
let result;
|
||||
for(;;){result=await request(comparison?'':'/status');if(result.status==='completed')break;if(result.status==='failed')throw new Error(result.error?.message||'PCB processing failed');await new Promise(r=>setTimeout(r,1000));}
|
||||
const texts=await Promise.all((comparison?['/svg/old','/svg/new']:['/svg']).map(path=>request(path,true)));
|
||||
const svgs=texts.map(text=>{
|
||||
const xml=new DOMParser().parseFromString(text,'image/svg+xml');
|
||||
if(xml.querySelector('parsererror')||xml.documentElement.localName!=='svg')throw new Error('Invalid PCB artifact');
|
||||
return document.importNode(xml.documentElement,true);
|
||||
});
|
||||
const boxes=comparison?[result.old.bounds,result.new.bounds]:svgs.map(svg=>{const b=svg.viewBox.baseVal;return {x:b.x,y:b.y,width:b.width,height:b.height};});
|
||||
const bounds=union(boxes);
|
||||
for(const [i,svg]of svgs.entries()){
|
||||
const surface=document.querySelector(i?'#new-surface':'#old-surface');surface.append(svg);
|
||||
views.push(bindCamera(surface,svg,bounds,source=>{if(comparison&&document.querySelector('#sync').checked)for(const view of views)if(view!==source)view.apply(source.pose());}));
|
||||
}
|
||||
await document.fonts.ready;
|
||||
state.classList.remove('loading');state.textContent='';
|
||||
document.querySelector('#fit').addEventListener('click',()=>{for(const view of views)view.fit();});
|
||||
document.querySelector('#sync').addEventListener('change',()=>{if(views[1]&&document.querySelector('#sync').checked)views[1].apply(views[0].pose());});
|
||||
const layers=comparison?[...new Map([...result.old.layers,...result.new.layers].map(l=>[l.id,l])).values()]:result.layers;
|
||||
let selected=new Set(layers.filter(l=>l.visible).map(l=>l.id));
|
||||
const layerNodes=svgs.flatMap(svg=>[...svg.querySelectorAll('[data-layer-id]')]);
|
||||
function paintLayers(){for(const node of layerNodes)node.setAttribute('display',layerVisible(node.dataset.layerId,(node.dataset.layerSpan||'').split(' ').filter(Boolean),selected)?'inline':'none');}
|
||||
const checks=new Map();
|
||||
for(const layer of layers){
|
||||
const label=document.createElement('label'),input=document.createElement('input');input.type='checkbox';input.checked=selected.has(layer.id);
|
||||
checks.set(layer.id,input);label.append(input,document.createTextNode(layer.name));document.querySelector('#layers').append(label);
|
||||
input.addEventListener('change',()=>{if(input.checked)selected.add(layer.id);else selected.delete(layer.id);paintLayers();});
|
||||
}
|
||||
for(const button of document.querySelectorAll('[data-preset]'))button.addEventListener('click',()=>{selected=presetLayers(layers,button.dataset.preset);for(const [id,input]of checks)input.checked=selected.has(id);paintLayers();});
|
||||
paintLayers();
|
||||
const diagnostics=comparison?[...result.old.diagnostics,...result.new.diagnostics]:result.diagnostics||[];
|
||||
document.querySelector('#compatibility').textContent=[...new Set(diagnostics.map(d=>d.message))].join('\n');
|
||||
if(comparison){
|
||||
const changes=result.changes,filter=document.querySelector('#filter'),rows=new Map();let active=null;
|
||||
document.querySelector('#summary').textContent=Object.entries(result.summary).map(([k,v])=>`${v} ${k}`).join(' · ');
|
||||
function focus(change){active=change.id;const boxes=[change.old_bounds,change.new_bounds].filter(Boolean);if(boxes.length)for(const view of views)view.focus(union(boxes));document.querySelector('#details').textContent=JSON.stringify({kind:change.kind,match:change.match,movement:change.movement,fields:change.fields},null,2);for(const [id,row]of rows)row.setAttribute('aria-current',String(id===active));}
|
||||
for(const change of changes){const button=document.createElement('button');button.type='button';button.className=change.status;button.textContent=`${change.status} · ${change.label}`;button.addEventListener('click',()=>focus(change));document.querySelector('#changes').append(button);rows.set(change.id,button);}
|
||||
if(!changes.length)document.querySelector('#changes').textContent='No structural changes detected.';
|
||||
const byId=new Map(changes.map(c=>[c.id,c])),nodes=svgs.flatMap(svg=>[...svg.querySelectorAll('[data-change-id]')]);
|
||||
function apply(){
|
||||
for(const node of nodes){const c=byId.get(node.dataset.changeId);node.toggleAttribute('data-diff-hidden',c?(filter.value!=='all'&&filter.value!==c.status):(filter.value!=='all'||document.querySelector('#only-changes').checked));}
|
||||
for(const change of changes)rows.get(change.id).hidden=filter.value!=='all'&&filter.value!==change.status;
|
||||
document.body.classList.toggle('no-highlights',!document.querySelector('#highlights').checked);
|
||||
}
|
||||
for(const input of [filter,document.querySelector('#only-changes'),document.querySelector('#highlights')])input.addEventListener('change',apply);
|
||||
for(const [selector,step]of [['#previous',-1],['#next',1]])document.querySelector(selector).addEventListener('click',()=>{const visible=changes.filter(c=>filter.value==='all'||c.status===filter.value);if(!visible.length)return;const i=visible.findIndex(c=>c.id===active);focus(visible[(i<0?(step>0?0:visible.length-1):(i+step+visible.length)%visible.length)]);});
|
||||
for(const svg of svgs)svg.addEventListener('click',e=>{const c=byId.get(e.target.closest('[data-change-id]')?.dataset.changeId);if(c)focus(c);});
|
||||
apply();
|
||||
}
|
||||
function expire(){const remaining=result.expires_at*1000-Date.now();if(remaining<=0){for(const view of views)view.destroy();document.querySelector('#pcb-workspace').replaceChildren();state.textContent='This PCB link has expired';}else setTimeout(expire,Math.min(2147483647,remaining));}expire();
|
||||
}
|
||||
start().catch(e=>{state.classList.remove('loading');state.textContent=e.name==='AbortError'?'':e.message;});
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Real HTTP smoke: private PCB render + comparison, without printing read capabilities."""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
def main():
|
||||
parser=argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('old',type=Path);parser.add_argument('new',type=Path,nargs='?')
|
||||
parser.add_argument('--url',default='http://127.0.0.1:8000')
|
||||
parser.add_argument('--timeout',type=int,default=180)
|
||||
args=parser.parse_args();base=args.url.rstrip('/');key=os.environ.get('API_KEY','')
|
||||
def request(path,headers=None,data=None):
|
||||
req=urllib.request.Request(base+path,data=data,headers=headers or {})
|
||||
with urllib.request.urlopen(req,timeout=30) as response:return response.status,response.headers,response.read()
|
||||
def upload(path,files):
|
||||
boundary='pcb-'+secrets.token_hex(16);parts=[]
|
||||
for field,file in files.items():
|
||||
parts.extend([f'--{boundary}\r\nContent-Disposition: form-data; name="{field}"; filename="board.PcbDoc"\r\nContent-Type: application/octet-stream\r\n\r\n'.encode(),file.read_bytes(),b'\r\n'])
|
||||
parts.extend([f'--{boundary}\r\nContent-Disposition: form-data; name="access"\r\n\r\nprivate\r\n--{boundary}--\r\n'.encode()])
|
||||
status,headers,body=request(path,{'X-API-Key':key,'Content-Type':f'multipart/form-data; boundary={boundary}'},b''.join(parts))
|
||||
assert status==202;created=json.loads(body);assert '#token=' in created['viewer_url']
|
||||
return created
|
||||
for comparison in (False,True):
|
||||
path='/api/v1/pcbs/comparisons' if comparison else '/api/v1/pcbs/render'
|
||||
files={'old_file':args.old,'new_file':args.new or args.old} if comparison else {'file':args.old}
|
||||
created=upload(path,files);identifier=created['id'];headers={'X-Viewer-Token':created['viewer_token']}
|
||||
resource=f'/api/v1/pcbs/comparisons/{identifier}' if comparison else f'/api/v1/pcbs/{identifier}'
|
||||
status_path=resource if comparison else resource+'/status'
|
||||
try:request(status_path)
|
||||
except urllib.error.HTTPError as exc:assert exc.code==404
|
||||
else:raise AssertionError('Private PCB accessible without a capability')
|
||||
deadline=time.monotonic()+args.timeout
|
||||
while True:
|
||||
_,_,body=request(status_path,headers);result=json.loads(body)
|
||||
if result['status']=='failed':raise RuntimeError(result['error'])
|
||||
if result['status']=='completed':break
|
||||
if time.monotonic()>deadline:raise TimeoutError('PCB worker did not finish')
|
||||
time.sleep(.5)
|
||||
for side in (('old','new') if comparison else ('',)):
|
||||
suffix='/svg/'+side if comparison else '/svg'
|
||||
status,_,svg=request(resource+suffix,headers);assert status==200 and b'<svg' in svg and b'data-layer-id' in svg
|
||||
view=f'/compare/pcb/{identifier}' if comparison else f'/view/pcb/{identifier}'
|
||||
_,response_headers,html=request(view)
|
||||
csp=response_headers.get_all('Content-Security-Policy')
|
||||
assert len(csp)==1 and "frame-ancestors 'self' https://git.sadentel.net" in csp[0]
|
||||
assert not response_headers.get_all('X-Frame-Options') and b'/assets/pcb.js' in html
|
||||
if comparison and (args.new is None or args.old.read_bytes()==args.new.read_bytes()):assert result['total_changed']==0
|
||||
print(json.dumps(dict(kind='pcb_comparison' if comparison else 'pcb_render',id=identifier,status=result['status'],
|
||||
summary=result.get('summary'),primitive_count=result.get('primitive_count'))))
|
||||
|
||||
if __name__=='__main__':main()
|
||||
مرجع در شماره جدید
مسدود کردن کاربر