|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Check DIN 915 macro solids, cylindrical point, shoulder, socket and STEP export. |
| 3 | +
|
| 4 | +Prepare with node --import tsx scripts/verify-din915.ts, then run this script with |
| 5 | +a FreeCAD-capable Python. These four optional native checks do not run in CI. |
| 6 | +""" |
| 7 | +import argparse |
| 8 | +from datetime import datetime, timezone |
| 9 | +import hashlib |
| 10 | +import json |
| 11 | +import math |
| 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-din915-cases.json') |
| 19 | +parser.add_argument('--freecad-lib', default='/Applications/FreeCAD.app/Contents/Resources/lib') |
| 20 | +parser.add_argument('--output', default='/tmp/protolab-din915-results.json') |
| 21 | +args = parser.parse_args() |
| 22 | +sys.path.insert(0, args.freecad_lib) |
| 23 | +import FreeCAD as App |
| 24 | +import Part |
| 25 | + |
| 26 | +cases = json.loads(Path(args.cases).read_text()) |
| 27 | +results = [] |
| 28 | +checked_at = datetime.now(timezone.utc).isoformat() |
| 29 | +with tempfile.TemporaryDirectory(prefix='protolab-din915-qa-') as folder: |
| 30 | + for index, case in enumerate(cases): |
| 31 | + doc = App.newDocument('DIN915QA'+str(index)) |
| 32 | + started = time.perf_counter() |
| 33 | + record = {'id': case['id'], 'codeSha256': hashlib.sha256(case['code'].encode()).hexdigest(), 'passed': False} |
| 34 | + try: |
| 35 | + exec(case['code'], {}) |
| 36 | + obj = next(obj for obj in doc.Objects if 'PartId' in obj.PropertiesList) |
| 37 | + shape = obj.Shape |
| 38 | + p = case['parameters'] |
| 39 | + assert shape.isValid() and shape.isClosed() and len(shape.Solids) == 1 and shape.Volume > 0, 'Invalid screw solid' |
| 40 | + # OCC's optimal box adds a conservative gap for trimmed helical faces. |
| 41 | + # Measure actual surface vertices at a tighter deflection than the assertion. |
| 42 | + vertices, _ = shape.tessellate(.003) |
| 43 | + dimensions = [max(getattr(v,axis) for v in vertices)-min(getattr(v,axis) for v in vertices) for axis in ('x','y','z')] |
| 44 | + assert all(abs(a-b) < .025 for a,b in zip(dimensions,[p['diameter'],p['diameter'],p['length']])), 'Incorrect overall diameter/length' |
| 45 | + relative_error = abs(shape.Volume-case['previewVolume'])/case['previewVolume'] |
| 46 | + assert relative_error < .015, 'Preview/native volume mismatch' |
| 47 | + def inside(r, angle, z): |
| 48 | + return shape.isInside(App.Vector(r*math.cos(angle),r*math.sin(angle),z-p['length']/2),1e-7,True) |
| 49 | + # Z is the full cylindrical segment, before the shoulder transition. |
| 50 | + for fraction in [.1,.9]: |
| 51 | + for i in range(12): |
| 52 | + a = (i+.17)*math.pi/6 |
| 53 | + assert inside(p['tipDiameter']/2-.005,a,p['tipLength']*fraction), 'Point cylinder missing material' |
| 54 | + assert not inside(p['tipDiameter']/2+.005,a,p['tipLength']*fraction), 'Point cylinder exceeds dp' |
| 55 | + # Below the thread root, the shoulder follows its conical envelope. |
| 56 | + fraction = .25 |
| 57 | + r = p['tipDiameter']/2+(p['diameter']-p['tipDiameter'])/2*fraction |
| 58 | + z = p['tipLength']+p['dogShoulderLength']*fraction |
| 59 | + assert inside(r-.005,.173,z) and not inside(r+.005,.173,z), 'Shoulder transition differs from preview' |
| 60 | + floor = p['length']-p['driveDepth'] |
| 61 | + assert inside(0,0,floor-.005) and not inside(0,0,floor+.005), 'Incorrect blind socket depth' |
| 62 | + for i in range(6): |
| 63 | + a = i*math.pi/3 |
| 64 | + z = p['length']-p['driveDepth']/2 |
| 65 | + assert not inside(p['driveWidth']/2-.005,a,z), 'Hex socket too narrow' |
| 66 | + assert inside(p['driveWidth']/2+.005,a,z), 'Hex socket too wide' |
| 67 | + assert obj.CatalogSource == 'references/din915-dimensions.png', 'Reference source missing' |
| 68 | + assert 'length' not in json.loads(obj.CatalogDimensions), 'Prototype length incorrectly verified' |
| 69 | + step = str(Path(folder)/(str(index)+'.step')) |
| 70 | + Part.export([obj],step) |
| 71 | + restored = Part.Shape() |
| 72 | + restored.read(step) |
| 73 | + assert restored.isValid() and len(restored.Solids) == 1, 'STEP lost solid' |
| 74 | + assert abs(restored.Volume-shape.Volume) < max(.01,shape.Volume*1e-5), 'STEP volume changed' |
| 75 | + record.update(passed=True,dimensions=dimensions,volume=shape.Volume,relativePreviewVolumeError=relative_error,pointProbes=48,socketProbes=14,shoulderProbes=2,stepRoundtrip=True) |
| 76 | + except Exception as error: |
| 77 | + record['error'] = str(error) |
| 78 | + finally: |
| 79 | + App.closeDocument(doc.Name) |
| 80 | + record['durationSeconds'] = time.perf_counter()-started |
| 81 | + results.append(record) |
| 82 | + report = {'checkedAt': checked_at, 'freecadVersion': App.Version(), 'casesRequested': len(cases), 'passed': sum(row['passed'] for row in results), 'failed': sum(not row['passed'] for row in results), 'cases': results} |
| 83 | + Path(args.output).write_text(json.dumps(report,indent=2)) |
| 84 | + print(json.dumps(record),flush=True) |
| 85 | +sys.exit(any(not row['passed'] for row in results)) |
0 commit comments