Skip to content

Commit 6e5dced

Browse files
committed
Fix bevel gear pair end caps and preserve planar mounting faces
1 parent fc45cfb commit 6e5dced

8 files changed

Lines changed: 1593 additions & 37 deletions

File tree

data/bevel-pair-native-validation.json

Lines changed: 1142 additions & 0 deletions
Large diffs are not rendered by default.

docs/reference-gears.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,14 @@ Both models include editable smooth radial set screw holes. Their diameter and t
3131

3232
The paired teeth retain the supplied envelopes with a faceted, tapered involute-like profile. They are not generated conjugate bevel tooth surfaces. The source has insufficient information for tooth corrections, root fillets, contact analysis, strength ratings or interchangeable manufacturing geometry. Linked rotation illustrates the tooth ratio; it does not certify transmission contact.
3333

34+
Both root annuli are planar at **H** and **L**. Local radial patches connect these planes to the raised tooth-end contours at **G** and **F**. Full flank samples retain the involute curvature; the module does not triangulate across unrelated teeth or stretch a tooth-tip height through the central face. The preview and FreeCAD share this boundary construction.
35+
3436
## Validation
3537

38+
The end-face repair has a [17-case native FreeCAD audit](../data/bevel-pair-native-validation.json). It covers all six catalog pairs, all four display states on the first two pairs, hex/D/keyway bores, maximum bores at 37° rotation, and 128 inside/outside root-plane probes. All checked assemblies have zero measured component intersection. Every case preserves valid separate solids through STEP and FCStd round trips. Preview tests additionally sample both planar annuli on both gears for every preset and check closed, consistently oriented triangles for all seven shaft profiles.
39+
40+
To reproduce a small native smoke check, run `node --import tsx scripts/verify-bevel-pair.ts`, then run `scripts/verify-bevel-pair.py --intersections` using a FreeCAD-enabled Python. The preparer's optional `--matrix` expands to every catalog state and all seven bore shapes.
41+
3642
The reference expansion was checked in native FreeCAD using 46 cases: all assembly and separated states, both bore endpoints for each individual bevel gear, linked 37° rotation, and the four miniature spur pinions. All generated solids and STEP round trips were valid. The maximum preview/native volume difference was 0.067%; all checked pair positions had zero component intersection. This sampled geometry check does not establish continuous conjugate contact or a load rating.
3743

3844
A separate 48-case preview check covered six bevel pairs, both bore limits, two rotations and both independent component exports. All meshes were closed and outward oriented. The automated gear test suites also retain the existing involute, helical, rack and generic bevel regressions.

scripts/verify-bevel-pair.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
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))

scripts/verify-bevel-pair.ts

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
/** Prepare focused preview/native checks for the bevel pair's faceted mounting geometry. */
2+
import fs from 'node:fs';
3+
import { Box3, Mesh, Vector3 } from 'three';
4+
import bevel, { bevelPairValues } from '../src/parts/bevel-gear-pair/part';
5+
import { generateScript } from '../src/core/freecad';
6+
import { disposeModel } from '../src/core/mechanical';
7+
import { validateParameters } from '../src/core/validation';
8+
import type { Parameters } from '../src/core/types';
9+
10+
type Side = 'pinion' | 'wheel';
11+
const args = process.argv.slice(2);
12+
const output =
13+
args.find((value) => !value.startsWith('--')) ?? '/tmp/protolab-bevel-pair-cases.json';
14+
const matrix = args.includes('--matrix');
15+
const unknown = args.filter((value) => value.startsWith('--') && value !== '--matrix');
16+
if (unknown.length) throw new Error(`Unknown option: ${unknown.join(', ')}`);
17+
const inputs: { id: string; parameters: Parameters; state: string }[] = [];
18+
if (matrix) {
19+
for (const preset of bevel.presets)
20+
for (const state of bevel.states!)
21+
inputs.push({
22+
id: `${preset.id}/${state.id}`,
23+
parameters: preset.parameters,
24+
state: state.id,
25+
});
26+
for (const shape of ['round', 'hex', 'd', 'double-d', 'square', 'polygon', 'keyway']) {
27+
inputs.push({
28+
id: `bore-${shape}/assembled`,
29+
state: 'assembled',
30+
parameters: {
31+
...bevel.defaults,
32+
pinionBore: Number(bevel.defaults.pinionBoreMin),
33+
wheelBore: Number(bevel.defaults.wheelBoreMin),
34+
pinionBoreShape: shape,
35+
wheelBoreShape: shape,
36+
pinionBoreSides: 7,
37+
wheelBoreSides: 5,
38+
pinionBoreAngle: 17,
39+
wheelBoreAngle: 29,
40+
pinionBoreKeyWidth: 1,
41+
wheelBoreKeyWidth: 1,
42+
pinionBoreKeyDepth: 0.5,
43+
wheelBoreKeyDepth: 0.5,
44+
rotation: 13,
45+
},
46+
});
47+
}
48+
inputs.push({
49+
id: 'maximum-bores/rotation-37',
50+
state: 'assembled',
51+
parameters: {
52+
...bevel.defaults,
53+
pinionBore: Number(bevel.defaults.pinionBoreMax),
54+
wheelBore: Number(bevel.defaults.wheelBoreMax),
55+
rotation: 37,
56+
},
57+
});
58+
} else {
59+
inputs.push({ id: 'default/wheel', parameters: bevel.defaults, state: 'wheel' });
60+
inputs.push({ id: 'default/assembled', parameters: bevel.defaults, state: 'assembled' });
61+
}
62+
// Smooth round bores leave uninterrupted annuli; radial screw bores are irrelevant to this defect.
63+
inputs.push({
64+
id: 'planar-annuli/assembled',
65+
parameters: {
66+
...bevel.defaults,
67+
setScrews: false,
68+
pinionBoreShape: 'round',
69+
wheelBoreShape: 'round',
70+
},
71+
state: 'assembled',
72+
});
73+
74+
function previewVolume(mesh: Mesh): number {
75+
const position = mesh.geometry.getAttribute('position'),
76+
index = mesh.geometry.index;
77+
let volume = 0;
78+
for (let i = 0; i < (index?.count ?? position.count); i += 3) {
79+
const p = [0, 1, 2].map((j) =>
80+
new Vector3()
81+
.fromBufferAttribute(position, index ? index.getX(i + j) : i + j)
82+
.applyMatrix4(mesh.matrixWorld),
83+
);
84+
volume += p[0].dot(p[1].clone().cross(p[2])) / 6;
85+
}
86+
return volume;
87+
}
88+
const cases = inputs.map((input) => {
89+
const errors = validateParameters(bevel, input.parameters, input.state);
90+
if (errors.length) throw new Error(`${input.id}: ${errors.join(' ')}`);
91+
const model = bevel.buildGeometry(input.parameters, input.state);
92+
model.updateMatrixWorld(true);
93+
const sides: Side[] =
94+
input.state === 'pinion'
95+
? ['pinion']
96+
: input.state === 'wheel'
97+
? ['wheel']
98+
: ['pinion', 'wheel'];
99+
try {
100+
const components = model.children.map((child, index) => {
101+
const b = new Box3().setFromObject(child, true);
102+
let volume = 0;
103+
child.traverse((object) => {
104+
if (object instanceof Mesh) volume += previewVolume(object);
105+
});
106+
return { side: sides[index], bounds: [b.min.toArray(), b.max.toArray()], volume };
107+
});
108+
const annuli: { component: number; name: string; inside: number[]; outside: number[] }[] = [];
109+
if (!input.parameters.setScrews)
110+
for (const [index, side] of sides.entries()) {
111+
if (input.parameters[`${side}BoreShape`] !== 'round') continue;
112+
const values = bevelPairValues(input.parameters, side),
113+
get = (key: string) => Number(input.parameters[`${side}${key}`]);
114+
for (const front of [false, true]) {
115+
const inner = front ? get('Bore') / 2 : get('Hub') / 2;
116+
const outer = values.v.rootRadius * (front ? values.scale : 1);
117+
const plane = get(front ? 'BodyLength' : 'HubLength');
118+
for (const fraction of [0.35, 0.65])
119+
for (let angleIndex = 0; angleIndex < 16; angleIndex++) {
120+
const a = ((angleIndex + 0.17) * Math.PI) / 8,
121+
r = inner + (outer - inner) * fraction,
122+
normal = front ? 1 : -1;
123+
const point = (z: number) =>
124+
new Vector3(r * Math.cos(a), r * Math.sin(a), z)
125+
.applyMatrix4(model.children[index].matrixWorld)
126+
.toArray();
127+
annuli.push({
128+
component: index,
129+
name: `${side}/${front ? 'front' : 'back'}/${fraction}/${angleIndex}`,
130+
inside: point(plane - normal * 0.005),
131+
outside: point(plane + normal * 0.005),
132+
});
133+
}
134+
}
135+
}
136+
return {
137+
id: input.id,
138+
parameters: input.parameters,
139+
state: input.state,
140+
code: generateScript(bevel, input.parameters, input.state),
141+
components,
142+
annuli,
143+
};
144+
} finally {
145+
disposeModel(model);
146+
}
147+
});
148+
fs.writeFileSync(output, JSON.stringify(cases));
149+
console.log(JSON.stringify({ cases: cases.length, mode: matrix ? 'matrix' : 'smoke', output }));

src/parts/bevel-gear-pair/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,8 @@ This folder owns this part's parameter schema, defaults, preview, FreeCAD recipe
88
- `lib/`: private domain helpers and reference values. Edits here affect this package only.
99
- `index.ts`: API version, stable part ID and display order.
1010

11+
The sampled tooth outline keeps the full involute flank resolution. `lib/core/end-cap.ts` joins each tooth to its own root projection, then fills the planar root-to-bore or root-to-hub annulus. Do not triangulate the entire varying-height tooth contour as one flat polygon: that creates diagonal ridges across the body face. The annulus joins its two boundaries in angular order so dense root samples do not generate nearly collinear triangle slivers.
12+
13+
Preview and FreeCAD use the same closed boundary. Keep the source mounting dimensions and the planar H/L root datums when editing tooth relief. Run `node --import tsx --test tests/reference-gears.test.ts` to check those surfaces and all six catalog assemblies, and the bevel-pair case in `tests/gear-bores.test.ts` for the seven shaft profiles.
14+
1115
Only the generic geometry SDK under `src/core` is shared. Run `npm run parts:check -- bevel-gear-pair` to check this package and `npm run parts:export -- bevel-gear-pair <output-folder>` to prepare a runnable handoff with the SDK and reference assets.

0 commit comments

Comments
 (0)