Split project into frontend and backend

This commit is contained in:
2026-08-13 16:24:36 +08:00
commit f051146486
274 changed files with 953932 additions and 0 deletions
+190
View File
@@ -0,0 +1,190 @@
from dataclasses import dataclass
from pathlib import Path
import math
import cadquery as cq
# All dimensions are millimetres. The main body is a revolved radial section.
INNER_BOTTOM_RADIUS = 60.0
INNER_STEP_RADIUS = 57.5
INNER_SHOULDER_RADIUS = 70.0
INNER_WALL_RADIUS = 79.0
BOTTOM_OUTER_RADIUS = 77.5
BODY_OUTER_RADIUS = 82.5
TOP_OUTER_RADIUS = 85.0
BASE_MIN_Z = -18.0
BASE_MAX_Z = 92.0
BASE_HEIGHT = BASE_MAX_Z - BASE_MIN_Z
@dataclass(frozen=True)
class HousingScale:
radial_scale: float = 1.0
axial_scale: float = 1.0
source: str = "default_template_scale"
def scaled_dimensions(scale: HousingScale) -> dict[str, float]:
return {
"inner_bottom_radius": INNER_BOTTOM_RADIUS * scale.radial_scale,
"inner_step_radius": INNER_STEP_RADIUS * scale.radial_scale,
"inner_shoulder_radius": INNER_SHOULDER_RADIUS * scale.radial_scale,
"inner_wall_radius": INNER_WALL_RADIUS * scale.radial_scale,
"bottom_outer_radius": BOTTOM_OUTER_RADIUS * scale.radial_scale,
"body_outer_radius": BODY_OUTER_RADIUS * scale.radial_scale,
"top_outer_radius": TOP_OUTER_RADIUS * scale.radial_scale,
"min_z": BASE_MIN_Z * scale.axial_scale,
"max_z": BASE_MAX_Z * scale.axial_scale,
"height": BASE_HEIGHT * scale.axial_scale,
"radial_scale": scale.radial_scale,
"axial_scale": scale.axial_scale,
"source": scale.source,
}
def scale_from_motor(
*,
motor_radial_diameter_mm: float,
motor_axis_length_mm: float,
radial_clearance_mm: float = 14.0,
reducer_axial_allowance_mm: float = 42.0,
min_radial_scale: float = 0.45,
max_radial_scale: float = 1.50,
min_axial_scale: float = 0.55,
max_axial_scale: float = 1.80,
) -> HousingScale:
target_inner_radius = motor_radial_diameter_mm / 2.0 + radial_clearance_mm
radial_scale = target_inner_radius / INNER_WALL_RADIUS
target_height = motor_axis_length_mm + reducer_axial_allowance_mm
axial_scale = target_height / BASE_HEIGHT
return HousingScale(
radial_scale=max(min_radial_scale, min(max_radial_scale, radial_scale)),
axial_scale=max(min_axial_scale, min(max_axial_scale, axial_scale)),
source="computed_from_motor_bbox",
)
def _polar_points(radius: float, start_degrees: float, count: int):
return [
(
radius * math.cos(math.radians(start_degrees + 360.0 * index / count)),
radius * math.sin(math.radians(start_degrees + 360.0 * index / count)),
)
for index in range(count)
]
def _radial_point(radius: float, angle_degrees: float, z: float) -> cq.Vector:
angle = math.radians(angle_degrees)
return cq.Vector(radius * math.cos(angle), radius * math.sin(angle), z)
def _radial_direction(angle_degrees: float) -> cq.Vector:
angle = math.radians(angle_degrees)
return cq.Vector(math.cos(angle), math.sin(angle), 0.0)
def build_body(scale: HousingScale = HousingScale()):
rd = scale.radial_scale
zd = scale.axial_scale
def r(value: float) -> float:
return value * rd
def z(value: float) -> float:
return value * zd
# Exact circular-arc midpoints for the two revolved outer blends.
lower_arc_mid = (r(78.8397459621556), z(-8.66025403784439))
upper_arc_mid = (r(83.1458565330652), z(71.9211556282713))
radial_section = (
cq.Workplane("XZ")
.moveTo(r(INNER_BOTTOM_RADIUS), z(-18.0))
.lineTo(r(BOTTOM_OUTER_RADIUS), z(-18.0))
.lineTo(r(BOTTOM_OUTER_RADIUS), z(-13.6602540378))
.threePointArc(lower_arc_mid, (r(BODY_OUTER_RADIUS), z(-5.0)))
.lineTo(r(BODY_OUTER_RADIUS), z(68.3856217223))
.threePointArc(upper_arc_mid, (r(TOP_OUTER_RADIUS), z(75.0)))
.lineTo(r(TOP_OUTER_RADIUS), z(92.0))
.lineTo(r(INNER_WALL_RADIUS), z(92.0))
.lineTo(r(INNER_WALL_RADIUS), z(0.0))
.lineTo(r(INNER_SHOULDER_RADIUS), z(0.0))
.lineTo(r(INNER_SHOULDER_RADIUS), z(-3.0))
.lineTo(r(INNER_STEP_RADIUS), z(-3.0))
.lineTo(r(INNER_STEP_RADIUS), z(-5.0))
.lineTo(r(INNER_BOTTOM_RADIUS), z(-5.0))
.close()
)
return radial_section.revolve(360.0, (0.0, 0.0), (0.0, 1.0))
def build(scale: HousingScale = HousingScale()):
rd = scale.radial_scale
zd = scale.axial_scale
def r(value: float) -> float:
return value * rd
def z(value: float) -> float:
return value * zd
part = build_body(scale)
# Ten Ø5 blind drilled holes, equally spaced on a Ø135 pitch circle.
drill_tip_height = z(1.5021515475689)
for x, y in _polar_points(r(67.5), 18.0, 10):
straight_hole = cq.Solid.makeCylinder(
r(2.5), z(10.0), cq.Vector(x, y, z(-18.0)), cq.Vector(0.0, 0.0, 1.0)
)
drill_tip = cq.Solid.makeCone(
r(2.5),
0.0,
drill_tip_height,
cq.Vector(x, y, z(-8.0)),
cq.Vector(0.0, 0.0, 1.0),
)
part = part.cut(straight_hole).cut(drill_tip)
# Four Ø2.5 blind holes opening at z=0, with 118-degree drill tips.
small_tip_height = z(0.75107577378445)
for x, y in _polar_points(r(71.875), 0.0, 4):
straight_hole = cq.Solid.makeCylinder(
r(1.25), z(4.24892422621555), cq.Vector(x, y, z(0.0)), cq.Vector(0.0, 0.0, -1.0)
)
drill_tip = cq.Solid.makeCone(
r(1.25),
0.0,
small_tip_height,
cq.Vector(x, y, z(-4.24892422621555)),
cq.Vector(0.0, 0.0, -1.0),
)
part = part.cut(straight_hole).cut(drill_tip)
# Eight radial Ø6.6 holes with 90-degree countersinks around the top rim.
for angle in range(0, 360, 45):
direction = _radial_direction(float(angle))
straight_hole = cq.Solid.makeCylinder(
r(3.3), r(3.9), _radial_point(r(78.0), float(angle), z(85.1)), direction
)
countersink = cq.Solid.makeCone(
r(3.3),
r(6.4),
r(3.1),
_radial_point(r(81.9), float(angle), z(85.1)),
direction,
)
part = part.cut(straight_hole).cut(countersink)
return part
def export_scaled(output: Path, scale: HousingScale) -> None:
cq.exporters.export(build(scale), str(output))
if __name__ == "__main__":
output = Path(__file__).resolve().with_name("gear_housing.step")
export_scaled(output, HousingScale())
print(output)
+255
View File
@@ -0,0 +1,255 @@
from pathlib import Path
import math
import cadquery as cq
AXIS = cq.Vector(0.0, 1.0, 0.0)
def _point_on_xz(radius: float, angle_degrees: float, y: float) -> cq.Vector:
angle = math.radians(angle_degrees)
return cq.Vector(radius * math.cos(angle), y, radius * math.sin(angle))
def _cylinder(radius: float, y0: float, length: float, x: float = 0.0, z: float = 0.0):
return cq.Solid.makeCylinder(radius, length, cq.Vector(x, y0, z), AXIS)
def _ring(outer_radius: float, inner_radius: float, y0: float, length: float):
return _cylinder(outer_radius, y0, length).cut(_cylinder(inner_radius, y0, length))
def _conical_ring(
outer_start: float,
outer_end: float,
inner_start: float,
inner_end: float,
y0: float,
length: float,
):
outer = cq.Solid.makeCone(outer_start, outer_end, length, cq.Vector(0.0, y0, 0.0), AXIS)
inner = cq.Solid.makeCone(inner_start, inner_end, length, cq.Vector(0.0, y0, 0.0), AXIS)
return outer.cut(inner)
def _sector_cut(angle_center: float, angle_width: float, y0: float, length: float, radius: float = 100.0):
start = math.radians(angle_center - angle_width / 2.0)
end = math.radians(angle_center + angle_width / 2.0)
points = [
(0.0, 0.0),
(radius * math.cos(start), radius * math.sin(start)),
(radius * math.cos(end), radius * math.sin(end)),
]
plane = cq.Plane(origin=(0.0, y0, 0.0), xDir=(1.0, 0.0, 0.0), normal=(0.0, 1.0, 0.0))
return cq.Workplane(plane).polyline(points).close().extrude(length).val()
def _annular_sector_cut(
angle_center: float,
angle_width: float,
inner_radius: float,
outer_radius: float,
y0: float,
length: float,
profile_fillet_radius: float = 0.0,
axial_fillet_radius: float = 0.0,
):
start = math.radians(angle_center - angle_width / 2.0)
middle = math.radians(angle_center)
end = math.radians(angle_center + angle_width / 2.0)
inner_start = (inner_radius * math.cos(start), inner_radius * math.sin(start))
inner_middle = (inner_radius * math.cos(middle), inner_radius * math.sin(middle))
inner_end = (inner_radius * math.cos(end), inner_radius * math.sin(end))
outer_start = (outer_radius * math.cos(start), outer_radius * math.sin(start))
outer_middle = (outer_radius * math.cos(middle), outer_radius * math.sin(middle))
outer_end = (outer_radius * math.cos(end), outer_radius * math.sin(end))
plane = cq.Plane(origin=(0.0, y0, 0.0), xDir=(1.0, 0.0, 0.0), normal=(0.0, 1.0, 0.0))
cutter = (
cq.Workplane(plane)
.moveTo(*inner_start)
.lineTo(*outer_start)
.threePointArc(outer_middle, outer_end)
.lineTo(*inner_end)
.threePointArc(inner_middle, inner_start)
.close()
.extrude(length)
)
if profile_fillet_radius > 0.0:
# Round the corners of the annular profile (fillet axes parallel to Y).
cutter = cutter.edges("|Y").fillet(profile_fillet_radius)
if axial_fillet_radius > 0.0:
# Round only the straight radial edges at the two axial ends. This
# produces the R10/R5/R2 window run-outs present in the source STEP.
end_edges = [
edge
for edge in cutter.edges("%LINE").vals()
if abs(edge.Center().y - y0) < 1.0e-6
or abs(edge.Center().y - (y0 + length)) < 1.0e-6
]
cutter = cutter.newObject(end_edges).fillet(axial_fillet_radius)
return cutter.val()
def _radial_bar(angle_degrees: float, y0: float, length: float, inner_radius: float, outer_radius: float, width: float):
radial_length = outer_radius - inner_radius
bar = (
cq.Workplane("XY")
.box(radial_length, length, width)
.translate(((inner_radius + outer_radius) / 2.0, y0 + length / 2.0, 0.0))
)
return bar.rotate((0.0, 0.0, 0.0), (0.0, 1.0, 0.0), -angle_degrees).val()
def _rounded_radial_slot(angle_degrees: float, y0: float, length: float):
"""A 40 mm wide radial window with R2 corners in its axial section."""
plane = cq.Plane(
origin=(40.0, y0 + length / 2.0, 0.0),
xDir=(0.0, 1.0, 0.0),
normal=(1.0, 0.0, 0.0),
)
slot = (
cq.Workplane(plane)
.rect(length - 4.0, 36.0)
.offset2D(2.0)
.extrude(42.0)
.rotate((0.0, 0.0, 0.0), (0.0, 1.0, 0.0), -angle_degrees)
)
return slot.val()
def _support_blend(angle_degrees: float, mirrored: bool):
"""One of the six R2 blends between the R64 and R29.2 support arcs."""
sharp = (-62.80715975198, 12.298808230444)
inner_tangent = (-62.1855700706093, 15.131254904777)
lobe_tangent = (-63.6707499640005, 16.8512614004542)
inner_center = (0.0, 0.0)
blend_center = (-61.6824120316291, 17.0669284278203)
lobe_center = (-34.6410161514, 20.0)
def transform(point):
x, z = point
if mirrored:
axis = math.radians(150.0)
ux, uz = math.cos(axis), math.sin(axis)
projection = x * ux + z * uz
x, z = 2.0 * projection * ux - x, 2.0 * projection * uz - z
angle = math.radians(angle_degrees)
return (
x * math.cos(angle) - z * math.sin(angle),
x * math.sin(angle) + z * math.cos(angle),
)
def arc_middle(center, start, end, radius):
ux = (start[0] - center[0]) / radius + (end[0] - center[0]) / radius
uz = (start[1] - center[1]) / radius + (end[1] - center[1]) / radius
scale = radius / math.hypot(ux, uz)
return center[0] + ux * scale, center[1] + uz * scale
points = [transform(point) for point in (sharp, inner_tangent, lobe_tangent)]
centers = [transform(point) for point in (inner_center, blend_center, lobe_center)]
plane = cq.Plane(origin=(0.0, 90.2, 0.0), xDir=(1.0, 0.0, 0.0), normal=(0.0, -1.0, 0.0))
return (
cq.Workplane(plane)
.moveTo(*points[0])
.threePointArc(arc_middle(centers[0], points[0], points[1], 64.0), points[1])
.threePointArc(arc_middle(centers[1], points[1], points[2], 2.0), points[2])
.threePointArc(arc_middle(centers[2], points[2], points[0], 29.2), points[0])
.close()
.extrude(10.0)
.val()
)
def build():
# Front cylindrical shell and its smaller continuation.
part = cq.Workplane(obj=_ring(79.4, 74.4, -10.0, 22.0))
part = part.union(_ring(78.4, 74.4, 12.0, 43.2))
# Nine radial R2 holes through the front lip, spaced every 40 degrees.
for angle in (90.0, 50.0, 10.0, -30.0, -70.0, -110.0, -150.0, -190.0, -230.0):
start = _point_on_xz(72.0, angle, -5.0)
direction = _point_on_xz(1.0, angle, 0.0)
direction = cq.Vector(direction.x, 0.0, direction.z)
part = part.cut(cq.Solid.makeCylinder(2.0, 10.0, start, direction))
# Six long ventilation windows, 40-degree openings with 20-degree ribs.
for center in (-110.0, -50.0, 10.0, 70.0, 130.0, 190.0):
part = part.cut(
_annular_sector_cut(center, 40.0, 70.0, 82.0, 12.0, 40.0, axial_fillet_radius=10.0)
)
# Taper from the large front shell to the narrower middle shell.
part = part.union(_conical_ring(78.4, 73.2, 74.4, 69.2, 55.2, 20.0))
part = part.union(_ring(73.2, 60.0, 75.2, 5.0))
# Three small axial holes in the first shoulder ring.
for angle in (30.0, 150.0, 270.0):
p = _point_on_xz(64.0, angle, 75.2)
part = part.cut(cq.Solid.makeCylinder(2.0, 5.0, p, AXIS))
# Three-lobed support plate: a disk with three large circular openings.
support = cq.Workplane(obj=_ring(73.2, 64.0, 80.2, 10.0))
for x, z in ((34.6410161514, 20.0), (-34.6410161514, 20.0), (0.0, -40.0)):
support = support.cut(_cylinder(29.2, 80.2, 10.0, x, z))
part = part.union(support)
# Thin middle shell and its second, angularly shifted six-window pattern.
part = part.union(_ring(73.2, 69.2, 90.2, 30.0))
for center in (-150.0, -90.0, -30.0, 30.0, 90.0, 150.0):
part = part.cut(
_annular_sector_cut(center, 40.0, 65.0, 76.0, 90.2, 20.0, axial_fillet_radius=5.0)
)
# Rear shell is one continuous 2:7 taper. Its inner cavity changes from
# a steep R69.2 -> R63.6 cone to the matching shallow taper at Y=125.8.
taper_outer = cq.Solid.makeCone(
73.2, 64.91428571428571, 29.0, cq.Vector(0.0, 120.2, 0.0), AXIS
)
taper_inner_a = cq.Solid.makeCone(69.2, 63.6, 5.6, cq.Vector(0.0, 120.2, 0.0), AXIS)
taper_inner_b = cq.Solid.makeCone(63.6, 59.2, 15.4, cq.Vector(0.0, 125.8, 0.0), AXIS)
part = part.union(taper_outer.cut(taper_inner_a).cut(taper_inner_b))
hub = cq.Workplane(obj=_ring(38.0, 26.0, 129.2, 4.0)).union(
_ring(38.0, 30.0, 133.2, 16.0)
)
part = part.union(hub)
# Stop 0.0001 mm before the coincident rear-plate face so OpenCascade
# keeps the six ribs in one valid solid.
for center in (30.0, 90.0, 150.0, 210.0, 270.0, 330.0):
part = part.cut(_rounded_radial_slot(center, 131.2, 9.9999))
# Central bore through the solid rear plate, then its six fan windows.
part = part.cut(_cylinder(30.0, 141.2, 8.0))
for center in (45.0, 105.0, 165.0, 225.0, 285.0, 345.0):
cutter = _annular_sector_cut(
center, 30.0, 38.0, 59.2, 141.2, 8.0, profile_fillet_radius=2.0
)
part = part.cut(cutter)
# These holes pass through both the end plate and the previously added hub.
for angle in (-90.0, -30.0, 30.0, 90.0, 150.0, 210.0):
p = _point_on_xz(34.0, angle, 141.2)
part = part.cut(cq.Solid.makeCylinder(1.81198, 8.0, p, AXIS))
# Add the small support blends last so they cannot interfere with fusing
# the face-connected rear shell and end plate.
for mirrored in (False, True):
for angle in (0.0, 120.0, 240.0):
part = part.union(_support_blend(angle, mirrored))
# Remove only the six 0.0001 mm run-out residuals after the complete part
# is connected. This reaches the exact Y=141.2 boundary without causing
# the earlier coincident-face split into six solids.
for center in (30.0, 90.0, 150.0, 210.0, 270.0, 330.0):
full_slot = _rounded_radial_slot(center, 131.2, 10.0)
stable_slot = _rounded_radial_slot(center, 131.2, 9.9999)
part = part.cut(full_slot.cut(stable_slot))
return part
if __name__ == "__main__":
output = Path(__file__).resolve().with_name("外壳框架.step")
cq.exporters.export(build(), str(output))
print(output)
File diff suppressed because one or more lines are too long