Files
Seth Hillbrand 0e7a052af0 PADS: add ASCII format importer for PCB and schematic files
Add complete import support for PADS ASCII (.asc) files, covering both
PCB layouts and schematics. The importer handles board outlines, copper
pours, routing with via inference, footprint decals, net assignment,
design rules, and component placement for PCB files. For schematics, it
supports symbol building from CAEDECAL definitions, multi-gate and
multi-variant parts, power symbols, wire routing, net labeling, arc
graphics, title blocks, and hierarchical sheet connectivity.

Fixes https://gitlab.com/kicad/code/kicad/-/issues/19944
2026-02-10 21:21:22 -08:00

546 lines
18 KiB
Python

#
# This program source code file is part of KiCad, a free EDA CAD application.
#
# Copyright The KiCad Developers, see AUTHORS.txt for contributors.
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
# MA 02110-1301, USA.
#
"""
PADS Importer QA Tests
These tests verify the PADS importer by:
1. Importing PADS .asc files to KiCad format
2. Exporting gerbers from the imported board
3. Comparing exported gerbers against reference gerbers
4. Reporting diff percentages per layer
Environment Variables:
PADS_QA_SAVE_ALL_DIFFS: Set to "1" to save diff images for all layers,
not just failing ones. Useful for debugging.
"""
import utils
from pathlib import Path
import pytest
import json
import os
from conftest import KiTestFixture
from dataclasses import dataclass, field
from typing import Dict, List, Optional
def should_save_all_diffs() -> bool:
"""Check if we should save diff images for all layers, not just failing ones."""
return os.environ.get( "PADS_QA_SAVE_ALL_DIFFS", "" ) == "1"
# Map KiCad layer names to gerber export layer arguments
KICAD_LAYER_TO_GERBER_ARG = {
"F.Cu": "F.Cu",
"B.Cu": "B.Cu",
"In1.Cu": "In1.Cu",
"In2.Cu": "In2.Cu",
"In3.Cu": "In3.Cu",
"In4.Cu": "In4.Cu",
"In5.Cu": "In5.Cu",
"In6.Cu": "In6.Cu",
"F.Mask": "F.Mask",
"B.Mask": "B.Mask",
"F.Paste": "F.Paste",
"B.Paste": "B.Paste",
"F.Silkscreen": "F.Silkscreen",
"B.Silkscreen": "B.Silkscreen",
}
# Map KiCad layer names to gerber export file suffixes
KICAD_LAYER_TO_SUFFIX = {
"F.Cu": "-F_Cu.gtl",
"B.Cu": "-B_Cu.gbl",
"In1.Cu": "-In1_Cu.g2",
"In2.Cu": "-In2_Cu.g3",
"In3.Cu": "-In3_Cu.g4",
"In4.Cu": "-In4_Cu.g5",
"In5.Cu": "-In5_Cu.g6",
"In6.Cu": "-In6_Cu.g7",
"F.Mask": "-F_Mask.gts",
"B.Mask": "-B_Mask.gbs",
"F.Paste": "-F_Paste.gtp",
"B.Paste": "-B_Paste.gbp",
"F.Silkscreen": "-F_Silkscreen.gto",
"B.Silkscreen": "-B_Silkscreen.gbo",
}
@dataclass
class LayerResult:
"""Result of comparing a single layer"""
layer_name: str
reference_file: str
generated_file: str
diff_percentage: float
passed: bool
diff_image: Optional[str] = None
@dataclass
class DrillResult:
"""Result of comparing drill files"""
reference_file: str
generated_file: str
diff_percentage: float
passed: bool
hole_count_reference: int = 0
hole_count_generated: int = 0
diff_image: Optional[str] = None
@dataclass
class TestCaseResult:
"""Result of testing a complete design"""
name: str
passed: bool
layer_results: List[LayerResult] = field( default_factory=list )
drill_results: List[DrillResult] = field( default_factory=list )
import_report: Optional[Dict] = None
error: Optional[str] = None
def get_pads_test_cases() -> List[str]:
"""Get list of available PADS test cases."""
test_data_dir = Path( __file__ ).parent.parent.parent / "data" / "pcbnew" / "plugins" / "pads"
if not test_data_dir.exists():
return []
test_cases = []
for item in test_data_dir.iterdir():
if item.is_dir() and ( item / "config.json" ).exists():
test_cases.append( item.name )
return test_cases
def load_test_config( test_case: str ) -> Dict:
"""Load the configuration for a test case."""
test_data_dir = Path( __file__ ).parent.parent.parent / "data" / "pcbnew" / "plugins" / "pads"
config_path = test_data_dir / test_case / "config.json"
with open( config_path, "r" ) as f:
return json.load( f )
def get_test_data_path( test_case: str ) -> Path:
"""Get the path to test data for a test case."""
return Path( __file__ ).parent.parent.parent / "data" / "pcbnew" / "plugins" / "pads" / test_case
class PadsImporterHelper:
"""Helper class for PADS importer tests"""
def __init__( self, kitest: KiTestFixture, test_case: str ):
self.kitest = kitest
self.test_case = test_case
self.config = load_test_config( test_case )
self.test_data_path = get_test_data_path( test_case )
self.output_dir = kitest.get_output_path( f"cli/pads_importer/{test_case}/" )
def import_pads_file( self ) -> tuple:
"""Import the PADS file and return (output_path, report, return_code)"""
source_file = self.test_data_path / self.config["source_file"]
output_path = self.output_dir / f"{self.test_case}.kicad_pcb"
report_path = self.output_dir / "import_report.json"
if output_path.exists():
output_path.unlink()
if report_path.exists():
report_path.unlink()
command = [
utils.kicad_cli(),
"pcb", "import",
"--format", "pads",
"--report-format", "json",
"--report-file", str( report_path ),
str( source_file ),
"-o", str( output_path )
]
stdout, stderr, return_code = utils.run_and_capture( command )
report = None
if report_path.exists():
with open( report_path, "r" ) as f:
report = json.load( f )
return output_path, report, return_code
def export_gerbers( self, board_path: Path ) -> Dict[str, Path]:
"""Export gerbers from the imported board."""
gerber_output_dir = self.output_dir / "gerbers"
gerber_output_dir.mkdir( parents=True, exist_ok=True )
# Build list of all layers to export at once
layers_to_export = []
for ref_gerber, kicad_layer in self.config["layer_mapping"].items():
if kicad_layer in KICAD_LAYER_TO_GERBER_ARG:
layers_to_export.append( KICAD_LAYER_TO_GERBER_ARG[kicad_layer] )
if not layers_to_export:
return {}
# Use the plural 'gerbers' command which exports all layers at once
command = [
utils.kicad_cli(),
"pcb", "export", "gerbers",
"--no-x2",
"--layers", ",".join( layers_to_export ),
"-o", str( gerber_output_dir ) + "/",
str( board_path )
]
stdout, stderr, return_code = utils.run_and_capture( command )
exported_gerbers = {}
# Map reference gerbers to exported files
for ref_gerber, kicad_layer in self.config["layer_mapping"].items():
if kicad_layer not in KICAD_LAYER_TO_SUFFIX:
continue
suffix = KICAD_LAYER_TO_SUFFIX[kicad_layer]
expected_file = gerber_output_dir / ( board_path.stem + suffix )
if expected_file.exists():
exported_gerbers[ref_gerber] = expected_file
return exported_gerbers
def compare_layer( self, ref_gerber: str, generated_gerber: Path ) -> LayerResult:
"""Compare a generated gerber against reference."""
reference_path = self.test_data_path / "reference_gerbers" / ref_gerber
if not reference_path.exists():
return LayerResult(
layer_name=self.config["layer_mapping"][ref_gerber],
reference_file=str( reference_path ),
generated_file=str( generated_gerber ),
diff_percentage=100.0,
passed=False
)
diff_output = self.output_dir / "diffs" / f"{ref_gerber}_diff.png"
diff_output.parent.mkdir( parents=True, exist_ok=True )
# First, get JSON output for statistics
command_json = [
utils.kicad_cli(),
"gerber", "diff",
"--format", "json",
str( reference_path ),
str( generated_gerber )
]
stdout, stderr, return_code = utils.run_and_capture( command_json )
diff_percentage = 100.0
# Return code 1 means files are different, 0 means identical
if return_code in ( 0, 1 ):
json_start = stdout.find( "{" )
json_end = stdout.rfind( "}" ) + 1
if json_start >= 0 and json_end > json_start:
try:
diff_result = json.loads( stdout[json_start:json_end] )
diff_percentage = diff_result.get( "total_diff_percent", 100.0 )
except json.JSONDecodeError:
pass
threshold = self.config.get( "pass_threshold", 5.0 )
passed = diff_percentage <= threshold
# Generate PNG diff image if there are differences and we need it
if should_save_all_diffs() or not passed:
command_png = [
utils.kicad_cli(),
"gerber", "diff",
"--format", "png",
"-o", str( diff_output ),
str( reference_path ),
str( generated_gerber )
]
utils.run_and_capture( command_png )
return LayerResult(
layer_name=self.config["layer_mapping"][ref_gerber],
reference_file=str( reference_path ),
generated_file=str( generated_gerber ),
diff_percentage=diff_percentage,
passed=passed,
diff_image=str( diff_output ) if diff_output.exists() else None
)
def export_drill( self, board_path: Path ) -> Optional[Path]:
"""Export drill files from the imported board."""
drill_output_dir = self.output_dir / "drill"
drill_output_dir.mkdir( parents=True, exist_ok=True )
command = [
utils.kicad_cli(),
"pcb", "export", "drill",
"--format", "excellon",
"-o", str( drill_output_dir ) + "/",
str( board_path )
]
stdout, stderr, return_code = utils.run_and_capture( command )
# Look for the generated drill file
expected_file = drill_output_dir / ( board_path.stem + ".drl" )
if expected_file.exists():
return expected_file
# Try PTH specific file
pth_file = drill_output_dir / ( board_path.stem + "-PTH.drl" )
if pth_file.exists():
return pth_file
return None
def compare_drill( self, ref_drill: str, generated_drill: Path ) -> DrillResult:
"""Compare a generated drill file against reference."""
reference_path = self.test_data_path / "reference_gerbers" / ref_drill
if not reference_path.exists():
return DrillResult(
reference_file=str( reference_path ),
generated_file=str( generated_drill ),
diff_percentage=100.0,
passed=False
)
diff_output = self.output_dir / "diffs" / f"{ref_drill}_diff.png"
diff_output.parent.mkdir( parents=True, exist_ok=True )
# Get JSON output for statistics
command_json = [
utils.kicad_cli(),
"gerber", "diff",
"--format", "json",
str( reference_path ),
str( generated_drill )
]
stdout, stderr, return_code = utils.run_and_capture( command_json )
diff_percentage = 100.0
hole_count_ref = 0
hole_count_gen = 0
# Return code 1 means files are different, 0 means identical
if return_code in ( 0, 1 ):
json_start = stdout.find( "{" )
json_end = stdout.rfind( "}" ) + 1
if json_start >= 0 and json_end > json_start:
try:
diff_result = json.loads( stdout[json_start:json_end] )
diff_percentage = diff_result.get( "total_diff_percent", 100.0 )
except json.JSONDecodeError:
pass
threshold = self.config.get( "pass_threshold", 5.0 )
passed = diff_percentage <= threshold
# Generate PNG diff image if there are differences and we need it
if should_save_all_diffs() or not passed:
command_png = [
utils.kicad_cli(),
"gerber", "diff",
"--format", "png",
"-o", str( diff_output ),
str( reference_path ),
str( generated_drill )
]
utils.run_and_capture( command_png )
return DrillResult(
reference_file=str( reference_path ),
generated_file=str( generated_drill ),
diff_percentage=diff_percentage,
passed=passed,
hole_count_reference=hole_count_ref,
hole_count_generated=hole_count_gen,
diff_image=str( diff_output ) if diff_output.exists() else None
)
def run_test( self ) -> TestCaseResult:
"""Run the complete import and comparison test."""
result = TestCaseResult( name=self.test_case, passed=False )
board_path, import_report, return_code = self.import_pads_file()
result.import_report = import_report
if return_code != 0 or not board_path.exists():
result.error = f"Import failed with return code {return_code}"
return result
exported_gerbers = self.export_gerbers( board_path )
if not exported_gerbers:
result.error = "No gerbers were exported"
return result
all_passed = True
for ref_gerber, generated_gerber in exported_gerbers.items():
layer_result = self.compare_layer( ref_gerber, generated_gerber )
result.layer_results.append( layer_result )
if not layer_result.passed:
all_passed = False
# Compare drill files if configured
drill_files = self.config.get( "drill_files", [] )
if drill_files:
generated_drill = self.export_drill( board_path )
if generated_drill:
for ref_drill in drill_files:
drill_result = self.compare_drill( ref_drill, generated_drill )
result.drill_results.append( drill_result )
if not drill_result.passed:
all_passed = False
result.passed = all_passed
return result
def save_results( self, result: TestCaseResult ) -> Path:
"""Save test results to JSON file."""
results_path = self.output_dir / "results.json"
results_dict = {
"name": result.name,
"passed": result.passed,
"error": result.error,
"import_report": result.import_report,
"layer_results": [
{
"layer_name": lr.layer_name,
"reference_file": lr.reference_file,
"generated_file": lr.generated_file,
"diff_percentage": lr.diff_percentage,
"passed": lr.passed,
"diff_image": lr.diff_image
}
for lr in result.layer_results
],
"drill_results": [
{
"reference_file": dr.reference_file,
"generated_file": dr.generated_file,
"diff_percentage": dr.diff_percentage,
"passed": dr.passed,
"hole_count_reference": dr.hole_count_reference,
"hole_count_generated": dr.hole_count_generated,
"diff_image": dr.diff_image
}
for dr in result.drill_results
]
}
with open( results_path, "w" ) as f:
json.dump( results_dict, f, indent=2 )
return results_path
# Get test cases at module load time
PADS_TEST_CASES = get_pads_test_cases()
@pytest.mark.skipif( len( PADS_TEST_CASES ) == 0,
reason="No PADS test data available" )
class TestPadsImporter:
"""Parametrized tests for PADS importer"""
@pytest.mark.parametrize( "test_case", PADS_TEST_CASES )
def test_pads_import_and_compare( self, kitest: KiTestFixture, test_case: str ):
"""Test importing a PADS file and comparing gerbers and drill files."""
helper = PadsImporterHelper( kitest, test_case )
result = helper.run_test()
results_path = helper.save_results( result )
if result.error:
pytest.fail( f"Test failed: {result.error}" )
# Attach diff images for failed layers
for layer_result in result.layer_results:
if not layer_result.passed:
if layer_result.diff_image:
kitest.add_attachment( layer_result.diff_image )
# Attach diff images for failed drill comparisons
for drill_result in result.drill_results:
if not drill_result.passed:
if drill_result.diff_image:
kitest.add_attachment( drill_result.diff_image )
failed_layers = [lr for lr in result.layer_results if not lr.passed]
failed_drills = [dr for dr in result.drill_results if not dr.passed]
if failed_layers or failed_drills:
msg = f"Comparison failures for {test_case}:\n"
if failed_layers:
msg += " Layer failures:\n"
for lr in failed_layers:
msg += f" {lr.layer_name}: {lr.diff_percentage:.2f}% diff (threshold: {helper.config.get('pass_threshold', 5.0)}%)\n"
if failed_drills:
msg += " Drill failures:\n"
for dr in failed_drills:
ref_name = Path( dr.reference_file ).name
msg += f" {ref_name}: {dr.diff_percentage:.2f}% diff (threshold: {helper.config.get('pass_threshold', 5.0)}%)\n"
pytest.fail( msg )
@pytest.mark.parametrize( "test_case", PADS_TEST_CASES )
def test_pads_import_only( self, kitest: KiTestFixture, test_case: str ):
"""Test that PADS import completes without error."""
helper = PadsImporterHelper( kitest, test_case )
board_path, import_report, return_code = helper.import_pads_file()
assert return_code == 0, f"Import failed with return code {return_code}"
assert board_path.exists(), f"Output file not created: {board_path}"
assert import_report is not None, "Import report not generated"
assert "statistics" in import_report, "Import report missing statistics"