Initial copy stand design

This commit is contained in:
2026-08-22 11:31:10 -07:00
commit 58a6344731
66 changed files with 2668740 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
__pycache__/
*.py[cod]
core
core.*
File diff suppressed because it is too large Load Diff
Binary file not shown.
+159
View File
@@ -0,0 +1,159 @@
# Copy Stand Base Plate
A parametric, 3D-printed copy-stand base sized for a Bambu Lab X2D and designed
around a Lobster Holder V2.1 on its CS-Lite. The base uses a removable 2020
extrusion socket, adjustable 1/4-20 feet, and an optical-breadboard-style
1-inch grid of heat-set inserts.
The structural parts are intended for black ASA. Grid lines are flush blue ASA
inlays printed against a textured build plate.
## Coordinate system and principal dimensions
- Base: 203.2 x 248 x 15 mm, with 12.7 mm corner radii.
- The grid origin is Y=0 and positive Y runs toward the operator. The socket's
straight front reference face is Y=12.7 mm (0.5 inch), halfway between the
origin and the first insert row.
- The 8 x 8 insert array has 25.4 mm spacing. Its rows are Y=25.4 through
Y=203.2 mm relative to the socket datum.
- A blue Y=0 datum line continues beneath the socket, leaving aligned visible
segments on both sides.
- The removable socket captures 50 mm of a nominal 20 x 20 mm extrusion.
- Its 110 x 38.7 x 10 mm flange reaches from the rear plate edge to the
half-inch reference face. The column is shifted forward to improve the
overturning load path and reduce the camera arm's required overhang.
- The two outside inserts in the front grid row also mount the front leveling
feet. They cannot accept top-side accessories while the feet are installed.
## Hardware
| Qty | Item | Relevant dimensions |
| ---: | --- | --- |
| 70 | [McMaster 97171A240 brass 1/4-20 heat-set insert](https://www.mcmaster.com/97171A240/) | 9.525 mm body, 12.7 mm flange, 1.27 mm flange thickness, 8.026 mm maximum hole |
| 4 | [1/4-20 adjustable foot](https://www.amazon.com/dp/B095PDSNPM) | 19 mm pad diameter, 8.5 mm pad height, 17.8 mm stud |
| 4 | [Knurled 1/4-20 locking nut](https://www.amazon.com/dp/B0GQSX98V4) | 24.4 mm OD, 6 mm thick |
| 1 | [European-style 2020 T-slot extrusion](https://www.amazon.com/dp/B08Y8N7FD1) | 20 x 20 x 400 mm, 6 mm slots |
| 4 | 1/4-20 x 1-inch socket-head screws | Socket flange to base |
| 4 | Black 1/4-inch flat washers, specified as 0.7 inch (17.78 mm) OD | Load distribution beneath socket screw heads; measure thickness before assembly |
| 4 | M5 x 10 mm socket-head screws and European 2020 T-nuts | Extrusion clamp; verify screw length against the selected T-nut |
The four feet use four of the listed inserts: two dedicated rear inserts and
the two shared front grid inserts. Four more dedicated inserts secure the
socket. The remaining 64 inserts make up the complete grid.
The McMaster drawing requires at least 0.530 inch (13.46 mm) of material. Every
insert boss in this design is 15 mm tall, including all four socket-mounting
bosses. The 52.5 x 22.5 mm locating slot passes through the base between those
bosses, so it does not reduce their thickness. Inserts install from the open
underside with their flange flush against the boss. The 1.35 mm flange seat and
9.525 mm body leave a 4.125 mm screw-clearance tunnel below the clean 6.8 mm
opening in the working surface.
## Build
Requirements: OpenSCAD 2021.01 or later, Ruby, and Python 3.
```bash
ruby build.rb
```
The build is incremental. Run `REBUILD=1 ruby build.rb` to regenerate every
component after changing geometry parameters.
Generated files are placed under `exports/3mf` and `exports/stl`. The primary
files are:
- `base_multicolor.3mf`: aligned black base and blue inlay meshes, assigned to
filaments 1 and 2.
- `socket.3mf`: removable black socket and gusset assembly.
- `coupon_multicolor.3mf`: two-piece insert, rail-clearance, through-key-fit,
and line calibration coupon.
- `coupon_key.3mf`: standalone full-size tongue gauge from the two-piece
coupon.
The portable multipart files intentionally do not embed a printer or process
profile. The individual aligned meshes and single-color fallbacks are retained
alongside them.
## Calibrate before the full base
Print `coupon_multicolor.3mf` in the intended ASA, face down on the intended
textured plate. It produces a main coupon and a separate full-size tongue
gauge. From left to right, the insert pilots are 7.70, 7.85, 8.00, 8.15, and
8.30 mm. The four rail gauges are 20.0, 20.1, 20.2, and 20.3 mm. Each is a
four-wall collar with the production 6 mm side walls, 2.7 mm end walls, and
full 50 mm engagement depth. External gussets and M5 holes are omitted because
they do not define the rail-fit surfaces. The coupon also
contains the production-width blue line and a 6 mm-deep production-size
52.5 x 22.5 mm through-slot. The separate 52 x 22 x 6 mm tongue prints in the
same orientation as the socket tongue and has a support-free stop shoulder and
vertical grip for insertion and removal. Verify that the slot is visibly open,
with no first-layer membrane, before testing the fit.
The production model defaults to the physically calibrated 8.10 mm insert
pilot and 20.1 mm rail cavity. These values were selected from an ASA coupon:
the 20.1 mm collar accepted the fully cooled rail through all 50 mm by hand
without objectionable play, and the 8.10 mm pilot provided the preferred
insert fit. The rectangular tongue and through-slot also passed the physical
fit test, so their dimensions remain unchanged. For another printer, material,
or process, select the smallest rail gauge that fits without forcing and use
the insert pilot that gives the required installation grip. Change
`insert_pilot_diameter` or `rail_clearance` at the top of `copy_stand.scad`,
then rebuild. The 8.30 mm coupon remains a diagnostic oversize option;
McMaster specifies 8.026 mm as the maximum nominal hole diameter, while this
design's 8.10 mm production value reflects the tested print process.
## Bambu Studio setup
1. Open `base_multicolor.3mf` as one multipart object.
2. Assign black ASA to Filament 1 and blue ASA to Filament 2.
3. Keep the 248 mm dimension along the printer's Y axis and place the base to
one side. Its 203.2 mm X width leaves about 52.8 mm for a nominal 35 x 35 mm
prime tower and clearance.
4. Confirm the tower and any brim fit in Preview before printing.
5. Print the base face down. No support is designed into the base, socket, or
coupon.
The coupon occupies approximately 228 x 110 x 53.2 mm including its separate
tongue and full-depth rail collars. Orient its 228 mm dimension along Y to
leave ample X space for the prime tower.
A 0.4 mm nozzle and 0.20 mm layers are the compatibility target. Use at least
six walls, six top and bottom layers, and 40-50% gyroid or cubic infill for the
socket. Use at least four walls for the base, suitable ASA bed adhesion, a
fully heat-soaked enclosure, and conservative cooling. The modeled open cells
provide the base structure; sparse slicer infill is not relied on there.
## Assembly
1. Heat-set the inserts from the underside. Stop when each brass flange is
flush with its counterbore; do not pull it toward the textured top surface.
2. Thread the leveling feet through their locking nuts and into the two rear
foot inserts and two outer inserts of the front grid row.
3. Lower the socket's 52 x 22 x 6 mm tongue into the base's through-slot, then
fasten the flange with four 0.7-inch OD washers and four 1/4-20 x 1-inch
screws. The screws provide clamping and vertical retention; the tongue
provides repeatable X/Y location and carries lateral shear. With a washer
thickness `t` in millimeters, nominal insert engagement is
`11.275 - t` mm, limited by the insert's 9.525 mm threaded length.
4. Preload two T-nuts in each side slot of the extrusion. Lower the extrusion
to the socket floor and fasten it through the four M5 clamp holes.
5. Level the base, then tighten each knurled locking nut against its insert
flange.
The Lobster/CS-Lite locating fixture, Arca rail, camera carriage, and camera
alignment accessories are deliberately deferred. They can attach to the grid
without changing this base.
The socket's broad side buttresses stop before the four base screws. Vertical
19 mm washer and tool envelopes preserve access above those screws, and 14 mm
horizontal tunnels provide driver access to the M5 rail clamps without
sacrificing the collar wall around the T-slot extrusion. The 19 mm fore/aft
screw-row spacing and shallower side buttresses improve overturning and
torsional resistance compared with the compact first revision.
Before mounting a camera, proof-test the assembled stand with a dummy load at
the maximum working overhang. Under the normal payload, target less than 1 mm
of rail-tip motion. After 24 hours at twice the intended payload, require no
cracking, washer embedment, or loose fasteners and less than 0.25 mm permanent
rail-tip displacement after unloading.
+16
View File
@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 755.70758 647.99997" fill="#000000">
<g transform="translate(-4.873836,-5.198043)">
<g transform="translate(-337.27261,-73.468683)">
<path d="m 693.64932,79.277393 c -1.95734,0.245333 -8.92667,1.101333 -15.28667,1.834666 -59.064,6.604 -121.43066,33.018671 -169.368,71.661331 -37.052,29.96 -65.54533,65.42267 -88.29066,110.18 -10.884,21.27866 -11.984,25.06933 -7.33733,24.70266 1.46666,-0.12266 9.66133,-0.24533 18.09866,-0.12266 l 15.408,0.12266 18.22,21.4 c 10.028,11.74 18.588,21.4 18.95467,21.4 0.368,0 1.10133,-3.91333 1.59066,-8.68266 0.36667,-4.76934 1.956,-13.94 3.30134,-20.42134 11.74,-55.02933 35.58533,-100.88666 72.516,-139.28533 23.724,-24.824 47.32533,-41.94533 79.48666,-57.84266 21.156,-10.393334 37.54267,-16.508001 58.82,-22.010667 l 13.452,-3.546667 -7.948,0.122667 c -4.40267,0.122666 -9.53867,0.366666 -11.61733,0.488"/>
<path d="m 963.90398,116.08673 c -100.64267,8.43733 -217.18267,54.17333 -324.67333,127.30133 -22.13333,15.04133 -58.69733,42.06666 -58.69733,43.28933 0,0.61066 5.136,0.97866 11.372,0.97866 h 11.25066 l 14.064,-10.51733 c 18.58667,-13.94133 52.828,-36.808 73.98267,-49.404 21.768,-13.08533 70.56,-37.78666 93.54933,-47.448 82.78933,-34.72933 160.68666,-48.18 208.5,-36.196 19.07732,4.76934 35.34132,16.75334 41.94402,30.69334 3.0573,6.604 3.3026,8.19333 3.3026,24.21333 0,16.14133 -0.2453,17.732 -4.0346,28.73733 -10.028,29.104 -32.896,65.91333 -63.71336,102.72133 -4.76933,5.748 -8.68133,10.63867 -8.68133,11.00534 0,0.368 18.09867,0.48933 40.35469,0.24533 l 40.3546,-0.36667 v -18.34266 -18.344 l 7.8267,-10.63867 c 17.2427,-23.60133 32.4067,-50.74933 39.8667,-71.78266 20.9093,-59.06534 -2.936,-96.36267 -67.26,-105.16667 -11.6174,-1.59066 -45.49069,-2.20133 -59.30802,-0.97866"/>
<path d="m 397.10266,402.60538 v 99.052 h 41.57733 V 365.91872 l 5.86933,6.48133 c 3.18,3.54667 17.36534,19.93333 31.55067,36.44267 14.18533,16.38666 35.34133,40.844 47.08,54.296 11.74,13.45066 23.968,27.636 27.27066,31.54933 l 5.86934,6.96933 h 21.27866 21.27734 v -99.052 -99.05199 h -21.52267 -21.644 l 0.61067,67.86933 c 0.24533,37.29733 0.24533,67.86933 -0.12267,67.86933 -0.36533,-0.12267 -2.68933,-2.568 -5.01333,-5.50267 -2.32267,-2.93466 -16.14134,-18.95466 -30.572,-35.58666 -41.33334,-47.692 -52.216,-60.28667 -67.25733,-77.89733 l -14.064,-16.508 -21.03334,-0.12267 -21.15466,-0.12133 v 99.05199"/>
<path d="m 615.99598,402.60538 v 99.052 h 41.57867 V 369.22139 l 25.68,34.11733 c 14.06267,18.83333 30.32667,40.356 36.07467,47.93733 l 10.63866,13.81733 35.952,-47.936 35.952,-47.93599 0.24533,51.60533 0.36667,51.604 14.552,-12.34934 c 8.072,-6.848 17.24267,-14.79733 20.54533,-17.732 l 5.86934,-5.25866 v -66.76933 -66.768 h -21.03334 l -21.156,0.12133 -35.46266,47.08133 c -19.444,25.80267 -35.83067,46.836 -36.19733,46.71333 -0.73467,-0.36666 -16.63067,-21.03333 -54.17334,-71.04933 l -17.24266,-22.86666 h -21.03334 -21.156 v 99.05199"/>
<path d="m 968.91731,383.89605 -47.56933,0.36667 -25.192,24.824 c -80.58667,79.12 -147.84533,129.13599 -225.74133,167.77733 -108.836,54.052 -203.85333,70.43866 -258.27066,44.51333 -11.25067,-5.38133 -23.11333,-15.53067 -28.12667,-23.968 -10.51733,-17.97733 -10.39466,-45.492 0.24534,-69.58133 3.79066,-8.68267 3.79066,-9.04933 -0.612,-9.04933 h -3.54667 l -0.36667,-10.76134 -0.36666,-10.64 -5.87067,9.784 c -14.42933,24.212 -26.168,52.95067 -29.592,72.516 -5.01467,28.37067 0.61067,49.40533 17.73067,66.28 10.15066,10.02667 18.95466,15.28666 33.996,20.29866 30.572,10.15067 71.29333,11.49467 115.68399,3.548 l 16.01867,-2.81333 14.67467,9.04933 c 55.51866,33.87334 114.33866,50.62667 177.43866,50.62667 54.78533,0 100.76533,-10.63867 149.43599,-34.484 55.15067,-27.14933 103.332,-70.68266 135.73866,-122.65466 7.4587,-11.98534 22.3787,-40.844 24.5787,-47.32667 l 1.224,-4.03466 -2.0787,3.05733 c -32.52932,46.95866 -68.60266,78.99733 -115.56132,102.35466 -68.60267,34.24 -147.968,41.21067 -219.13866,19.076 -12.352,-3.79066 -40.96667,-15.16266 -40.844,-16.14133 0,-0.36667 13.084,-7.21467 28.98133,-15.04133 15.89733,-7.82667 36.68667,-18.71067 46.10267,-24.33467 37.54133,-22.012 84.74533,-55.152 119.84133,-84.13333 l 13.452,-11.12933 0.36666,4.892 0.36667,4.89066 h 90.49333 90.36929 v -18.95466 -18.95334 h -69.70262 -69.70534 v -4.64666 c 0,-4.40267 0.98,-5.62667 16.99867,-20.78934 l 16.99733,-16.14133 h 39.86667 l 39.86529,-0.12267 v -19.56666 c 0,-10.63867 -0.1226,-19.32 -0.244,-19.19867 -0.2453,0.244 -21.76796,0.48934 -47.93729,0.612"/>
<path d="m 455.18932,464.60538 v 53.56134 l -22.74533,0.612 -22.74667,0.61066 5.01467,11.00667 c 13.452,29.47066 32.89467,59.79866 52.58266,81.932 l 5.38134,5.992 17.732,-1.46667 c 19.19866,-1.59067 36.31866,-4.52533 58.57466,-10.15067 12.96267,-3.17866 43.53467,-12.84 45.00134,-14.184 0.24533,-0.24533 -2.20134,-2.81466 -5.50267,-5.62666 -36.07467,-30.08267 -64.56667,-68.60267 -82.42133,-111.28133 -5.50267,-12.84 -6.236,-13.94 -27.63734,-38.764 -12.10533,-14.064 -22.25599,-25.55867 -22.62266,-25.55867 -0.36667,-0.12267 -0.61067,23.968 -0.61067,53.31733"/>
<g transform="matrix(0.53949817,0,0,0.53949817,789.88891,124.13937)">
<path d="m 134.23594,367.14576 v 34.66667 h 69.06667 c 37.86666,0 71.06666,-0.4 73.86666,-0.8 4.4,-0.8 7.2,-3.73333 31.33333,-32.8 14.53334,-17.46667 26.53334,-32.4 26.8,-32.93333 0.26667,-0.66667 -0.93333,-1.46667 -2.66666,-2 -1.73334,-0.4 -47.06667,-0.8 -100.66667,-0.8 h -97.73333 z"/>
<path d="m 134.90261,432.74576 c -0.4,0.93334 -0.53333,27.2 -0.4,58.4 l 0.4,56.53333 38.4,-37.06666 38.26666,-37.06667 -0.26666,-20.93333 -0.4,-20.8 -37.73334,-0.4 c -29.46666,-0.26667 -37.86666,0 -38.26666,1.33333 z"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.6 KiB

+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
require "fileutils"
require "open3"
ROOT = File.expand_path(__dir__)
SCAD = File.join(ROOT, "copy_stand.scad")
OPENSCAD = ENV.fetch("OPENSCAD", "openscad")
PYTHON = ENV.fetch("PYTHON", "python3")
EXPORT_3MF = File.join(ROOT, "exports", "3mf")
EXPORT_STL = File.join(ROOT, "exports", "stl")
COMPONENT_3MF = File.join(EXPORT_3MF, "components")
COMPONENT_STL = File.join(EXPORT_STL, "components")
def run!(*command)
puts command.join(" ")
output, status = Open3.capture2e(*command)
puts output unless output.empty?
raise "Command failed: #{command.join(' ')}" unless status.success?
end
def export(part, directory, extension)
output = File.join(directory, "#{part}.#{extension}")
return output if ENV["REBUILD"] != "1" && File.size?(output)
run!(OPENSCAD, "-o", output, "-D", %(part="#{part}"), SCAD)
output
end
def export_segment(part, index, directory, extension)
output = File.join(directory, "#{part}_#{index}.#{extension}")
return output if ENV["REBUILD"] != "1" && File.size?(output)
run!(OPENSCAD, "-o", output, "-D", %(part="#{part}"),
"-D", "segment_index=#{index}", SCAD)
output
end
def package(output, title, bases, accents = [])
command = [
PYTHON, File.join(ROOT, "tools", "package_multimaterial_3mf.py"),
output, "--title", title
]
bases.each { |path| command.concat(["--base", path]) }
accents.each { |path| command.concat(["--accent", path]) }
run!(*command)
end
FileUtils.mkdir_p([EXPORT_3MF, EXPORT_STL, COMPONENT_3MF, COMPONENT_STL])
%w[base_accent socket coupon coupon_black coupon_key coupon_accent].each do |part|
export(part, EXPORT_3MF, "3mf")
end
%w[socket coupon coupon_key].each do |part|
export(part, EXPORT_STL, "stl")
end
base_segments = (0..5).map do |index|
export_segment("base_segment", index, COMPONENT_3MF, "3mf")
end
black_segments = (0..5).map do |index|
export_segment("base_black_segment", index, COMPONENT_3MF, "3mf")
end
package(File.join(EXPORT_3MF, "base.3mf"), "Copy Stand Base - Single Color",
base_segments)
package(File.join(EXPORT_3MF, "base_black.3mf"), "Copy Stand Base - Black Mesh",
black_segments)
package(File.join(EXPORT_3MF, "base_multicolor.3mf"), "Copy Stand Base",
black_segments, [File.join(EXPORT_3MF, "base_accent.3mf")])
package(File.join(EXPORT_3MF, "coupon_multicolor.3mf"),
"Copy Stand Calibration Coupon",
[File.join(EXPORT_3MF, "coupon_black.3mf"),
File.join(EXPORT_3MF, "coupon_key.3mf")],
[File.join(EXPORT_3MF, "coupon_accent.3mf")])
# A multi-solid ASCII STL fallback avoids forcing OpenSCAD/CGAL to union the
# large stepped base in one expensive operation. Slicers merge the touching
# solids at slice time.
segment_stls = (0..5).map do |index|
export_segment("base_segment", index, COMPONENT_STL, "stl")
end
File.open(File.join(EXPORT_STL, "base.stl"), "wb") do |combined|
segment_stls.each { |path| combined.write(File.binread(path)) }
end
puts "Exports written to #{File.join(ROOT, 'exports')}"
+628
View File
@@ -0,0 +1,628 @@
/*
* Parametric copy-stand base for a Bambu Lab X2D.
* Service coordinates: X left-to-right, Y rear-to-front, Z bottom-to-top.
* The grid origin is at plate Y=datum_y. The socket's straight front edge is
* 0.5 inch forward of that origin and remains a convenient reference face.
*/
/* [Output] */
part = "assembly"; // [assembly,base,base_black,base_segment,base_black_segment,base_accent,socket,coupon,coupon_black,coupon_key,coupon_accent]
segment_index = 0; // [0:1:5]
show_references = true;
/* [Base] */
plate_width = 203.2;
plate_depth = 248;
plate_height = 15;
corner_radius = 12.7;
datum_y = 26;
top_skin = 3.2;
perimeter_wall = 3.2;
rib_width = 2.4;
/* [Grid inlay] */
grid_pitch = 25.4;
grid_columns = 8;
grid_rows = 8;
grid_line_width = 0.8;
inlay_depth = 0.6;
inlay_clearance = 0.08;
/* [McMaster 97171A240 inserts] */
insert_pilot_diameter = 8.1;
insert_body_length = 9.525;
insert_flange_diameter = 12.9;
insert_flange_depth = 1.35;
screw_clearance_diameter = 6.8;
insert_boss_diameter = 20;
/* [Feet] */
foot_pad_diameter = 19;
foot_pad_height = 8.5;
foot_stud_length = 17.8;
foot_nut_diameter = 24.4;
foot_nut_height = 6;
/* [Removable 2020 socket] */
rail_size = 20;
rail_clearance = 0.05;
socket_capture = 50;
socket_front_y = grid_pitch / 2;
socket_rear_y = -datum_y;
socket_flange_size = [110, socket_front_y - socket_rear_y, 10];
socket_key_size = [52, 22, 6];
socket_key_clearance = 0.25;
socket_side_wall = 6;
socket_end_wall = 2.7;
socket_mount_x = 45;
socket_mount_ys = [-16, 3];
socket_mount_tool_diameter = 19;
socket_gusset_outer_x = 35;
socket_gusset_y_span = [-23, 10];
socket_gusset_height = 32;
rail_screw_diameter = 5.5;
rail_screw_head_diameter = 9.5;
rail_screw_head_depth = 3;
rail_tool_diameter = 14;
rail_screw_heights = [15, 40];
/* [Calibration] */
coupon_insert_diameters = [7.70, 7.85, 8.00, 8.15, 8.30];
coupon_rail_gaps = [20.0, 20.1, 20.2, 20.3];
$fn = $preview ? 36 : 72;
eps = 0.02;
assert(plate_width <= 256 && plate_depth <= 256,
"Base exceeds the X2D main-nozzle build area");
assert(plate_height >= 13.462,
"Insert bosses violate McMaster's 0.530-inch minimum material thickness");
assert(top_skin > inlay_depth,
"Top skin must be thicker than the inlay");
assert(grid_columns == 8 && grid_rows == 8,
"This release's shared front-foot geometry expects an 8 x 8 grid");
assert(socket_key_size[0] / 2 + socket_key_clearance
< socket_mount_x - insert_boss_diameter / 2,
"Socket key slot overlaps a full-depth mounting-insert boss");
assert(socket_gusset_outer_x
< socket_mount_x - socket_mount_tool_diameter / 2,
"Socket gusset enters a flange-screw tool-access envelope");
assert(socket_front_y == grid_pitch / 2,
"Socket reference face must remain at the half-inch grid coordinate");
assert(socket_rear_y == -datum_y,
"Socket flange must reach the rear edge of the base");
assert(socket_mount_x + socket_mount_tool_diameter / 2
<= socket_flange_size[0] / 2,
"Socket washer envelope exceeds the flange width");
assert(socket_mount_ys[0] - socket_mount_tool_diameter / 2
>= socket_rear_y
&& socket_mount_ys[len(socket_mount_ys) - 1]
+ socket_mount_tool_diameter / 2 <= socket_front_y,
"Socket washer envelope exceeds the flange depth");
assert(socket_rail_axis_y() + socket_key_size[1] / 2
+ socket_key_clearance
< grid_pitch - insert_boss_diameter / 2,
"Socket key slot overlaps the first grid-row insert bosses");
assert(socket_mount_ys[len(socket_mount_ys) - 1]
+ insert_boss_diameter / 2
< grid_pitch - insert_boss_diameter / 2,
"Socket mounting bosses overlap the first grid-row bosses");
assert(socket_key_size[2] < plate_height,
"Socket tongue must stop above the base underside");
grid_xs = [for (i = [0 : grid_columns - 1])
plate_width / 2 + (i - (grid_columns - 1) / 2) * grid_pitch];
grid_ys = [for (i = [1 : grid_rows]) datum_y + i * grid_pitch];
rear_foot_ys = corner_radius;
socket_hole_xs = [plate_width / 2 - socket_mount_x,
plate_width / 2 + socket_mount_x];
socket_hole_ys = [for (y = socket_mount_ys) datum_y + y];
all_insert_points = concat(
[for (x = grid_xs, y = grid_ys) [x, y]],
[for (x = [grid_xs[0], grid_xs[len(grid_xs) - 1]]) [x, rear_foot_ys]],
[for (x = socket_hole_xs, y = socket_hole_ys) [x, y]]
);
module rounded_rect_2d(size, radius) {
offset(r = radius)
square([size[0] - 2 * radius, size[1] - 2 * radius], center = true);
}
module rounded_prism(size, radius) {
linear_extrude(height = size[2])
rounded_rect_2d([size[0], size[1]], radius);
}
module plate_outline_2d(inset = 0) {
translate([plate_width / 2, plate_depth / 2])
offset(delta = -inset)
rounded_rect_2d([plate_width, plate_depth], corner_radius);
}
module insert_boss_at(x, y) {
translate([x, y, 0]) cylinder(h = plate_height, d = insert_boss_diameter);
}
module insert_positive_bosses() {
for (p = all_insert_points) insert_boss_at(p[0], p[1]);
}
module underside_structure_2d() {
intersection() {
plate_outline_2d();
union() {
difference() {
plate_outline_2d();
plate_outline_2d(perimeter_wall);
}
for (x = grid_xs)
translate([x - rib_width / 2, corner_radius, 0])
square([rib_width, plate_depth - 2 * corner_radius]);
for (y = concat([datum_y], grid_ys))
translate([corner_radius, y - rib_width / 2, 0])
square([plate_width - 2 * corner_radius, rib_width]);
translate([corner_radius, rear_foot_ys - rib_width / 2, 0])
square([plate_width - 2 * corner_radius, rib_width]);
for (x = socket_hole_xs)
translate([x - rib_width / 2, corner_radius, 0])
square([rib_width, datum_y - corner_radius]);
for (p = all_insert_points)
translate(p) circle(d = insert_boss_diameter);
}
}
}
module base_structure_positive() {
union() {
linear_extrude(height = plate_height - top_skin)
underside_structure_2d();
translate([0, 0, plate_height - top_skin])
linear_extrude(height = top_skin) plate_outline_2d();
}
}
module one_insert_cutout(x, y, pilot = insert_pilot_diameter) {
translate([x, y, -eps])
cylinder(h = insert_flange_depth + eps, d = insert_flange_diameter);
translate([x, y, insert_flange_depth - eps])
cylinder(h = insert_body_length + 2 * eps, d = pilot);
translate([x, y, insert_flange_depth + insert_body_length - eps])
cylinder(
h = plate_height - insert_flange_depth - insert_body_length + 2 * eps,
d = screw_clearance_diameter
);
}
module all_insert_cutouts() {
union() {
for (p = all_insert_points)
translate([p[0], p[1], -eps])
cylinder(h = insert_flange_depth + eps,
d = insert_flange_diameter);
}
union() {
for (p = all_insert_points)
translate([p[0], p[1], insert_flange_depth - eps])
cylinder(h = insert_body_length + 2 * eps,
d = insert_pilot_diameter);
}
union() {
for (p = all_insert_points)
translate([p[0], p[1],
insert_flange_depth + insert_body_length - eps])
cylinder(
h = plate_height - insert_flange_depth
- insert_body_length + 2 * eps,
d = screw_clearance_diameter
);
}
}
module insert_pattern_2d(diameter) {
for (p = all_insert_points) translate(p) circle(d = diameter);
}
module socket_key_2d(clearance = 0) {
translate([plate_width / 2, datum_y + socket_rail_axis_y()])
rounded_rect_2d(
[socket_key_size[0] + 2 * clearance,
socket_key_size[1] + 2 * clearance],
2 + clearance
);
}
module grid_artwork_2d(clearance = 0) {
intersection() {
plate_outline_2d();
offset(delta = clearance)
union() {
for (x = grid_xs)
translate([x - grid_line_width / 2, datum_y])
square([grid_line_width, plate_depth - datum_y]);
for (y = concat([datum_y], grid_ys))
translate([0, y - grid_line_width / 2])
square([plate_width, grid_line_width]);
}
}
}
module accent_service() {
translate([0, 0, plate_height - inlay_depth])
linear_extrude(height = inlay_depth)
difference() {
grid_artwork_2d();
insert_pattern_2d(screw_clearance_diameter);
socket_key_2d(socket_key_clearance);
}
}
module base_layer_2d(outline = false, hole_diameter, artwork = false) {
difference() {
if (outline) plate_outline_2d();
else underside_structure_2d();
insert_pattern_2d(hole_diameter);
// The locator is a through-slot so no unsupported bridge is printed
// against the textured build plate.
socket_key_2d(socket_key_clearance);
if (artwork) grid_artwork_2d(inlay_clearance);
}
}
module base_segment_service(index, with_artwork = false) {
underside_top = plate_height - top_skin;
body_top = insert_flange_depth + insert_body_length;
artwork_bottom = plate_height - inlay_depth;
top_split = (underside_top + artwork_bottom) / 2;
if (index == 0)
linear_extrude(height = insert_flange_depth)
base_layer_2d(false, insert_flange_diameter);
else if (index == 1)
translate([0, 0, insert_flange_depth])
linear_extrude(height = insert_body_length)
base_layer_2d(false, insert_pilot_diameter);
else if (index == 2)
translate([0, 0, body_top])
linear_extrude(height = underside_top - body_top)
base_layer_2d(false, screw_clearance_diameter);
else if (index == 3)
translate([0, 0, underside_top])
linear_extrude(height = top_split - underside_top)
base_layer_2d(true, screw_clearance_diameter);
else if (index == 4)
translate([0, 0, top_split])
linear_extrude(height = artwork_bottom - top_split)
base_layer_2d(true, screw_clearance_diameter);
else if (index == 5)
translate([0, 0, artwork_bottom])
linear_extrude(height = inlay_depth)
base_layer_2d(true, screw_clearance_diameter, with_artwork);
else assert(false, str("Unknown base segment: ", index));
}
module base_layered_service(with_artwork = false) {
for (i = [0 : 5]) base_segment_service(i, with_artwork);
}
module base_black_service() {
base_layered_service(true);
}
module base_complete_service() {
base_layered_service(false);
}
module print_orient_base() {
translate([0, plate_depth, plate_height])
rotate([180, 0, 0]) children();
}
module socket_flange_2d() {
translate([0, (socket_front_y + socket_rear_y) / 2])
rounded_rect_2d([socket_flange_size[0], socket_flange_size[1]], 3);
}
function socket_outer_y() = rail_size + 2 * rail_clearance
+ 2 * socket_end_wall;
function socket_rail_axis_y() = socket_front_y - socket_outer_y() / 2;
module socket_key_local_2d() {
translate([0, socket_rail_axis_y()])
rounded_rect_2d([socket_key_size[0], socket_key_size[1]], 2);
}
module socket_gusset(side = 1) {
cavity = rail_size + 2 * rail_clearance;
collar_half_x = (cavity + 2 * socket_side_wall) / 2;
near_x = side > 0 ? collar_half_x : -collar_half_x - 2;
outer_x = side > 0 ? socket_gusset_outer_x - 2
: -socket_gusset_outer_x;
gusset_depth = socket_gusset_y_span[1] - socket_gusset_y_span[0];
// Broad buttress with a short outer reach. Tool-access bores below remove
// any material that could obstruct either family of fasteners.
hull() {
translate([near_x, socket_gusset_y_span[0], socket_flange_size[2]])
cube([2, gusset_depth, socket_gusset_height]);
translate([outer_x, socket_gusset_y_span[0], socket_flange_size[2]])
cube([2, gusset_depth, 2]);
}
}
module socket_positive() {
cavity = rail_size + 2 * rail_clearance;
outer_x = cavity + 2 * socket_side_wall;
outer_y = socket_outer_y();
translate([0, 0, -socket_key_size[2]])
linear_extrude(height = socket_key_size[2] + eps)
socket_key_local_2d();
linear_extrude(height = socket_flange_size[2]) socket_flange_2d();
translate([-outer_x / 2, socket_front_y - outer_y,
socket_flange_size[2]])
cube([outer_x, outer_y, socket_capture]);
socket_gusset(-1);
socket_gusset(1);
}
module socket_cutouts() {
cavity = rail_size + 2 * rail_clearance;
collar_half_x = (cavity + 2 * socket_side_wall) / 2;
translate([-cavity / 2, socket_rail_axis_y() - cavity / 2,
socket_flange_size[2]])
cube([cavity, cavity, socket_capture + eps]);
// Four base mounting holes with clear vertical driver access.
for (x = [-socket_mount_x, socket_mount_x], y = socket_mount_ys) {
translate([x, y, -socket_key_size[2] - eps])
cylinder(h = socket_flange_size[2] + socket_key_size[2] + 2 * eps,
d = screw_clearance_diameter);
translate([x, y, socket_flange_size[2]])
cylinder(h = socket_gusset_height + eps,
d = socket_mount_tool_diameter);
}
// Two screw levels through both side walls create four M5 clamp points.
for (h = rail_screw_heights) {
translate([-socket_flange_size[0] / 2, socket_rail_axis_y(),
socket_flange_size[2] + h])
rotate([0, 90, 0])
cylinder(h = socket_flange_size[0], d = rail_screw_diameter);
translate([-collar_half_x - eps, socket_rail_axis_y(),
socket_flange_size[2] + h])
rotate([0, 90, 0])
cylinder(h = rail_screw_head_depth + eps,
d = rail_screw_head_diameter);
translate([collar_half_x - rail_screw_head_depth,
socket_rail_axis_y(),
socket_flange_size[2] + h])
rotate([0, 90, 0])
cylinder(h = rail_screw_head_depth + eps,
d = rail_screw_head_diameter);
// Driver tunnels exist only outside the collar, preserving the side
// wall while allowing a socket driver through each buttress.
translate([-socket_gusset_outer_x - eps, socket_rail_axis_y(),
socket_flange_size[2] + h])
rotate([0, 90, 0])
cylinder(h = socket_gusset_outer_x - collar_half_x + 2 * eps,
d = rail_tool_diameter);
translate([collar_half_x - eps, socket_rail_axis_y(),
socket_flange_size[2] + h])
rotate([0, 90, 0])
cylinder(h = socket_gusset_outer_x - collar_half_x + 2 * eps,
d = rail_tool_diameter);
}
}
module socket_service() {
difference() {
socket_positive();
socket_cutouts();
}
}
module socket_print() {
translate([socket_flange_size[0] / 2, -socket_rear_y,
socket_key_size[2]]) socket_service();
}
module reference_rail() {
color([0.08, 0.08, 0.09, 0.8])
translate([plate_width / 2 - rail_size / 2,
datum_y + socket_rail_axis_y() - rail_size / 2,
plate_height + socket_flange_size[2]])
cube([rail_size, rail_size, 400]);
}
module reference_feet() {
foot_points = concat(
[[grid_xs[0], rear_foot_ys],
[grid_xs[len(grid_xs)-1], rear_foot_ys]],
[[grid_xs[0], grid_ys[len(grid_ys)-1]],
[grid_xs[len(grid_xs)-1], grid_ys[len(grid_ys)-1]]]
);
for (p = foot_points) {
color([0.05, 0.05, 0.05, 0.8]) {
translate([p[0], p[1], -foot_nut_height])
cylinder(h = foot_nut_height, d = foot_nut_diameter);
translate([p[0], p[1], -foot_nut_height - foot_pad_height])
cylinder(h = foot_pad_height, d = foot_pad_diameter);
}
}
}
// Two-piece calibration coupon, modeled directly in print orientation.
coupon_size = [165, 110, 3.2];
coupon_boss_height = 15;
coupon_rail_xs = [20, 60, 100, 140];
coupon_rail_y = 23;
coupon_fit_center = [82.5, 90];
coupon_fit_outer = [64, 34];
coupon_fit_height = 6;
coupon_key_center = [200, 90];
coupon_key_cap = [56, 26, 2];
coupon_key_handle = [40, 6, 8];
assert(coupon_fit_height == socket_key_size[2],
"Coupon socket depth must match the production tongue");
assert(len(coupon_rail_gaps) == len(coupon_rail_xs),
"Each rail gauge must have one layout position");
assert(len([for (gap = coupon_rail_gaps)
if (gap == rail_size + 2 * rail_clearance) gap]) == 1,
"Production rail cavity must appear once in the coupon gauges");
assert(coupon_rail_xs[0]
- (coupon_rail_gaps[len(coupon_rail_gaps) - 1]
+ 2 * socket_side_wall) / 2 >= 0
&& coupon_rail_xs[len(coupon_rail_xs) - 1]
+ (coupon_rail_gaps[len(coupon_rail_gaps) - 1]
+ 2 * socket_side_wall) / 2 <= coupon_size[0],
"Rail gauges exceed the main coupon width");
assert(coupon_fit_center[0] - coupon_fit_outer[0] / 2 >= 0
&& coupon_fit_center[0] + coupon_fit_outer[0] / 2 <= coupon_size[0]
&& coupon_fit_center[1] - coupon_fit_outer[1] / 2 >= 0
&& coupon_fit_center[1] + coupon_fit_outer[1] / 2 <= coupon_size[1],
"Coupon fit socket exceeds the main coupon outline");
assert(coupon_key_center[0] - coupon_key_cap[0] / 2 > coupon_size[0],
"Coupon tongue must remain a physically separate print");
assert(coupon_key_center[0] + coupon_key_cap[0] / 2 <= 256
&& coupon_key_center[1] + coupon_key_cap[1] / 2 <= 256,
"Coupon and separate tongue exceed the X2D build area");
module coupon_outline_2d() {
translate([coupon_size[0] / 2, coupon_size[1] / 2])
rounded_rect_2d([coupon_size[0], coupon_size[1]], 4);
}
module coupon_accent_2d(clearance = 0) {
offset(delta = clearance)
translate([8, 6]) square([75, grid_line_width]);
}
module coupon_positive() {
linear_extrude(height = coupon_size[2]) coupon_outline_2d();
for (i = [0 : len(coupon_insert_diameters) - 1])
translate([14 + i * 24, 56, coupon_size[2]])
cylinder(h = coupon_boss_height - coupon_size[2], d = insert_boss_diameter);
// Four production-style closed collars, left-to-right 20.0-20.3 mm.
for (i = [0 : len(coupon_rail_gaps) - 1]) {
gap = coupon_rail_gaps[i];
outer_x = gap + 2 * socket_side_wall;
outer_y = gap + 2 * socket_end_wall;
translate([coupon_rail_xs[i], coupon_rail_y,
coupon_size[2] - eps])
difference() {
translate([-outer_x / 2, -outer_y / 2, 0])
cube([outer_x, outer_y, socket_capture + eps]);
translate([-gap / 2, -gap / 2, -eps])
cube([gap, gap, socket_capture + 3 * eps]);
}
}
// Full-depth test socket surrounding the production-size through-slot.
translate([coupon_fit_center[0], coupon_fit_center[1],
coupon_size[2] - eps])
rounded_prism(
[coupon_fit_outer[0], coupon_fit_outer[1],
coupon_fit_height - coupon_size[2] + eps],
3
);
}
module coupon_common_cutouts() {
for (i = [0 : len(coupon_insert_diameters) - 1]) {
x = 14 + i * 24;
pilot = coupon_insert_diameters[i];
translate([x, 56, coupon_boss_height - insert_flange_depth])
cylinder(h = insert_flange_depth + eps, d = insert_flange_diameter);
translate([x, 56, coupon_boss_height - insert_flange_depth - insert_body_length])
cylinder(h = insert_body_length + eps, d = pilot);
translate([x, 56, -eps])
cylinder(h = coupon_boss_height - insert_flange_depth
- insert_body_length + 2 * eps,
d = screw_clearance_diameter);
}
translate([coupon_fit_center[0], coupon_fit_center[1], -eps])
rounded_prism(
[socket_key_size[0] + 2 * socket_key_clearance,
socket_key_size[1] + 2 * socket_key_clearance,
coupon_fit_height + 2 * eps],
2 + socket_key_clearance
);
}
module coupon_key() {
// Separate full-size tongue. A 45-degree stop shoulder remains
// support-free, and the vertical rib provides a grip for removal.
translate([coupon_key_center[0], coupon_key_center[1], 0]) {
rounded_prism(socket_key_size, 2);
hull() {
translate([0, 0, socket_key_size[2] - eps])
rounded_prism(
[socket_key_size[0], socket_key_size[1], eps], 2);
translate([0, 0,
socket_key_size[2] + coupon_key_cap[2] - eps])
rounded_prism(
[coupon_key_cap[0], coupon_key_cap[1], eps], 3);
}
translate([0, 0, socket_key_size[2] + coupon_key_cap[2] - eps])
rounded_prism(
[coupon_key_handle[0], coupon_key_handle[1],
coupon_key_handle[2] + eps],
2
);
}
}
module coupon_black() {
difference() {
coupon_positive();
coupon_common_cutouts();
translate([0, 0, -eps])
linear_extrude(height = inlay_depth + 2 * eps)
coupon_accent_2d(inlay_clearance);
}
}
module coupon_accent() {
linear_extrude(height = inlay_depth) coupon_accent_2d();
}
module coupon_complete() {
union() {
difference() {
coupon_positive();
coupon_common_cutouts();
}
coupon_key();
}
}
module assembly() {
color([0.035, 0.035, 0.04]) base_black_service();
color([0.05, 0.25, 0.8]) accent_service();
color([0.035, 0.035, 0.04])
translate([plate_width / 2, datum_y, plate_height]) socket_service();
if (show_references) {
reference_rail();
reference_feet();
}
}
if (part == "assembly") assembly();
else if (part == "base") print_orient_base() base_complete_service();
else if (part == "base_black") print_orient_base() base_black_service();
else if (part == "base_segment")
print_orient_base() base_segment_service(segment_index, false);
else if (part == "base_black_segment")
print_orient_base() base_segment_service(segment_index, true);
else if (part == "base_accent") print_orient_base() accent_service();
else if (part == "socket") socket_print();
else if (part == "coupon") coupon_complete();
else if (part == "coupon_black") coupon_black();
else if (part == "coupon_key") coupon_key();
else if (part == "coupon_accent") coupon_accent();
else assert(false, str("Unknown part: ", part));
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 MiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1276896
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+54098
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+41246
View File
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""Combine two aligned OpenSCAD 3MF meshes into a Bambu-friendly multipart 3MF."""
from __future__ import annotations
import argparse
import copy
import zipfile
from pathlib import Path
from xml.etree import ElementTree as ET
CORE = "http://schemas.microsoft.com/3dmanufacturing/core/2015/02"
ET.register_namespace("", CORE)
CONTENT_TYPES_XML = b'''<?xml version="1.0" encoding="UTF-8"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="model" ContentType="application/vnd.ms-package.3dmanufacturing-3dmodel+xml"/>
</Types>
'''
RELATIONSHIPS_XML = b'''<?xml version="1.0" encoding="UTF-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Target="/3D/3dmodel.model" Id="rel0" Type="http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"/>
</Relationships>
'''
def load_mesh(path: Path) -> ET.Element:
with zipfile.ZipFile(path) as archive:
root = ET.fromstring(archive.read("3D/3dmodel.model"))
mesh = root.find(f".//{{{CORE}}}mesh")
if mesh is None:
raise ValueError(f"{path} does not contain a 3MF mesh")
return copy.deepcopy(mesh)
def model_xml(bases: list[Path], accents: list[Path], title: str) -> bytes:
root = ET.Element(f"{{{CORE}}}model", {
"unit": "millimeter",
"{http://www.w3.org/XML/1998/namespace}lang": "en-US",
})
ET.SubElement(root, f"{{{CORE}}}metadata", {"name": "Title"}).text = title
ET.SubElement(root, f"{{{CORE}}}metadata", {"name": "Generator"}).text = "copy-stand"
resources = ET.SubElement(root, f"{{{CORE}}}resources")
materials = ET.SubElement(resources, f"{{{CORE}}}basematerials", {"id": "1"})
ET.SubElement(materials, f"{{{CORE}}}base", {"name": "Black ASA", "displaycolor": "#101014"})
ET.SubElement(materials, f"{{{CORE}}}base", {"name": "Blue ASA inlay", "displaycolor": "#0D47CC"})
parts: list[tuple[int, str, Path, int]] = []
next_id = 2
for index, source in enumerate(bases):
parts.append((next_id, f"Black ASA segment {index + 1} - Filament 1", source, 0))
next_id += 1
for index, source in enumerate(accents):
parts.append((next_id, f"Blue ASA inlay {index + 1} - Filament 2", source, 1))
next_id += 1
for object_id, name, source, material_index in parts:
obj = ET.SubElement(resources, f"{{{CORE}}}object", {
"id": str(object_id), "name": name, "type": "model",
"pid": "1", "pindex": str(material_index),
})
obj.append(load_mesh(source))
assembly_id = next_id
assembly = ET.SubElement(resources, f"{{{CORE}}}object", {
"id": str(assembly_id), "name": title, "type": "model",
})
components = ET.SubElement(assembly, f"{{{CORE}}}components")
for object_id, _name, _source, _material_index in parts:
ET.SubElement(components, f"{{{CORE}}}component", {"objectid": str(object_id)})
build = ET.SubElement(root, f"{{{CORE}}}build")
ET.SubElement(build, f"{{{CORE}}}item", {"objectid": str(assembly_id)})
return ET.tostring(root, encoding="utf-8", xml_declaration=True), assembly_id, parts
def model_settings_xml(title: str, assembly_id: int,
parts: list[tuple[int, str, Path, int]]) -> bytes:
root = ET.Element("config")
obj = ET.SubElement(root, "object", {"id": str(assembly_id)})
ET.SubElement(obj, "metadata", {"key": "name", "value": title})
for part_id, name, _source, material_index in parts:
extruder = material_index + 1
part = ET.SubElement(obj, "part", {"id": str(part_id), "subtype": "normal_part"})
ET.SubElement(part, "metadata", {"key": "name", "value": name})
ET.SubElement(part, "metadata", {"key": "extruder", "value": str(extruder)})
ET.SubElement(root, "assemble")
return ET.tostring(root, encoding="utf-8", xml_declaration=True)
def write_entry(archive: zipfile.ZipFile, name: str, data: bytes) -> None:
info = zipfile.ZipInfo(name)
info.create_system = 3
info.external_attr = 0o100644 << 16
info.compress_type = zipfile.ZIP_DEFLATED
archive.writestr(info, data)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("output", type=Path)
parser.add_argument("--base", action="append", type=Path, required=True)
parser.add_argument("--accent", action="append", type=Path, default=[])
parser.add_argument("--title", required=True)
args = parser.parse_args()
args.output.parent.mkdir(parents=True, exist_ok=True)
xml, assembly_id, parts = model_xml(args.base, args.accent, args.title)
with zipfile.ZipFile(args.output, "w") as archive:
write_entry(archive, "[Content_Types].xml", CONTENT_TYPES_XML)
write_entry(archive, "_rels/.rels", RELATIONSHIPS_XML)
write_entry(archive, "3D/3dmodel.model", xml)
write_entry(archive, "Metadata/model_settings.config",
model_settings_xml(args.title, assembly_id, parts))
if __name__ == "__main__":
main()