|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Check prepared bevel previews against native FreeCAD solids and exported assemblies. |
| 3 | +
|
| 4 | +Prepare with node --import tsx scripts/verify-bevel-pair.ts [cases.json] [--matrix]. |
| 5 | +Run this script with a FreeCAD-capable Python. The default smoke batch stays small; |
| 6 | +--matrix on the preparation command adds all presets, states and shaft-hole shapes. |
| 7 | +""" |
| 8 | +import argparse |
| 9 | +from datetime import datetime, timezone |
| 10 | +import hashlib |
| 11 | +import json |
| 12 | +from pathlib import Path |
| 13 | +import sys |
| 14 | +import tempfile |
| 15 | +import time |
| 16 | + |
| 17 | +parser = argparse.ArgumentParser(description=__doc__) |
| 18 | +parser.add_argument('cases', nargs='?', default='/tmp/protolab-bevel-pair-cases.json') |
| 19 | +parser.add_argument('--freecad-lib', default='/Applications/FreeCAD.app/Contents/Resources/lib') |
| 20 | +parser.add_argument('--output', default='/tmp/protolab-bevel-pair-results.json') |
| 21 | +parser.add_argument('--bop-check', action='store_true', help='Also run the slower native Boolean self-interference analyzer.') |
| 22 | +parser.add_argument('--intersections', action='store_true', help='Measure assembled pair overlap as an explicit geometric diagnostic.') |
| 23 | +args = parser.parse_args() |
| 24 | +sys.path.insert(0, args.freecad_lib) |
| 25 | +import FreeCAD as App |
| 26 | +import Part |
| 27 | + |
| 28 | +cases = json.loads(Path(args.cases).read_text()) |
| 29 | +results = [] |
| 30 | +started = time.perf_counter() |
| 31 | +checked_at = datetime.now(timezone.utc).isoformat() |
| 32 | + |
| 33 | +def shape_bounds(shape): |
| 34 | + # Tessellation bounds avoid inflated boxes for trimmed analytic bore intersections. |
| 35 | + shape.tessellate(.003) |
| 36 | + b = shape.optimalBoundingBox(True, False) |
| 37 | + return [[b.XMin, b.YMin, b.ZMin], [b.XMax, b.YMax, b.ZMax]] |
| 38 | + |
| 39 | +def compare_bounds(actual, expected, label): |
| 40 | + largest_error = 0.0 |
| 41 | + for a, e in zip(actual, expected): |
| 42 | + for value, target in zip(a, e): |
| 43 | + largest_error = max(largest_error, abs(value-target)) |
| 44 | + assert abs(value-target) < .025, f'{label}: bounds {actual} != {expected}' |
| 45 | + return largest_error |
| 46 | + |
| 47 | +def valid_component(shape, label): |
| 48 | + assert not shape.isNull() and shape.isValid(), f'{label}: invalid BRep' |
| 49 | + assert len(shape.Solids) == 1 and shape.Volume > 0, f'{label}: expected one positive-volume solid' |
| 50 | + assert shape.isClosed(), f'{label}: open shell' |
| 51 | + if args.bop_check: |
| 52 | + shape.check(True) |
| 53 | + |
| 54 | +with tempfile.TemporaryDirectory(prefix='protolab-bevel-qa-') as folder: |
| 55 | + for index, case in enumerate(cases): |
| 56 | + doc = App.newDocument('BevelQA'+str(index)) |
| 57 | + case_started = time.perf_counter() |
| 58 | + record = {'id': case['id'], 'state': case['state'], 'parameters': case['parameters'], 'codeSha256': hashlib.sha256(case['code'].encode()).hexdigest(), 'passed': False} |
| 59 | + try: |
| 60 | + exec(case['code'], {}) |
| 61 | + roots = [obj for obj in doc.Objects if 'PartId' in obj.PropertiesList] |
| 62 | + assert len(roots) == 1 and roots[0].PartId == 'bevel-gear-pair', 'Missing root metadata' |
| 63 | + root = roots[0] |
| 64 | + objects = list(root.Group) if root.TypeId == 'App::Part' else [root] |
| 65 | + assert len(objects) == len(case['components']), 'Incorrect number of independently editable components' |
| 66 | + volumes = [] |
| 67 | + volume_errors = [] |
| 68 | + bounds_errors = [] |
| 69 | + for obj, expected in zip(objects, case['components']): |
| 70 | + valid_component(obj.Shape, expected['side']) |
| 71 | + bounds_errors.append(compare_bounds(shape_bounds(obj.Shape), expected['bounds'], expected['side'])) |
| 72 | + assert abs(obj.Shape.Volume-expected['volume']) <= max(.02, expected['volume']*.015), f'{expected["side"]}: preview/native volume mismatch' |
| 73 | + volumes.append(obj.Shape.Volume) |
| 74 | + volume_errors.append(abs(obj.Shape.Volume-expected['volume'])/expected['volume']) |
| 75 | + for point in case['annuli']: |
| 76 | + shape = objects[point['component']].Shape |
| 77 | + assert shape.isInside(App.Vector(*point['inside']), 1e-7, True), point['name']+': missing material below the root plane' |
| 78 | + assert not shape.isInside(App.Vector(*point['outside']), 1e-7, True), point['name']+': nonplanar ridge above the root plane' |
| 79 | + if args.intersections and case['state'] == 'assembled' and len(objects) == 2: |
| 80 | + intersection = objects[0].Shape.common(objects[1].Shape) |
| 81 | + assert intersection.isNull() or intersection.isValid(), 'Pair intersection produced an invalid Boolean result' |
| 82 | + record['pairIntersectionVolume'] = 0.0 if intersection.isNull() else intersection.Volume |
| 83 | + before = [shape_bounds(obj.Shape) for obj in objects] |
| 84 | + if len(objects) > 1: |
| 85 | + placement = App.Placement(objects[0].Placement) |
| 86 | + objects[0].Placement.Base = placement.Base+App.Vector(4,3,2) |
| 87 | + doc.recompute() |
| 88 | + for i in range(1,len(objects)): |
| 89 | + compare_bounds(shape_bounds(objects[i].Shape),before[i],'Unmoved component') |
| 90 | + assert abs(shape_bounds(objects[0].Shape)[0][0]-before[0][0][0]-4) < .025, 'First component did not move independently' |
| 91 | + objects[0].Placement = placement |
| 92 | + doc.recompute() |
| 93 | + step = Path(folder)/('case-'+str(index)+'.step') |
| 94 | + Part.export(objects,str(step)) |
| 95 | + imported = Part.Shape() |
| 96 | + imported.read(str(step)) |
| 97 | + assert imported.isValid() and len(imported.Solids) == len(objects), 'STEP roundtrip lost solid components' |
| 98 | + assert abs(imported.Volume-sum(volumes)) < max(.02,sum(volumes)*1e-5), 'STEP roundtrip changed volume' |
| 99 | + fcstd = Path(folder)/('case-'+str(index)+'.FCStd') |
| 100 | + doc.recompute() |
| 101 | + doc.saveAs(str(fcstd)) |
| 102 | + App.closeDocument(doc.Name) |
| 103 | + doc = App.openDocument(str(fcstd)) |
| 104 | + root = next(obj for obj in doc.Objects if 'PartId' in obj.PropertiesList) |
| 105 | + restored = list(root.Group) if root.TypeId == 'App::Part' else [root] |
| 106 | + assert len(restored) == len(objects), 'FCStd roundtrip lost assembly structure' |
| 107 | + for obj, expected in zip(restored,case['components']): |
| 108 | + valid_component(obj.Shape,expected['side']) |
| 109 | + compare_bounds(shape_bounds(obj.Shape),expected['bounds'],'FCStd '+expected['side']) |
| 110 | + record.update(passed=True,components=len(restored),annulusChecks=len(case['annuli']),volumes=volumes,relativeVolumeErrors=volume_errors,maxBoundsError=max(bounds_errors),independentMovement=len(restored)>1,stepRoundtrip=True,fcstdRoundtrip=True) |
| 111 | + except Exception as error: |
| 112 | + record['error'] = str(error) |
| 113 | + finally: |
| 114 | + App.closeDocument(doc.Name) |
| 115 | + record['durationSeconds'] = time.perf_counter()-case_started |
| 116 | + results.append(record) |
| 117 | + report = {'checkedAt':checked_at,'freecadVersion':App.Version(),'casesRequested':len(cases),'checks':['valid closed one-solid components','preview/native component bounds and volume','planar front/back root annuli','independent component movement','STEP roundtrip','FCStd roundtrip'],'bopCheck':args.bop_check,'intersectionDiagnostic':args.intersections,'elapsedSeconds':time.perf_counter()-started,'passed':sum(row['passed'] for row in results),'failed':sum(not row['passed'] for row in results),'cases':results} |
| 118 | + Path(args.output).parent.mkdir(parents=True,exist_ok=True) |
| 119 | + Path(args.output).write_text(json.dumps(report,indent=2)) |
| 120 | + print(json.dumps(record),flush=True) |
| 121 | +failed = sum(not row['passed'] for row in results) |
| 122 | +print(json.dumps({'passed':len(results)-failed,'total':len(results),'failed':failed,'output':args.output}),flush=True) |
| 123 | +sys.exit(bool(failed)) |
0 commit comments