LLM CAD: International Space Station in progress
I have been exploring using on-device LLMs to model 3D objects with reference material. To my surprise, the ISS has been rather conducive to the project.
The reference material used here was 39 PDFs from NASA, ESA, JAXA and vendors. At times the AI-powered tool did have to take guesses on dimensions. Those guesses are flagged, and future work will investigate how to validate them and blend the photogrammetry and tech specs more seamlessly with confidence intervals.
Most of my time has gone into the LLM CAD side: getting a model to read reference documents, keep track of where each number came from, and say so when it’s guessing. With that in place, the ISS itself came together in one evening.
The model below is the facets OpenSCAD produced from the code the LLM tooling wrote. Drag it to turn it, edit the LLM-produced source, and press render to rebuild the station in your browser. The model viewer is a WebAssembly build of my OpenSCAD fork. I still plan on doing a write-up on that project, so check back soon.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155
// ============================================================================
// station.scad — the whole station
// Eric Moderbacher
// 2026-10-21
// Every module at its sourced size, berthed where it berths on the real station:
// Unity, Destiny, Harmony, Tranquility, Cupola, Leonardo, BEAM, Bishop, Quest, Columbus, Kibo,
// the PMAs, Zarya, Rassvet, Zvezda, Poisk, Nauka (with Prichal and ERA), the truss, arrays and
// Canadarm2. Offsets no source gives are ASSUMED and echoed.
// Pirs is left out because it was deorbited in 2021. Frame: +X forward,
// +Y port, +Z zenith, origin at Destiny's center.
// ============================================================================
use <config.scad>
include <detail.scad>
use <common/pressurizedModule.scad>
use <truss/trussSegment.scad>
use <truss/integratedTruss.scad>
use <solarArrays/rigidPanelWing.scad>
use <cupola/cupola.scad>
use <tranquility/tranquility.scad>
use <unity/unity.scad>
use <destiny/destiny.scad>
use <harmony/harmony.scad>
use <columbus/columbus.scad>
use <kibo/kibo.scad>
use <pma/pressurizedMatingAdapter.scad>
use <pma/internationalDockingAdapter.scad>
use <leonardo/leonardo.scad>
use <beam/beam.scad>
use <bishop/bishop.scad>
use <quest/quest.scad>
use <zvezda/zvezda.scad>
use <poisk/poisk.scad>
use <zarya/zarya.scad>
use <rassvet/rassvet.scad>
use <nauka/nauka.scad>
use <common/placement.scad>
use <common/moduleShell.scad>
// Solar array alpha angle (SARJ, about the truss Y axis), degrees. 0 = blankets face zenith.
solarArrayAngle = 0;
// Solar array beta angle (each wing about its own mast), degrees.
solarBetaAngle = 0;
// Heat rejection radiator angle (TRRJ), degrees down from horizontal-aft.
radiatorThermalAngle = 0;
// Where the Mobile Transporter sits along the truss, mm from the S0 midpoint (+ port).
transporterPositionY = 0;
// Canadarm2 joints from the base LEE on the Mobile Base System: [roll, yaw, pitch, elbow, pitch, yaw, roll].
// An empty list leaves the arm off.
canadarmJointAngles = [0, 0, -50, 100, 40, 0, 0];
// Also draw the iROSAs planned for fall 2026 (2A, 3B), not confirmed installed.
showPlannedRollOuts = false;
// Four Module-to-Truss Structure struts from Destiny's inner rings up to the S0 truss's
// lower edges. The count is sourced; where they land is ASSUMED.
module _mtsStruts(spec, destinyAftX, trussCenterZ) {
outer = getModuleValue(spec, "outerRadius");
ringZs = getModuleValue(spec, "ringZs");
spread = getLayout("mtsStrutSpreadDegrees");
trussHalfWidth = getTrussWidth("S0") / 2;
trussHalfHeight = getTrussHeight("S0") / 2;
color("Silver")
for (ring = [1, 2], side = [-1, 1])
let(x = destinyAftX + ringZs[ring],
start = [x, side * outer * sin(spread), outer * cos(spread)],
end = [x + (ring == 1 ? -1 : 1) * trussHalfWidth * 0.6, side * trussHalfWidth * 0.8, trussCenterZ - trussHalfHeight])
rodBetween(start, end, getDestiny("mtsStrutDiameter"));
}
module station() {
for (row = getAssumedRows(getStationLayout()))
echo(str("ASSUMED: ", row[0], " = ", row[1], " (", row[3], ")"));
gap = getLayout("berthingGap");
destinyHalf = getDestiny("overallLength") / 2;
destinySpec = getDestinySpec();
trussCenterZ = getModuleValue(destinySpec, "outerRadius") + getDestiny("cradleHeight")
+ getTrussHeight("S0") / 2;
// US spine, forward to aft
harmonyStart = destinyHalf;
harmonySpec = getHarmonySpec();
harmonyPortX = harmonyStart + getModuleValue(harmonySpec, "portZ");
harmonyPortFace = getModuleValue(harmonySpec, "portFaceRadius");
unityForwardFace = -destinyHalf;
unityAftFace = unityForwardFace - getUnity("overallLength");
pma1Start = unityAftFace;
// PMA-1's narrow end steps off Unity's axis; Zarya and everything aft of it step with it.
pma1Angle = getPma("pma1OffsetAngle");
russianOffset = [0, getPma("endOffset") * sin(pma1Angle), getPma("endOffset") * cos(pma1Angle)];
zaryaStart = pma1Start - getPma("length");
// Destiny's element +Z runs station forward, its +X is nadir, its +Y port.
translate([-destinyHalf, 0, 0]) rotate([0, 90, 0]) destiny();
_mtsStruts(destinySpec, -destinyHalf, trussCenterZ);
// Harmony's element +Z runs station forward, its +X is nadir, its +Y port.
translate([harmonyStart, 0, 0]) rotate([0, 90, 0]) harmony();
// PMA-2 with IDA-2 forward of Harmony, PMA-3 with IDA-3 on Harmony's zenith port.
translate([harmonyStart + getModuleValue(harmonySpec, "overallLength"), 0, 0]) rotate([0, 90, 0]) {
pressurizedMatingAdapter(getPma("pma2OffsetAngle"));
placeOnPmaNarrowEnd(getPma("pma2OffsetAngle")) internationalDockingAdapter();
}
translate([harmonyStart, 0, 0]) rotate([0, 90, 0])
placeOnNodeRadialPortOutward(harmonySpec, getHarmonyPortAngle("zenith")) {
pressurizedMatingAdapter(getPma("pma3OffsetAngle"));
placeOnPmaNarrowEnd(getPma("pma3OffsetAngle")) internationalDockingAdapter();
}
// PMA-1 from Unity's aft port to Zarya.
translate([pma1Start, 0, 0]) rotate([0, -90, 0]) pressurizedMatingAdapter(pma1Angle);
// The Russian segment hangs off PMA-1's narrow end and is carried by its offset: Zarya, with
// Zvezda on its aft port (Poisk on Zvezda's zenith) and Rassvet on its nadir port.
translate(russianOffset) translate([zaryaStart, 0, 0]) rotate([0, -90, 0]) {
zarya();
placeOnZaryaPort("aft") {
zvezda();
placeOnZvezdaPort("zenith") poisk();
placeOnZvezdaPort("nadir") nauka();
}
placeOnZaryaPort("nadir") rassvet();
}
// Off Harmony's radial ports: Columbus starboard, Kibo port (nadir hosts visiting cargo)
translate([harmonyStart, 0, 0]) rotate([0, 90, 0])
placeOnNodeRadialPortOutward(harmonySpec, getHarmonyPortAngle("starboard")) columbus();
translate([harmonyStart, 0, 0]) rotate([0, 90, 0])
placeOnNodeRadialPortOutward(harmonySpec, getHarmonyPortAngle("port")) kibo();
// Unity, with Tranquility (and its Cupola and Leonardo) port, Quest starboard, Z1 zenith.
// Unity's element +Z runs station forward, its +X is nadir, its +Y port.
unitySpec = getUnitySpec();
translate([unityAftFace, 0, 0]) rotate([0, 90, 0]) {
unity();
placeOnNodeRadialPortOutward(unitySpec, getUnityPortAngle("port")) rotate([0, 0, 180]) {
tranquility();
translate([0, 0, getTranquility("overallLength")]) bishop();
placeOnNodeRadialPortOutward(getTranquilitySpec(), getTranquilityPortAngle("nadir")) cupola();
placeOnNodeRadialPortOutward(getTranquilitySpec(), getTranquilityPortAngle("forward")) leonardo();
placeOnNodeRadialPortOutward(getTranquilitySpec(), getTranquilityPortAngle("aft"))
rotate([0, 0, 90]) beam();
}
placeOnNodeRadialPortOutward(unitySpec, getUnityPortAngle("starboard")) quest();
placeOnNodeRadialPortOutward(unitySpec, getUnityPortAngle("zenith"))
translate([0, -getTrussLength("Z1") / 2, getTrussHeight("Z1") / 2])
trussSegment(getTrussLength("Z1"), getTrussWidth("Z1"), getTrussHeight("Z1"));
}
// Integrated truss on Destiny's cradle
translate([0, 0, trussCenterZ])
integratedTruss(solarArrayAngle, solarBetaAngle, radiatorThermalAngle, transporterPositionY,
showPlannedRollOuts, canadarmJointAngles);
}
station();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202
// ============================================================================
// config.scad — ISS reference dimensions
// Eric Moderbacher
// 2026-10-21
// Every design constant for the model. Values are full-size millimeters,
// transcribed from documentation/referenceDimensions.md, which carries the
// source and page for each row. That table is the record; this file follows it.
// Consumers `use <config.scad>` and read through the getters below, since
// `use` does not import top-level variables.
// ============================================================================
// Rows: [name, lengthMm, maxDiameterMm, source]
function getPressurizedModules() = [
["Zarya", 12990, 4100, "G10 p59"],
["Unity", 5500, 4300, "G10 p53"],
["Zvezda", 13100, 4200, "G10 p63"],
["Destiny", 8500, 4300, "G10 p49"],
["Quest", 5500, 4000, "G10 p56"],
["Pirs", 4900, 2550, "G10 p60"],
["Poisk", 4900, 2550, "G10 p61"],
["Harmony", 6700, 4300, "G10 p54"],
["Tranquility", 6700, 4300, "G10 p55"],
["Columbus", 6900, 4500, "G10 p50"],
["KiboPm", 11200, 4400, "G10 p51"],
// G10 p51 gives 3.9 m; G15 p29 and the JAXA Kibo Handbook give 4.2 m (berthing ring included).
["KiboElm", 4200, 4400, "G15 p29; JAXA Kibo Handbook p21"],
["Rassvet", 6000, 2350, "G10 p62"],
["Leonardo", 6670, 4500, "G10 p58"],
["Cupola", 1500, 3000, "G10 p57"],
];
// Rows: [name, lengthMm (along the truss, station Y), widthMm (fore-aft, X), heightMm (Z), source].
// The truss segments' quoted lengths sum to about 102.6 m; G15 p78 says 95 m P6 to S6 and the
// G10/Wikipedia infobox says 94 m. Unresolved; station.scad echoes the gap on every render.
function getTrussSegments() = [
["Z1", 4600, 4200, 4200, "Wikipedia ITS (cross-section is its 'diameter'; ASSUMED square)"],
["S0", 13400, 4570, 3050, "Wikipedia ITS 13.4 m (NASA captions say 43 ft); cross-section ASSUMED"],
["S1", 13716, 4572, 1829, "STS-112 press kit p39: 45 ft long, 15 ft, 6 ft (NASA pages say 10 ft tall)"],
["P1", 13716, 4572, 1829, "mirror of S1 (STS-112 press kit p41)"],
["S3S4", 13655, 4963, 4630, "Wikipedia STS-117: 44 ft 9.6 in x 16 ft 3.4 in x 15 ft 2.3 in"],
["P3P4", 13807, 4877, 4755, "CBS P3/P4 fact sheet: 45.3 x 16 x 15.6 ft"],
["P5", 3353, 4572, 4267, "STS-116 report: 11 x 15 x 14 ft"],
["S5", 3353, 4572, 4267, "mirror of P5, ASSUMED"],
["S6", 13838, 4968, 4481, "Spaceflight Now STS-119: 45.4 x 16.3 x 14.7 ft, arrays stowed"],
["P6", 13838, 4968, 4481, "mirror of S6, ASSUMED; Wikipedia ITS gives 18.3 m from its pre-move layout"],
];
// Every value above that no single source settles. Echoed by any top-level file that renders.
function getAssumedValues() = [
"KiboElm length 4200 mm: G10 says 3900; G15 and the JAXA handbook say 4200",
"Truss segments sum to ~102.6 m; G15 says 95 m P6 to S6",
];
function getPmaLength() = 1860; // G10 p64
function getPmaWideDiameter() = 1900; // G10 p64
function getPmaNarrowDiameter() = 1370; // G10 p64
function getCanadarmLength() = 17600; // G10 p91
function getCanadarmMaxDiameter() = 360; // G10 p91
function getStationLength() = 74000; // G10 p101
function getStationWidth() = 110000; // G10 p101
function getTrussSpanP6ToS6() = 95000; // G15 p78
// Rows: [name, valueMm, status, source]. status is VERIFIED (a source gives it) or
// ASSUMED (a guess from photos, chosen to fit the verified envelope). Every ASSUMED
// row is echoed by cupola.scad. Stack from the berthing face up:
// passiveCbmDepth + skirtHeight + shoulderHeight + domeHeight + topBossHeight
// + windowFrameHeight + shutterGap + shutterThickness = overallHeight.
function getCupolaDimensions() = [
["overallHeight", 1500, "VERIFIED", "ESA Cupola spec"],
["maxDiameterShuttersClosed", 2955, "VERIFIED", "ESA Cupola spec"],
["baseWidth", 2000, "VERIFIED", "Thales Alenia brochure; NASA JSC education PDF"],
["topWindowDiameter", 800, "VERIFIED", "ESA; a NASA JSC education PDF says 706"],
["sideWindowCount", 6, "VERIFIED", "ESA Cupola spec"],
["grappleFixtureCount", 2, "VERIFIED", "ESA: two FRGF interface plates"],
["paneCount", 4, "VERIFIED", "Wikipedia: debris, 2 pressure, scratch"],
["sidePressurePaneThickness", 25.4, "VERIFIED", "Springer s40940-025-00309-z"],
["topPressurePaneThickness", 36.8, "VERIFIED", "Springer s40940-025-00309-z"],
["outerInnerPaneThickness", 10, "ASSUMED", "debris and scratch panes, no source"],
["paneGap", 15, "ASSUMED", "no source"],
["skirtHeight", 300, "ASSUMED", "closes the stack to overallHeight"],
["skirtDiameter", 2032, "ASSUMED", "matches the berthing ring OD; sources say ~2 m base"],
["skirtWallThickness", 40, "ASSUMED", "no source"],
["bumperStandoff", 90, "ASSUMED", "ESA confirms a bumper on the cylinder; gap unsourced"],
["bumperPanelCount", 12, "ASSUMED", "no source"],
["bumperThickness", 4, "ASSUMED", "no source"],
["shoulderHeight", 80, "ASSUMED", "flare from skirt to the wider dome, photo proportion"],
["domeHeight", 625, "ASSUMED", "photo proportion"],
["domeBottomAcrossFlats", 2300, "ASSUMED", "keeps closed shutters inside maxDiameter"],
["domeTopAcrossFlats", 1150, "ASSUMED", "room for the top window boss"],
["domeWallThickness", 60, "ASSUMED", "no source"],
["edgeRibDiameter", 110, "ASSUMED", "forged ribs between windows, photo proportion"],
["topBossHeight", 60, "ASSUMED", "photo proportion"],
["topBossDiameter", 1000, "ASSUMED", "top window plus frame"],
["sideWindowBottomWidth", 880, "ASSUMED", "photo proportion"],
["sideWindowTopWidth", 440, "ASSUMED", "photo proportion"],
["sideWindowSlantHeight", 560, "ASSUMED", "photo proportion"],
["windowFrameWidth", 70, "ASSUMED", "no source"],
["windowFrameHeight", 35, "ASSUMED", "no source"],
["windowBoltSpacing", 90, "ASSUMED", "no source"],
["windowBoltDiameter", 14, "ASSUMED", "no source"],
["shutterThickness", 45, "ASSUMED", "no source"],
["shutterMargin", 90, "ASSUMED", "covers the window frame"],
["shutterGap", 12, "ASSUMED", "no source"],
["shutterRimWidth", 30, "ASSUMED", "no source"],
["shutterRibWidth", 28, "ASSUMED", "outer stiffeners, photo impression"],
["shutterRibHeight", 18, "ASSUMED", "no source"],
["hingeKnuckleCount", 3, "ASSUMED", "no source"],
["hingeKnuckleDiameter", 40, "ASSUMED", "no source"],
["hingeKnuckleLength", 110, "ASSUMED", "no source"],
["shutterLinkDiameter", 24, "ASSUMED", "visual link, not a kinematic model"],
["closeoutPanelCount", 8, "ASSUMED", "Thales confirms removable close-out panels"],
["closeoutPanelInset", 60, "ASSUMED", "no source"],
["closeoutPanelThickness", 15, "ASSUMED", "no source"],
["interiorHandrailDiameter", 32, "ASSUMED", "Thales confirms upper and lower rails"],
["interiorHandrailInset", 140, "ASSUMED", "no source"],
["workstationDisplayWidth", 360, "ASSUMED", "laptop-class display"],
["workstationDisplayHeight", 240, "ASSUMED", "laptop-class display"],
];
// Rows: [name, valueMm, status, source]. Parts shared by many modules.
function getCommonDimensions() = [
["handrailWidth", 25, "ASSUMED", "no source"],
["handrailDepth", 35, "ASSUMED", "no source"],
["handrailStandoff", 110, "ASSUMED", "no source"],
["handrailLength", 480, "ASSUMED", "no source"],
["grappleBaseSize", 330, "ASSUMED", "no source"],
["grappleBaseThickness", 25, "ASSUMED", "no source"],
["grappleShaftDiameter", 65, "ASSUMED", "no source"],
["grappleShaftHeight", 260, "ASSUMED", "no source"],
["grappleCamArmLength", 150, "ASSUMED", "no source"],
["grappleTargetDiameter", 250, "ASSUMED", "no source"],
["grappleTargetStandoff", 150, "ASSUMED", "no source"],
["hatchClearWidth", 1270, "VERIFIED", "G10: Node 1 hatches 50 in wide"],
["hatchFrameWidth", 40, "ASSUMED", "a collector blog's 53 in may be the outer frame"],
["hatchCornerRadius", 180, "ASSUMED", "no source"],
["hatchThickness", 60, "ASSUMED", "no source"],
["hatchRibCount", 16, "ASSUMED", "radial ribs seen in the NASA Node 3 render"],
["hatchWindowDiameter", 230, "ASSUMED", "no source"],
["rackHeight", 2014, "VERIFIED", "ISPR 79.3 in, Henze IAC-12-E5.3.7 (UH SICSA)"],
["rackWidth", 1049, "VERIFIED", "ISPR 41.3 in, Henze IAC-12-E5.3.7"],
["rackDepth", 859, "VERIFIED", "ISPR 33.8 in, Henze IAC-12-E5.3.7"],
["rackFrontDepth", 500, "ASSUMED", "straight front section before the back tapers"],
["rackBackHeightFraction", 0.64, "ASSUMED", "the tapered back is this fraction of the rack height"],
["trunnionDiameter", 83, "ASSUMED", "orbiter payload trunnion, no source"],
["trunnionLength", 180, "ASSUMED", "no source"],
["trunnionFittingSize", 260, "ASSUMED", "no source"],
["pdgfBaseSize", 600, "ASSUMED", "no source"],
["pdgfBaseHeight", 250, "ASSUMED", "no source"],
];
function getCommon(name) = _findRow(getCommonDimensions(), name)[1];
// Rows: [name, valueMm, status, source]. NTRS = NASA NTRS 19920015843 (Illi, Boeing, 1992,
// Space Station Freedom era: the flown design may differ in detail). EHD = ehdavis.engineering
// CBM core design description (flown hardware part numbers).
function getBerthingDimensions() = [
["ringOuterDiameter", 2032, "VERIFIED", "NTRS p287: approx 80 in"],
["ringInnerDiameter", 1803, "VERIFIED", "NTRS p287: approx 71 in"],
["passiveCbmDepth", 343, "VERIFIED", "NTRS p287: 13.5 in"],
["activeCbmDepth", 191, "VERIFIED", "NTRS p283: 7.5 in"],
["boltCount", 16, "VERIFIED", "NTRS p288, EHD"],
["alignmentGuideCount", 8, "VERIFIED", "NTRS p288, EHD: four of each hand"],
["alignmentGuideHeight", 127, "VERIFIED", "EHD: about 5 in above the flange"],
["alignmentGuideArcDegrees", 14, "VERIFIED", "EHD: just under 14 degrees"],
["alignmentGuidePairSpanDegrees", 50, "VERIFIED", "EHD: pair spans 50 degrees"],
["captureFittingCount", 4, "VERIFIED", "NTRS p287, EHD"],
["alignmentSocketCount", 4, "VERIFIED", "EHD: at 0, 90, 180, 270 degrees"],
["ringAttachBoltCount", 192, "VERIFIED", "EHD (flown); NTRS p287 says 64 (1992 design)"],
["sealBeadCount", 3, "VERIFIED", "NTRS p288"],
["sealSegmentCount", 8, "VERIFIED", "NTRS fig 14: 4 male, 4 female"],
["ringWallThickness", 25, "ASSUMED", "no source"],
["ringFlangeThickness", 30, "ASSUMED", "no source"],
["nutHousingDiameter", 60, "ASSUMED", "no source"],
["nutHousingHeight", 80, "ASSUMED", "no source"],
["alignmentGuideRadialDepth", 110, "ASSUMED", "no source"],
["captureFittingSize", 90, "ASSUMED", "no source"],
["sealWidth", 45, "ASSUMED", "no source"],
["sealThickness", 6, "ASSUMED", "no source"],
["activeGuideCount", 4, "VERIFIED", "NTRS fig 3, EHD: on the 0/90/180/270 axes"],
["alignmentPinDiameter", 19, "VERIFIED", "EHD: about 3/4 in thick"],
["alignmentPinProtrusion", 19, "VERIFIED", "EHD: almost 3/4 in above the ring face"],
["captureLatchCount", 4, "VERIFIED", "NTRS p284, EHD"],
["shearTieCount", 2, "VERIFIED", "NTRS fig 3 and 13: 180 degrees apart (1992 design)"],
["controllerPanelCount", 4, "VERIFIED", "EHD: one per quadrant"],
["coverPetalCount", 4, "ASSUMED", "EHD names deployable M/D covers; count from the NASA Node 3 render"],
["coverPetalLength", 750, "ASSUMED", "no source"],
["captureLatchArmLength", 260, "ASSUMED", "NTRS fig 8 shape, no dimension"],
["poweredBoltHousingDiameter", 70, "ASSUMED", "no source"],
];
// Rows: [name, valueMm, status, source]. N3D = NASA "Node 3 Description" presentation
// (capcomespace.net copy). Element frame: +Z along the axis from the passive berthing face
// (inboard, toward Unity) to the outboard active face; +Y is nadir once installed.
function getTranquilityDimensions() = [
["overallLength", 6706, "VERIFIED", "ESA Node-3 spec; N3D p5 says 278 in (7061)"],
["outerDiameter", 4480, "VERIFIED", "ESA Node-3 spec; N3D p5 says 174 in (4420)"],
["pressureShellDiameter", 4300, "ASSUMED", "G10's 4.3 m read as the bare shell, ESA's 4.48 m as over the debris panels"],
["radialPortCount", 4, "VERIFIED", "N3D p2"],
["activeCbmCount", 5, "VERIFIED", "N3D p2, p7: four radial plus one axial"],
["passiveCbmCount", 1, "VERIFIED", "N3D p2, p7"],
["grappleFixtureCount", 1, "VERIFIED", "N3D p7: one FRGF"],
["debrisPanelCount", 75, "VERIFIED", "ESA: around 75 panels"],
["rackCount", 8, "VERIFIED", "N3D p6: six system racks plus two avionics racks"],
["heatExchangerCount", 2, "VERIFIED", "N3D p3: two, on the external side of the passive-end cone"],
["endConeLength", 600, "ASSUMED", "N3D p7 drawing proportion"],
["endConeSmallDiameter", 2150, "ASSUMED", "just over the berthing ring"],
["portBayLength", 2500, "ASSUMED", "N3D p7 drawing proportion; at the active end per ESA What is Node 2"],
["shellWallThickness", 40, "ASSUMED", "no source"],
["reinforcementRingWidth", 120, "ASSUMED", "N3D p8 photo"],
["reinforcementRingHeight", 60, "ASSUMED", "N3D p8 photo"],
["radialPortBossDiameter", 2250, "ASSUMED", "no source"],
["radialPortFaceProud", 30, "ASSUMED", "berthing face just outside the debris panels"],
["panelRowsPerBay", 2, "ASSUMED", "chosen so the panel total lands near 75"],
["panelsAround", 12, "ASSUMED", "chosen so the panel total lands near 75"],
["panelThickness", 4, "ASSUMED", "no source"],
["panelGap", 12, "ASSUMED", "no source"],
["heatExchangerLength", 900, "ASSUMED", "no source"],
["heatExchangerWidth", 500, "ASSUMED", "no source"],
["heatExchangerHeight", 220, "ASSUMED", "no source"],
["rackRadialClearance", 120, "ASSUMED", "keeps the tapered rack back inside the shell (tested)"],
];
function getTranquility(name) = _findRow(getTranquilityDimensions(), name)[1];
// Rows: [name, valueMm, status, source]. STS88 = NASA STS-88 press kit (Nov 1998), local
// references/nasa/sts088PressKit.pdf. Element frame: +Z along the axis from the aft berthing
// face (z = 0, toward PMA-1) to the forward face (toward Destiny). Once installed, element +X
// is nadir, +Y port, +Z station forward.
function getUnityDimensions() = [
["overallLength", 5486, "VERIFIED", "STS88 PDF p48: 18 ft; Wikipedia 5.47 m; G10 5.5 m"],
["outerDiameter", 4572, "VERIFIED", "STS88 PDF p48: 15 ft; Wikipedia 4.57 m; G10 4.3 m"],
["pressureShellDiameter", 4300, "ASSUMED", "G10's 4.3 m read as the bare shell, as for Tranquility"],
["radialPortCount", 4, "VERIFIED", "Wikipedia, STS88"],
["rackCount", 4, "VERIFIED", "G10 p53, STS88 PDF p49"],
["endConeCount", 2, "VERIFIED", "G10 p53: aluminum cylindrical sections, 2 endcones"],
["handrailsInstalledSts88", 6, "VERIFIED", "STS88 PDF p38: six handrails on the second EVA"],
["activeCbmCount", 6, "ASSUMED", "each neighbour (Destiny, PMA-1, Tranquility, Quest, Z1, Cygnus) berths with a passive half"],
["grappleFixtureCount", 1, "ASSUMED", "the shuttle arm lifted Unity on STS-88, so it carried one"],
["endConeLength", 600, "ASSUMED", "same as Tranquility"],
["endConeSmallDiameter", 2150, "ASSUMED", "same as Tranquility"],
["portBayLength", 2500, "ASSUMED", "same as Tranquility: G10 p52 says Node 1 is Node 3 less one rack bay"],
["shellWallThickness", 40, "ASSUMED", "no source"],
["reinforcementRingWidth", 120, "ASSUMED", "no source"],
["reinforcementRingHeight", 60, "ASSUMED", "no source"],
["radialPortBossDiameter", 2250, "ASSUMED", "same as Tranquility"],
["radialPortFaceProud", 30, "ASSUMED", "same as Tranquility"],
["panelRowsPerBay", 2, "ASSUMED", "G10 p53 photo"],
["panelsAround", 12, "ASSUMED", "G10 p53 photo"],
["panelThickness", 4, "ASSUMED", "no source"],
["panelGap", 12, "ASSUMED", "no source"],
["panelRaisedInset", 110, "ASSUMED", "the raised inner plates on Unity's panels, G10 p53 photo"],
["rackRadialClearance", 120, "ASSUMED", "keeps the tapered rack back inside the shell (tested)"],
];
function getUnity(name) = _findRow(getUnityDimensions(), name)[1];
// Rows: [name, valueMm, status, source]. Element frame: +Z from the aft passive berthing face
// (z = 0, toward Unity) to the forward active face (toward Harmony). Once installed, element
// +X is nadir, +Y port, +Z station forward.
function getDestinyDimensions() = [
["overallLength", 9200, "VERIFIED", "G10 p49: 9.2 m with attached CBMs"],
["bodyLength", 8500, "VERIFIED", "G10 p49: 8.5 m; Wikipedia 8.53 m"],
["pressureShellDiameter", 4267, "VERIFIED", "Wikipedia specifications 4.27 m; G10 4.3 m"],
["outerDiameter", 4420, "ASSUMED", "debris panels standing off like the nodes'"],
["cylinderSectionCount", 3, "VERIFIED", "G10 p49, Wikipedia: 3 cylindrical sections, 2 endcones"],
["rackCount", 24, "VERIFIED", "G10 p49: 24 rack locations, 13 scientific and 11 system"],
["racksPerWall", 6, "VERIFIED", "Wikipedia: six on each side"],
["cornerStandoffCount", 4, "VERIFIED", "G10 p49 diagram: corner standoffs for utilities and plumbing"],
["windowDiameter", 509, "VERIFIED", "G10 p49: 50.9 cm; Wikipedia 20 in"],
["endConeLength", 650, "ASSUMED", "G10 p49 diagram proportion; absorbs the 9.2 m less rings vs 8.5 m gap"],
["endConeSmallDiameter", 2150, "ASSUMED", "same as the nodes"],
["shellWallThickness", 40, "ASSUMED", "no source"],
["reinforcementRingWidth", 120, "ASSUMED", "no source"],
["reinforcementRingHeight", 60, "ASSUMED", "no source"],
["panelRowsPerBay", 2, "ASSUMED", "no source"],
["panelsAround", 12, "ASSUMED", "no source"],
["panelThickness", 4, "ASSUMED", "no source"],
["panelGap", 12, "ASSUMED", "no source"],
["rackRadialClearance", 120, "ASSUMED", "keeps the tapered rack back inside the shell (tested)"],
["rackPitch", 1100, "ASSUMED", "rack width plus a gap"],
["cornerStandoffSize", 320, "ASSUMED", "no source"],
["windowFrameWidth", 100, "ASSUMED", "no source"],
["windowShutterThickness", 40, "ASSUMED", "Wikipedia confirms a shutter, not its size"],
["cradleHeight", 450, "ASSUMED", "fills the gap up to the S0 truss"],
["cradleWidth", 1600, "ASSUMED", "no source"],
["cradleBeamSize", 180, "ASSUMED", "no source"],
["mtsStrutCount", 4, "VERIFIED", "Wikipedia ITS, Gunter's Space Page: four MTS struts"],
["mtsStrutDiameter", 110, "ASSUMED", "no source"],
];
function getDestiny(name) = _findRow(getDestinyDimensions(), name)[1];
// Rows: [name, valueMm, status, source]. LM = Lockheed Martin array release; CBS = CBS News
// STS-115 P3/P4 Boeing fact sheet; FAST = Elliott, Horta, Templeton, Knight, "Model Calibration
// Efforts for the ISS Solar Array Mast" (NASA LaRC, NTRS 20120008397), local
// references/nasa/ntrs20120008397.pdf; WEPS = Wikipedia "Electrical system of the ISS".
function getSolarArrayDimensions() = [
["wingLength", 35000, "VERIFIED", "WEPS: 35 m extended; CBS 115 ft; LM 107 ft"],
["wingWidth", 11582, "VERIFIED", "CBS, LM: 38 ft across; WEPS 12 m"],
["blanketWidth", 4572, "VERIFIED", "WEPS: blanket box 4.57 m long; patent 9815575 ~15 ft"],
["blanketPanelCount", 84, "ASSUMED", "Wikipedia ITS: 82 active panels; 84 total with two blank ones from an untraced search result"],
["mastLength", 32918, "VERIFIED", "FAST p1: 1296 in deployed"],
["mastWidth", 772, "VERIFIED", "FAST p1: 30.4 in square"],
["mastBayCount", 32, "VERIFIED", "FAST p2: first 20 and last bay tapered, 11 straight; fig 2 shows 45.11 in, which does not fit"],
["mastLongeronSize", 15, "VERIFIED", "FAST p2: 0.5 and 0.59 in square"],
["blanketBoxHeight", 508, "VERIFIED", "WEPS, CBS: 20 in"],
["betaGimbalSize", 914, "VERIFIED", "CBS: 3 x 3 x 3 ft"],
["equipmentAssemblySize", 4877, "VERIFIED", "CBS: IEA 16 x 16 x 16 ft"],
["radiatorLength", 13600, "VERIFIED", "CBS: 44 ft deployed; search snippet 13.6 m"],
["radiatorWidth", 3120, "VERIFIED", "CBS: 12 ft; search snippet 3.12 m"],
["radiatorPanelCount", 7, "VERIFIED", "CBS: seven panels"],
["rollOutLength", 18288, "VERIFIED", "NASA blog 2023-06-15: 60 ft; Spaceflight Now 63 ft"],
["rollOutWidth", 6096, "VERIFIED", "NASA blog 2023-06-15: 20 ft"],
["rollOutBoomDiameter", 165, "VERIFIED", "Spaceflight Now 2021: slit-tube carbon booms 6.5 in across"],
["rollOutCantDegrees", 10, "VERIFIED", "Spaceflight Now 2021: canted 10 degrees (pre-install plan)"],
["blanketBoxDepth", 300, "ASSUMED", "no source"],
["blanketThickness", 12, "ASSUMED", "display thickness"],
["blanketPanelGap", 14, "ASSUMED", "fold line between panels"],
["mastCanisterDiameter", 1100, "ASSUMED", "no source"],
["tipBarSize", 160, "ASSUMED", "no source"],
["radiatorPanelThickness", 70, "ASSUMED", "no source"],
["radiatorPanelGap", 60, "ASSUMED", "no source"],
["rollOutStandoff", 1500, "ASSUMED", "height of the mounting bracket above the old wing's root"],
["rollOutMandrelDiameter", 300, "ASSUMED", "no source"],
];
function getSolarArray(name) = _findRow(getSolarArrayDimensions(), name)[1];
// Power channel on each wing, and whether it carries an iROSA. 2A and 3B were planned for
// fall 2026 (WEPS; 2A bracket installed 2026-03-18) and are not confirmed installed.
// Rows: [channel, truss segment, wing side (1 fore, -1 aft), iROSA installed].
// Which wing of a pair points fore is ASSUMED.
function getPowerChannels() = [
["1A", "S3S4", 1, true], ["3A", "S3S4", -1, true],
["1B", "S6", 1, true], ["3B", "S6", -1, false],
["2A", "P3P4", 1, false], ["4A", "P3P4", -1, true],
["2B", "P6", 1, true], ["4B", "P6", -1, true],
];
// Rows: [name, valueMm, status, source]. S112 = STS-112 press kit (local
// references/nasa/sts112PressKit.pdf), PDF page numbers; G10 as above.
function getTrussDimensions() = [
["sarjDiameter", 3048, "VERIFIED", "CBS P3/P4 fact sheet: 10 ft; Wikipedia ITS"],
["sarjFractionAlongP3P4", 0.45, "ASSUMED", "where the SARJ splits P3 (inboard) from P4"],
["sarjDepth", 700, "ASSUMED", "no source"],
["radiatorsPerSide", 3, "VERIFIED", "Boeing 2002 P1 release: a set of three radiators on each of P1, S1"],
["radiatorPanelsEach", 8, "VERIFIED", "NASA archive photo caption (wikiarchives.space): eight panels"],
["radiatorPanelWidth", 3353, "VERIFIED", "NASA archive photo caption: each panel 11 ft wide"],
["radiatorDeployedLength", 22860, "VERIFIED", "NASA archive photo caption: 75 ft; a NASA ATCS overview says 76.4 ft"],
["radiatorPanelThickness", 60, "ASSUMED", "no source"],
["radiatorBoxLength", 4200, "ASSUMED", "S112 p43 drawing: three boxes fill the top face"],
["radiatorBoxDepth", 3600, "ASSUMED", "S112 p43 drawing"],
["radiatorBoxHeight", 1200, "ASSUMED", "6 ft beam plus these boxes reaches the 10 ft quoted elsewhere"],
["ammoniaTankSize", 1500, "ASSUMED", "S112 p43 drawing: ammonia tank assembly at the inboard end"],
["s1BottomWidthFraction", 0.55, "ASSUMED", "S112 p43 drawing: S1 narrows toward nadir"],
["trussBayLength", 2300, "ASSUMED", "no source"],
["longeronSize", 220, "ASSUMED", "no source"],
["bracingSize", 90, "ASSUMED", "no source"],
["mobileBaseLength", 5700, "VERIFIED", "G10 p91: MBS 5.7 x 4.5 x 2.9 m"],
["mobileBaseWidth", 4500, "VERIFIED", "G10 p91"],
["mobileBaseHeight", 2900, "VERIFIED", "G10 p91"],
["railGauge", 2400, "ASSUMED", "no source"],
["railSize", 120, "ASSUMED", "no source"],
["cetaCartCount", 2, "VERIFIED", "S112 p44: one each on S1 and P1"],
["cetaCartSize", 1600, "ASSUMED", "no source"],
];
function getTruss(name) = _findRow(getTrussDimensions(), name)[1];
// Rows: [name, valueMm, status, source]. ESA2 = ESA "What is Node 2". Primary structure is
// identical to Node 3 (NASA Node 3 Description p7), so the shell uses Tranquility's numbers.
// Element frame: +Z from the aft passive face (z = 0, to Destiny) to the forward active face
// (to PMA-2). Once installed, element +X is nadir, +Y port, +Z station forward.
function getHarmonyDimensions() = [
["overallLength", 6706, "VERIFIED", "same structure as Node 3; Wikipedia 6.7 m; ESA2 just over 7 m"],
["outerDiameter", 4480, "VERIFIED", "same structure as Node 3; Wikipedia 4.4 m; ESA2 4.6 m"],
["rackCount", 8, "VERIFIED", "Wikipedia, ESA2: four avionics, four stowage or crew quarters"],
["debrisPanelCount", 98, "VERIFIED", "Wikipedia, ESA2: 98 panels"],
["activeCbmCount", 5, "VERIFIED", "Wikipedia: aft passive, the other five active"],
["panelRowsPerBay", 3, "ASSUMED", "chosen so the panel total lands near 98"],
["panelsAround", 12, "ASSUMED", "chosen so the panel total lands near 98"],
];
function getHarmony(name) = _findRow(getHarmonyDimensions(), name)[1];
// Rows: [name, valueMm, status, source]. SSUG = Space Station User's Guide (spaceref.com, MSS);
// WMSS = Wikipedia "Mobile Servicing System".
function getCanadarmDimensions() = [
["overallLength", 17600, "VERIFIED", "G10 p91, SSUG, WMSS: 17.6 m fully extended"],
["boomDiameter", 350, "VERIFIED", "SSUG, WMSS: 35 cm; G10 p91 says 0.36 m max"],
["jointCount", 7, "VERIFIED", "SSUG, WMSS: roll, yaw, pitch at each end, pitch at the elbow"],
["endEffectorCount", 2, "VERIFIED", "WMSS: identical LEE at each end, three snare wires"],
["endEffectorLength", 900, "ASSUMED", "no source"],
["endEffectorDiameter", 560, "ASSUMED", "no source"],
["jointSpacing", 420, "ASSUMED", "no source"],
["jointHousingDiameter", 480, "ASSUMED", "no source"],
["mobileBaseGrappleCount", 4, "VERIFIED", "WMSS: one PDGF at each of the MBS's four top corners"],
];
function getCanadarm(name) = _findRow(getCanadarmDimensions(), name)[1];
// Both booms are equal and take whatever the 17.6 m leaves after the joints and LEEs (ASSUMED).
function getCanadarmBoomLength() =
(getCanadarm("overallLength") - 2 * getCanadarm("endEffectorLength")
- (getCanadarm("jointCount") - 1) * getCanadarm("jointSpacing") - getCanadarm("jointSpacing")) / 2;
// Rows: [name, valueMm, status, source]. ESAC = ESA "European Columbus laboratory" page.
// Element frame: +Z from the passive berthing face (z = 0, at Harmony's starboard port) out
// to the closed outboard end. Wall angles: see getColumbusWallAngle() in columbus.scad.
function getColumbusDimensions() = [
["overallLength", 6871, "VERIFIED", "ESAC: total module length 6871 mm; G10 p50 6.9 m"],
["outerDiameter", 4477, "VERIFIED", "ESAC: largest diameter 4477 mm; G10 p50 4.5 m"],
["rackCount", 10, "VERIFIED", "ESAC, G10 p50: 10 ISPRs"],
["externalPayloadSites", 4, "VERIFIED", "ESAC: 4 external payload facilities, 370 kg each"],
["pressureShellDiameter", 4200, "ASSUMED", "debris panels standing off like the nodes'"],
["inboardConeLength", 600, "ASSUMED", "G10 p50 drawing proportion"],
["outboardConeLength", 750, "ASSUMED", "G10 p50 drawing proportion"],
["endConeSmallDiameter", 2150, "ASSUMED", "same as the nodes"],
["outboardBulkheadDiameter", 2400, "ASSUMED", "G10 p50 drawing"],
["shellWallThickness", 40, "ASSUMED", "no source"],
["reinforcementRingWidth", 120, "ASSUMED", "no source"],
["reinforcementRingHeight", 60, "ASSUMED", "no source"],
["cylinderSectionCount", 2, "ASSUMED", "G10 p50 photo"],
["panelRowsPerBay", 2, "ASSUMED", "G10 p50 photo"],
["panelsAround", 12, "ASSUMED", "no source"],
["panelThickness", 4, "ASSUMED", "no source"],
["panelGap", 12, "ASSUMED", "no source"],
["racksPerSideWall", 4, "ASSUMED", "four on each side wall and two overhead make the sourced ten"],
["rackRadialClearance", 120, "ASSUMED", "same as the nodes (tested)"],
["rackPitch", 1100, "ASSUMED", "rack width plus a gap"],
["payloadArmLength", 1500, "ASSUMED", "G10 p50 drawing"],
["payloadAdapterSize", 900, "ASSUMED", "no source"],
["payloadBoxSize", 800, "ASSUMED", "a 370 kg payload's envelope, no source"],
];
function getColumbus(name) = _findRow(getColumbusDimensions(), name)[1];
// Rows: [name, valueMm, status, source]. KH = JAXA Kibo Handbook, Sept 2007 (local
// references/jaxa/kiboHandbook2007.pdf), PDF page numbers. Element frame: +Z outboard from the
// PM's passive berthing face (z = 0, at Harmony's port port). Installed, element +X points station
// aft and +Y zenith.
function getKiboDimensions() = [
["pmLength", 11200, "VERIFIED", "KH p21, p27: 11.2 m"],
["pmOuterDiameter", 4400, "VERIFIED", "KH p27: 4.4 m"],
["pmInnerDiameter", 4200, "VERIFIED", "KH p27: 4.2 m"],
["pmRackCount", 23, "VERIFIED", "KH p27: 11 system racks and 12 ISPRs"],
["pmInteriorWidth", 2200, "VERIFIED", "KH p29: work space almost square, 2.2 m"],
["windowCount", 2, "VERIFIED", "Guinness World Records; KH p28 photo labels windows on the outboard end"],
["airlockOuterDiameter", 1700, "VERIFIED", "KH p35: outer side 1.7 m"],
["airlockInnerDiameter", 1400, "VERIFIED", "KH p35: inner side 1.4 m"],
["airlockLength", 2000, "VERIFIED", "KH p35: 2.0 m"],
["elmLength", 4200, "VERIFIED", "KH p21-22: 4.2 m, berthing ring included"],
["elmOuterDiameter", 4400, "VERIFIED", "KH p21: 4.4 m"],
["elmRackCount", 8, "VERIFIED", "KH p21: 8 racks"],
["efWidth", 5000, "VERIFIED", "KH p42: 5.0 m; current JAXA page agrees"],
["efLength", 5600, "VERIFIED", "KH p42: 5.6 m; current JAXA page says 5.2 m"],
["efHeight", 4000, "VERIFIED", "KH p42: 4.0 m; current JAXA page says 3.8 m"],
["efSiteCount", 12, "VERIFIED", "KH p42: 12, two for JEM systems and one temporary"],
["efPayloadLength", 1850, "VERIFIED", "KH p43: standard payload envelope 1.85 x 1.0 x 0.8 m"],
["efPayloadWidth", 1000, "VERIFIED", "KH p43"],
["efPayloadHeight", 800, "VERIFIED", "KH p43"],
["mainArmLength", 10000, "VERIFIED", "KH p21, p54: 10 m; ICRA 2011 9.9 m"],
["mainArmJointCount", 6, "VERIFIED", "KH p54: six joints"],
["smallFineArmLength", 2200, "VERIFIED", "KH p21: 2.2 m; ICRA 2011 1.9 m; JAXA status page 1.5 m"],
["overallLength", 20500, "VERIFIED", "KH p22 diagram: PM plus EF plus the since-removed ELM-ES"],
["overallHeight", 8600, "VERIFIED", "KH p22 diagram: PM plus ELM-PS"],
["pmShellDiameter", 4250, "ASSUMED", "4.2 m inside plus a thin wall; panels reach 4.4 m"],
["shellWallThickness", 25, "ASSUMED", "no source"],
["pmInboardConeLength", 500, "ASSUMED", "KH p28 drawing"],
["pmOutboardConeLength", 400, "ASSUMED", "KH p28 drawing"],
["pmSectionCount", 4, "ASSUMED", "KH p28 drawing"],
["endConeSmallDiameter", 2150, "ASSUMED", "same as the nodes"],
["outboardBulkheadDiameter", 3300, "ASSUMED", "KH p28 drawing"],
["reinforcementRingWidth", 120, "ASSUMED", "no source"],
["reinforcementRingHeight", 50, "ASSUMED", "no source"],
["panelRowsPerBay", 2, "ASSUMED", "KH p28 photo"],
["panelsAround", 14, "ASSUMED", "KH p28 photo"],
["panelThickness", 4, "ASSUMED", "no source"],
["panelGap", 12, "ASSUMED", "no source"],
["racksPerWall", 6, "ASSUMED", "six per wall on four walls is 24 slots for 23 racks"],
["rackPitch", 1100, "ASSUMED", "rack width plus a gap"],
["rackRadialClearance", 120, "ASSUMED", "puts rack fronts 2.24 m apart (KH: 2.2 m square)"],
["elmPortAlongPm", 8600, "ASSUMED", "KH p22 drawing: ELM-PS sits toward the outboard end"],
["elmPortFaceProud", 30, "ASSUMED", "same as the nodes"],
["efBerthingGap", 500, "ASSUMED", "EF berthing mechanism depth"],
["efDropBelowAxis", 1600, "ASSUMED", "KH p22 drawing: the EF hangs below the PM axis"],
["windowDiameter", 500, "ASSUMED", "no source"],
["mainArmBoomDiameter", 300, "ASSUMED", "no source"],
["armJointSpacing", 350, "ASSUMED", "no source"],
];
function getKibo(name) = _findRow(getKiboDimensions(), name)[1];
// Rows: [name, valueMm, status, source]. RDS = rdataspace.github.io "ISS Components - PMA, IDA";
// WIDA = Wikipedia "International Docking Adapter".
function getPmaDimensions() = [
["length", 1860, "VERIFIED", "G10 p64: 1.86 m"],
["wideDiameter", 1900, "VERIFIED", "G10 p64: 1.9 m at the wide end (Gunter's: 1.3 m, likely the passage)"],
["narrowDiameter", 1370, "VERIFIED", "G10 p64: 1.37 m at the narrow end (Gunter's: 0.7 m)"],
["endOffset", 610, "VERIFIED", "RDS: truncated cone with a 24 in offset between the end rings"],
["idaHeight", 1100, "VERIFIED", "WIDA: 110 cm"],
["idaWidth", 1600, "VERIFIED", "WIDA: 160 cm"],
["idaOuterDiameter", 2400, "VERIFIED", "WIDA: about 240 cm with targets and reflectors"],
["pma1OffsetAngle", 0, "ASSUMED", "which way PMA-1's narrow end steps off Unity's axis is unsourced"],
["pma2OffsetAngle", 0, "ASSUMED", "unsourced"],
["pma3OffsetAngle", 0, "ASSUMED", "unsourced"],
["apasRingDiameter", 1300, "ASSUMED", "a little inside the 1.37 m narrow end"],
["apasRingHeight", 150, "ASSUMED", "no source"],
["apasPetalHeight", 350, "ASSUMED", "no source"],
["shellWallThickness", 30, "ASSUMED", "no source"],
["computerBoxSize", 600, "ASSUMED", "STS-88 press kit p48: early MDM computers on PMA-1's exterior"],
];
function getPma(name) = _findRow(getPmaDimensions(), name)[1];
// Rows: [name, valueMm, status, source]. MSFC = NASA MSFC "What is MPLM?" (web archive, 2010-05-27);
// STS133 = STS-133 press kit (Nov 2010), local references/nasa/sts133PressKit.pdf; STS102 = STS-102
// press kit, local references/nasa/sts102PressKit.pdf; PMMLE = Jones, "ISS PMM Life Extension"
// (NTRS 20120016610), local; SFN = Spaceflight Now "Roomy addition for space station", 2010-10;
// WLEO = Wikipedia "Leonardo (ISS module)" (secondary). PDF page numbers. Element frame: +Z from
// the passive berthing face (z = 0, at Tranquility's forward port) to the closed aft end cone.
// Rows named ...Kg, ...M3, ...Fraction, ...Count or ...Degrees are not millimeters.
function getLeonardoDimensions() = [
["overallLength", 6670, "VERIFIED", "G10 p58, G15 p36: 6.67 m; STS133 p30 and MSFC 21 ft; MSFC 6.6 m; STS102 p25 6.4 m"],
["outerDiameter", 4500, "VERIFIED", "G10 p58, G15 p36: exterior 4.5 m; STS133 p30 and MSFC 15 ft; STS102 p25 4.6 m"],
["interiorDiameter", 4210, "VERIFIED", "G10 p58, G15 p36: interior 4.21 m; MSFC 4.2 m"],
["cylinderLength", 4800, "VERIFIED", "MSFC: cylindrical part 4.8 m"],
["pressurizedVolumeM3", 76.7, "VERIFIED", "G10 p58, G15 p36: 76.7 m3; STS133 p9 says 2,472 ft3 (70.0 m3)"],
["habitableVolumeM3", 31, "VERIFIED", "G10 p58, MSFC: 31 m3"],
["massKg", 4428, "VERIFIED", "G10 p58: 4,428 kg; STS133 p30 empty 9,672 lb (4,387 kg); MSFC MPLM 4,685 kg"],
["rackCount", 16, "VERIFIED", "G10 p58, G15 p36, MSFC, STS102 p25: up to 16 racks"],
["activeRackCount", 5, "VERIFIED", "MSFC: 16 standard racks (5 active)"],
["racksCarriedSts133Count", 14, "VERIFIED", "STS133 p9, p30: 14 racks on the PMM's delivery flight"],
["mplmGrappleFixtureCount", 2, "VERIFIED", "MSFC: two grapple fixtures on the MPLM"],
["grappleFixtureCount", 1, "VERIFIED", "WLEO: the +Y FRGF was removed for the PMM, leaving one of MSFC's two"],
["addedBlanketCount", 50, "VERIFIED", "SFN: 50 Nextel/Kevlar mattresses laid under the existing metal shields"],
["addedBlanketCoverageFraction", 0.67, "VERIFIED", "SFN: about two-thirds of the surface, chosen by where the module sits"],
["endConeLength", 764, "ASSUMED", "two equal cones share what 6.67 m leaves after the 4.8 m cylinder and the passive ring"],
["endConeSmallDiameter", 2150, "ASSUMED", "same as the nodes"],
["shellWallThickness", 40, "ASSUMED", "no source"],
["cylinderSectionCount", 2, "ASSUMED", "no source"],
["reinforcementRingWidth", 120, "ASSUMED", "no source"],
["reinforcementRingHeight", 60, "ASSUMED", "no source"],
["panelRowsPerBay", 2, "ASSUMED", "no source"],
["panelsAround", 12, "ASSUMED", "no source"],
["panelThickness", 4, "ASSUMED", "no source"],
["panelGap", 12, "ASSUMED", "no source"],
["addedBlanketThickness", 15, "ASSUMED", "no source"],
["addedBlanketCenterDegrees", 0, "ASSUMED", "which two-thirds SFN means is unsourced; centered on station starboard"],
["racksPerWall", 4, "ASSUMED", "16 racks over four walls; PMMLE p7 cutaway shows racks on all four"],
["rackPitch", 1100, "ASSUMED", "rack width plus a gap"],
["rackRadialClearance", 120, "ASSUMED", "same as the nodes (tested)"],
["aftAccessClosureDiameter", 1600, "ASSUMED", "racks pass through it on the ground (NTRS 20100042363 p2); size unsourced"],
["aftAccessClosureHeight", 80, "ASSUMED", "no source"],
["grappleFixtureDegrees", 270, "ASSUMED", "which side keeps the remaining FRGF is unsourced"],
["grappleFixtureAlongCylinder", 0.5, "ASSUMED", "fraction of the cylinder length from its forward end; unsourced"],
["trunnionCount", 5, "ASSUMED", "four longeron and one keel, the usual shuttle payload set; no MPLM source"],
["retroReflectorCount", 3, "ASSUMED", "WLEO confirms visiting-vehicle retro-reflectors, not how many"],
["retroReflectorDiameter", 120, "ASSUMED", "no source"],
];
function getLeonardo(name) = _findRow(getLeonardoDimensions(), name)[1];
// Rows: [name, valueMm, status, source]. Rows not in mm say their unit in the name.
// FS16 = NASA BEAM fact sheet NF-2016-03-600-HQ; YR2 = Valle & Wells, "BEAM ISS Year-Two"
// (NTRS 20180006494); SHM = Wells, IWSHM 2017 (NTRS 20170004708); TF20 = Taylor, TFAWS-2020
// (NTRS 20205006279); BLOG = NASA ISS blog "BEAM expanded to full size", 2016-05-28;
// CRS8 = NASA SpaceX CRS-8 mission overview. Element frame: z = 0 on the PCBM face toward
// Tranquility, +Z station aft; once installed element +X is zenith and +Y port.
function getBeamDimensions() = [
["expandedLength", 4013, "VERIFIED", "BLOG: final length 158 in; YR2 p28: 4.01 m with FRGF; FS16 p2: 13.16 ft"],
["expandedDiameter", 3226, "VERIFIED", "BLOG: 127 in, called internal; YR2 p28: 3.23 m; FS16 p2: 10.5 ft. Drawn as the outer surface"],
["packedLength", 2161, "VERIFIED", "FS16 p2: 7.09 ft; YR2 p28: 2.16 m with FRGF"],
["packedDiameter", 2360, "VERIFIED", "YR2 p28: 2.36 m; FS16 p2: just under 7.75 ft"],
["massKg", 1413, "VERIFIED", "CRS8 p1: 3115.1 lb / 1413 kg; YR2 p28 ~1400 kg with PCBM; FS16 p2 ~1360 kg"],
["expandedVolumeM3", 16, "VERIFIED", "YR2 p28: 16 m3; FS16 p2: 565 cu ft; TF20 p3: 16.0 m3"],
["packedVolumeM3", 3.6, "VERIFIED", "YR2 p28: 3.6 m3"],
["bulkheadCount", 2, "VERIFIED", "FS16 p2: two metal bulkheads; YR2 p4: forward and aft"],
["longeronCount", 4, "VERIFIED", "YR2 p4: Longeron (x4); TF20 p10 fig 12a"],
["adssStrutCount", 4, "VERIFIED", "YR2 p4: ADSS struts (x4)"],
["shearPanelCount", 8, "VERIFIED", "YR2 p4: Shear Panel (x8); SHM p4 fig 2 shows an octagon"],
["airTankCount", 8, "VERIFIED", "YR2 p4: Air Tanks (x8); BLOG pressurization post: eight tanks"],
["fseTrunnionCount", 6, "VERIFIED", "YR2 p4: Flight Support Equipment (x6); TF20 fig 12a: FSE trunnions"],
["frgfCount", 2, "VERIFIED", "TF20 p10 fig 12a: two FRGFs at the aft end; Wikipedia (Webb 2015)"],
["windowCount", 0, "VERIFIED", "FS16 p2: it has no windows"],
["forwardBulkheadDiameter", 2200, "ASSUMED", "TF20 fig 12a proportion; inside the 2.36 m packed diameter"],
["forwardBulkheadThickness", 120, "ASSUMED", "no source"],
["tunnelAdapterDiameter", 1700, "ASSUMED", "TF20 fig 12a: inside the PCBM ring"],
["hatchDiameter", 1000, "ASSUMED", "YR2 p4, TF20 fig 12a: round door; size from proportion"],
["hatchThickness", 60, "ASSUMED", "no source"],
["aftBulkheadDiameter", 1800, "ASSUMED", "TF20 fig 12a proportion, a little smaller than the forward one"],
["aftBulkheadThickness", 120, "ASSUMED", "no source"],
["softGoodsThickness", 150, "ASSUMED", "proprietary layup (TF20 p10); chosen so the modelled interior is near 16 m3 (tested)"],
["softGoodsShoulderExponent", 2, "ASSUMED", "elliptical shoulders between bulkhead and barrel, TF20 fig 12a"],
["shearBoxAcrossFlats", 1300, "ASSUMED", "SHM fig 2, YR2 p4 proportion"],
["shearBoxLength", 1000, "ASSUMED", "must fit the packed length (tested)"],
["airTankDiameter", 250, "ASSUMED", "YR2 p4 proportion"],
["airTankLength", 650, "ASSUMED", "YR2 p4 proportion"],
["longeronPitchDiameter", 1500, "ASSUMED", "TF20 fig 12a: longerons run from the forward bulkhead to the shear box corners"],
["longeronWidth", 70, "ASSUMED", "no source"],
["adssStrutDiameter", 45, "ASSUMED", "no source"],
["imvDuctDiameter", 150, "ASSUMED", "YR2 p4 proportion"],
["fseTrunnionLength", 250, "ASSUMED", "no source"],
["fseTrunnionWidth", 120, "ASSUMED", "no source"],
["fseFirstAngle", 30, "ASSUMED", "TF20 fig 12a shows uneven spacing; drawn every 60 degrees"],
["aftFrgfStandoff", 120, "ASSUMED", "TF20 fig 12a: FRGF on a bracket off the aft bulkhead"],
["aftFrgfRadius", 550, "ASSUMED", "TF20 fig 12a: off-axis on the aft face"],
["aftFrgfAngle", 225, "ASSUMED", "TF20 fig 12b: toward nadir-starboard"],
["radialFrgfAngle", 270, "ASSUMED", "TF20 fig 12b: on the starboard side of the aft bulkhead rim"],
];
function getBeam(name) = _findRow(getBeamDimensions(), name)[1];
// Rows: [name, valueMm, status, source]. RUWP = ru.wikipedia "Поиск (модуль МКС)" spec table;
// RSW = russianspaceweb.com iss_dc.html and iss-mim2.html (DC-1/MIM-2 spec table); WPS =
// Wikipedia "Strela (crane)"; WDB = Wikipedia "Docking and berthing of spacecraft"; all read
// 2026-10-21, Wikipedia secondary. Hull stations run along the axis from the Zvezda face (z = 0).
// Profile rows citing "G10 p60 cutaway" are measured off NASA's Pirs drawing (Poisk is its twin)
// scaled to the 4049 mm hull, so they are ASSUMED. Element +X is station forward, +Y port.
function getPoiskDimensions() = [
["lengthBetweenDockingPlanes", 4049, "VERIFIED", "RUWP: hull length between docking-unit planes 4049; Wikipedia 4.049 m"],
["lengthWithProbeExtended", 4910, "VERIFIED", "RSW table: 4.91 m with probe extended; G10 p61 and G15 p40 round to 4.9 m"],
["maxDiameter", 2550, "VERIFIED", "G10 p61, G15 p40, RUWP: 2.55 m"],
["hullMaxDiameter", 2200, "VERIFIED", "RSW iss_dc text: diameter did not exceed 2.2 m; read as bare hull, 2.55 m as over the hatch rims"],
["pressurizedVolumeLitres", 12500, "VERIFIED", "RUWP and RSW: 12.5 m3 gas volume; G10 p61 and G15 p40 say 14.8 m3 (conflict, open)"],
["evaHatchCount", 2, "VERIFIED", "G10 p61: EVA Hatch 1 and 2; RUWP; Wikipedia"],
["evaHatchDiameter", 1000, "VERIFIED", "RUWP: exit hatch diameter 1000 mm; RSW: two side hatches of 1 m"],
["evaHatchSpacingDegrees", 180, "VERIFIED", "G10 p60 Pirs cutaway: hatches 1 and 2 on opposite sides"],
["dockingPassageDiameter", 800, "VERIFIED", "WDB: SSVP transfer passage 800 mm"],
["dockingUnitCount", 2, "VERIFIED", "RUWP; RSW: active hybrid SSVP-M toward Zvezda, passive SSVP outboard"],
["externalWorkstationCount", 5, "VERIFIED", "G10 p61, G15 p40: five; RSW and Wikipedia say two external payloads (conflict, open)"],
["activeExternalWorkstationCount", 1, "VERIFIED", "G15 p40: one three-port active and four passive"],
["activeWorkstationPortCount", 3, "VERIFIED", "G15 p40: three-port active workstation"],
["internalWorkstationCount", 3, "VERIFIED", "G10 p61: three temporary internal workstations near the side windows"],
["kursHighGainAntennaCount", 2, "VERIFIED", "G10 p61 labels two High-Gain Kurs Antennas"],
["handrailsAddedJan2010", 2, "VERIFIED", "Wikipedia Poisk: the Jan 2010 EVA installed two handrails"],
["strelaCount", 1, "VERIFIED", "RUWP and WPS: Strela GStM-1 moved from Pirs to Poisk, Feb 2012"],
["strelaRetractedLength", 1820, "VERIFIED", "WPS: around 6 ft (182 cm) collapsed (verify; photos look longer)"],
["strelaExtendedLength", 14000, "VERIFIED", "WPS: 46 ft (14 m); RUWP says 15 m"],
["activeUnitLength", 230, "ASSUMED", "G10 p60 cutaway proportion"],
["activeUnitDiameter", 1300, "ASSUMED", "G10 p60 cutaway proportion"],
["hybridHookCount", 12, "ASSUMED", "WDB: the hybrid uses an APAS-95 hard-dock collar; APAS hook count"],
["upperBulkheadDiameter", 1400, "ASSUMED", "G10 p60 cutaway proportion"],
["hullMaxZ", 1800, "ASSUMED", "G10 p60 cutaway: widest at the EVA hatches"],
["airlockSectionEndZ", 2530, "ASSUMED", "G10 p60 cutaway: joint to the equipment cylinder"],
["equipmentSectionDiameter", 2200, "ASSUMED", "RSW 2.2 m body; G10 p60 draws it ~10% narrower, widened toward RUWP 12.5 m3"],
["equipmentSectionEndZ", 3350, "ASSUMED", "G10 p60 cutaway proportion"],
["lowerDomeEndZ", 3820, "ASSUMED", "G10 p60 cutaway proportion"],
["passiveUnitDiameter", 1250, "ASSUMED", "G10 p60 cutaway proportion"],
["shellWallThickness", 30, "ASSUMED", "no source"],
["probeDiameter", 120, "ASSUMED", "no source"],
["probeHeadDiameter", 220, "ASSUMED", "no source"],
["drogueConeDepth", 450, "ASSUMED", "no source"],
["drogueMouthDiameter", 1000, "ASSUMED", "no source"],
["evaHatchRimProud", 175, "ASSUMED", "closes hullMaxDiameter to the sourced 2.55 m"],
["evaHatchRimWidth", 90, "ASSUMED", "no source"],
["evaHatchWindowDiameter", 230, "ASSUMED", "G10 p60 cutaway shows a porthole in the hatch"],
["evaHatch1Angle", 90, "ASSUMED", "which station side the hatches face is unsourced; port and starboard chosen"],
["movableHandrailDiameter", 35, "ASSUMED", "G10 p60 cutaway shows a rail ringing EVA hatch 2"],
["sideWindowCount", 3, "ASSUMED", "one per internal workstation (G10 p61)"],
["sideWindowDiameter", 230, "ASSUMED", "no source"],
["kursBoomLength", 900, "ASSUMED", "G10 p61 drawing proportion"],
["kursDishDiameter", 600, "ASSUMED", "G10 p61 drawing proportion"],
["stubAntennaLength", 350, "ASSUMED", "attitude-control and wide-beam antennas, G10 p61 labels"],
["strelaBaseHeight", 450, "ASSUMED", "no source"],
["strelaBoomDiameter", 160, "ASSUMED", "no source"],
["strelaAngle", 330, "ASSUMED", "G10 p61 drawing puts the boom on the equipment section; angle clears the hatch rim"],
["workstationPlateSize", 350, "ASSUMED", "no source"],
["workstationStandoff", 120, "ASSUMED", "no source"],
["dockingTargetPostLength", 500, "ASSUMED", "no source; target installed on the Jan 2010 EVA (Wikipedia)"],
["dockingTargetCrossSize", 300, "ASSUMED", "no source"],
["handrailRowCount", 6, "ASSUMED", "no source"],
];
function getPoisk(name) = _findRow(getPoiskDimensions(), name)[1];
// Rows: [name, valueMm, status, source]. IDD = Nanoracks NR-AIRLOCK-S0009 Rev D, Interface
// Definition for Nanoracks Airlock (local references/nanoracks/bishopAirlockIdd.pdf; "p" is the
// printed page, PDF page = p + 6). CRS21 = NASA SpaceX CRS-21 mission overview (local
// references/nasa/crs21MissionOverview.pdf). Stations run along the axis from the PCBM mating
// face, the IDD's X. Element frame: +Z outboard (station port once installed); +X station
// forward and +Y station nadir, from the IDD's Y-zenith / Z-forward axes (IDD p5).
function getBishopDimensions() = [
["overallLength", 1800, "VERIFIED", "IDD p6 fig 3.1.4-1: ~70 in (1.8 m); Wikipedia 1.80 m; Spaceflight Now 5.8 ft"],
["outerDiameter", 2100, "VERIFIED", "IDD p6 fig 3.1.4-1: ~83 in (2.1 m), over the MMOD shield; Spaceflight Now 6.9 ft"],
["pressureShellDiameter", 2014, "VERIFIED", "Wikipedia infobox 2.014 m (secondary); IDD p19 fig 5.1.4-1 scales to about 78.6 in"],
["pressurizedVolumeLiters", 3990, "VERIFIED", "Wikipedia 3.99 m3; Thales Alenia 2020-12-04 and Gunter's: four cubic meters"],
["massKg", 1060, "VERIFIED", "IDD p6 fig 3.1.4-1: ~2,337 lb (1,060 kg) total; Wikipedia 1,059 kg"],
["launchMassKg", 1090, "VERIFIED", "CRS21 p2: unpressurized payload 2,403 lb (1,090 kg) incl. installation hardware"],
["lowerRingStation", 309, "VERIFIED", "IDD p19 fig 5.1.4-1: X(12.15 in) fastener plane at the PCBM/shell ring"],
["cylinderTopStation", 1190, "ASSUMED", "IDD p19 fig 5.1.4-1 drawing scale: cylinder/dome bolted joint near 46.9 in"],
["domeFastenerStation", 1379, "VERIFIED", "IDD p19 fig 5.1.4-1: X(54.31 in) fastener plane in the dome"],
["crownRingStation", 1617, "VERIFIED", "IDD p15 fig 5.1.3.1-2: dome seat track at X 63.24 to 63.68 in"],
["pressureDomeCrownStation", 1680, "ASSUMED", "crown ring at 63.68 in plus the shell above it, fig 5.1.4-1 scale"],
["shieldDomeBaseStation", 1240, "ASSUMED", "IDD p19 fig 5.1.4-1 drawing scale: where the MMOD dome leaves the cylinder"],
["crownRingDiameter", 610, "VERIFIED", "IDD p15 fig 5.1.3.1-2: 24.00 in"],
["shellWallThickness", 20, "ASSUMED", "IDD p19 fig 5.1.4-1 hatched wall, about 1 in at drawing scale"],
["shieldSkinThickness", 4, "ASSUMED", "no source"],
["domeGoreCount", 6, "ASSUMED", "seams counted on the IDD p5-6 end views"],
["skirtDiameter", 2500, "ASSUMED", "IDD p19 fig 5.1.4-1 drawing scale (about 98 in); the Node 3 stovepipe cap MMOD shield, p4"],
["skirtStation", 290, "ASSUMED", "IDD p19 fig 5.1.4-1 drawing scale"],
["skirtThickness", 40, "ASSUMED", "IDD p19 fig 5.1.4-1 drawing scale"],
["cylinderSeatTrackCount", 16, "VERIFIED", "IDD p16 fig 5.1.3.2-1: tracks A to P"],
["cylinderSeatTrackPitchDegrees", 22.5, "VERIFIED", "IDD p16 fig 5.1.3.2-1: 22.50 deg typ"],
["cylinderSeatTrackRadius", 951, "VERIFIED", "IDD p16 fig 5.1.3.2-1: Rad. 37.45 in"],
["cylinderSeatTrackLength", 799, "ASSUMED", "IDD p16 fig 5.1.3.2-2 hole span 31.450 in taken as the track length"],
["domeSeatTrackCount", 8, "VERIFIED", "IDD p15 fig 5.1.3.1-1: tracks Q to X"],
["domeSeatTrackPitchDegrees", 45, "VERIFIED", "IDD p15 fig 5.1.3.1-2: 45 deg typ"],
["domeSeatTrackLength", 98, "VERIFIED", "IDD p15 fig 5.1.3.1-3: 3.875 in"],
["seatTrackWidth", 28, "VERIFIED", "IDD p15-16 seat track section: 1.12 in"],
["seatTrackDepth", 11, "VERIFIED", "IDD p15-16 seat track section: .44 in"],
["maxDeployablePayloadWidth", 1123, "VERIFIED", "IDD p8: 44.2 in x 44.2 in x 50 in, 709 lb"],
["maxDeployablePayloadHeight", 1270, "VERIFIED", "IDD p8: 50 in"],
["grappleFixtureCount", 2, "VERIFIED", "IDD p4 fig 3.1-1: primary and secondary PVGF (Power and Video Grapple Fixture); Gunter's: two"],
["pvgfHousingDiameter", 600, "ASSUMED", "IDD p19 fig 5.1.4-1 drawing scale"],
["pvgfHousingHeight", 180, "ASSUMED", "IDD p19 fig 5.1.4-1 drawing scale"],
["pvgfProbeLength", 180, "ASSUMED", "IDD p19 fig 5.1.4-1 drawing scale"],
["pvgfProbeDiameter", 40, "ASSUMED", "IDD p19 fig 5.1.4-1 drawing scale"],
["pvgfPetalCount", 3, "ASSUMED", "three guides seen on the IDD p6 end view"],
["primaryPvgfAngle", 45, "ASSUMED", "IDD p5-6 end views, measured: on the dome, between forward and nadir"],
["primaryPvgfRadius", 1000, "ASSUMED", "IDD p6 side view proportion"],
["primaryPvgfStation", 1600, "ASSUMED", "IDD p6 side view proportion"],
["primaryPvgfTiltDegrees", 35, "ASSUMED", "face seen nearly head-on in the IDD p6 end view, obliquely in the side view"],
["secondaryPvgfAngle", 255, "ASSUMED", "IDD p5-6 end views, measured: on the cylinder, zenith side"],
["secondaryPvgfStation", 900, "ASSUMED", "IDD p19 fig 5.1.4-1 drawing scale"],
["goldSiteCount", 6, "VERIFIED", "IDD p9 sect 4.2: six external sites, Oceaneering GOLD-2 connectors (A to F)"],
["goldSiteFirstAngle", 342, "ASSUMED", "IDD p6 fig 3.1.3.1-1 and 3.1.4-1, site A measured"],
["goldSitePitchDegrees", 60, "ASSUMED", "IDD p6 end view: sites A to E measured 58 to 62 deg apart"],
["goldSiteStation", 1180, "ASSUMED", "IDD p19 fig 5.1.4-1 drawing scale, at the cylinder/dome joint"],
["goldConnectorSize", 250, "ASSUMED", "IDD p6 drawing proportion"],
["goldConnectorHeight", 120, "ASSUMED", "IDD p6 drawing proportion"],
["externalPayloadWidth", 610, "VERIFIED", "IDD p9 sect 4.2: 24 in x 24 in x 28 in, 500 lb"],
["externalPayloadHeight", 711, "VERIFIED", "IDD p9 sect 4.2: 28 in"],
["cylinderHandrailCount", 5, "ASSUMED", "IDD p4-6 figures show yellow EVA handrails; count and spots not given"],
["domeHandrailCount", 4, "ASSUMED", "IDD p4-6 figures; count not given"],
["antennaLength", 230, "ASSUMED", "IDD p6 WIFI whips by each PVGF, drawing proportion"],
];
function getBishop(name) = _findRow(getBishopDimensions(), name)[1];
// Rows: [name, valueMm, status, source]. Rassvet (MRM1). S132 = NASA STS-132 press kit, May 2010
// (local references/nasa/sts132PressKit.pdf), PDF pages; GCTC = Gagarin Cosmonaut Training Center
// page on MIM1 (gctc.ru main.php?id=332); RSW = RussianSpaceWeb iss_mim1.html; GSP = Gunter's Space
// Page mrm-1; WSSVP = Wikipedia "SSVP docking system" (secondary). Masses in kg, volumes in litres.
// Element frame: z = 0 on the active docking plane at Zarya's nadir port, +Z toward the passive
// nadir port. Installed, element +X points station forward and +Y starboard (see rassvet.scad).
function getRassvetDimensions() = [
["lengthBetweenDockingPlanes", 6000, "VERIFIED", "S132 p40: hull length between docking assembly planes 6.0 m; G10 p62, G15 p41 6.0 m; RSW 5.9 m; GCTC 6.5 m (basis unstated)"],
["maxDiameter", 2350, "VERIFIED", "S132 p40: maximum hull diameter 2.35 m; G10 p62"],
["pressureHullDiameter", 2200, "VERIFIED", "GCTC: max diameter 2.2 m; ru.wikipedia citing Roscosmos: 2.2 m without attached equipment"],
["massKg", 5075, "VERIFIED", "S132 p40: module launch mass 5075 kg; G10 p62"],
["launchMassKg", 8015, "VERIFIED", "S132 p40: total launch mass 8015 kg including cargo"],
["cargoMassKg", 2940, "VERIFIED", "S132 p40: 2940 kg of cargo inside and outside"],
["pressurizedVolumeLitres", 17400, "VERIFIED", "S132 p40: 17.4 m3; G10 p62; GCTC 17.5 m3"],
["habitableVolumeLitres", 5850, "VERIFIED", "S132 p40: 5.85 m3 (RSW 5.75 m3)"],
["shkMassKg", 900, "VERIFIED", "GSP: ShK airlock 900 kg, carried outside, now on Nauka"],
["rtodMassKg", 570, "VERIFIED", "GSP: RTOd radiator 570 kg, carried outside, now on Nauka"],
["eraElbowMassKg", 150, "VERIFIED", "GSP: ERA spare elbow 150 kg, carried outside"],
["workPostMassKg", 100, "VERIFIED", "GSP: ERA portable work post 100 kg, carried outside, now on Nauka"],
["externalCargoMassKg", 1720, "VERIFIED", "GSP: 1.72 t outside (RSW says 1310 kg external payload)"],
["dockingPassageDiameter", 800, "VERIFIED", "WSSVP: SSVP transfer tunnel 800 mm inside"],
["dockingHookCount", 8, "VERIFIED", "WSSVP: eight hard-capture locks on standard SSVP"],
["longeronTrunnionCount", 4, "VERIFIED", "S132 p42 drawing: four trunnion fittings; GCTC: shuttle-bay fittings remain on the hull"],
["pvgfCount", 1, "VERIFIED", "S132 p42 drawing labels one PVGF; on-orbit photo S132 (Commons 'Rassvet Canadarm Crop') shows it"],
["eraLimbLength", 5000, "VERIFIED", "ESA ERA brochure p5: limbs 5 m (the spare's stowed length below is ASSUMED)"],
["keelTrunnionCount", 1, "ASSUMED", "S132 p42 drawing shows a pin under the hull"],
["kursAntennaCount", 2, "ASSUMED", "GCTC confirms Kurs antennas, no count; one boom dish seen in 2024 photo iss072e451610"],
["dockingCollarLength", 350, "ASSUMED", "G10 p62 drawing proportion; plane to start of the end dome"],
["endDomeDepth", 650, "ASSUMED", "G10 p62 drawing; RSW: shaped like a diver's air tank"],
["shellWallThickness", 5, "ASSUMED", "aluminium-magnesium alloy hull (GCTC), thickness unsourced"],
["dockingTunnelDiameter", 1000, "ASSUMED", "800 mm passage plus walls"],
["dockingRingDiameter", 1300, "ASSUMED", "G10 p62 photo proportion"],
["dockingRingHeight", 120, "ASSUMED", "no source"],
["drogueConeDepth", 450, "ASSUMED", "no source"],
["drogueMouthDiameter", 900, "ASSUMED", "inside the tunnel, no source"],
["screenRowCount", 3, "ASSUMED", "S132 photo: three bands of screens and blankets"],
["screenPanelsAround", 8, "ASSUMED", "no source"],
["screenGap", 15, "ASSUMED", "no source"],
["radiatorPanelCount", 2, "ASSUMED", "GCTC lists radiators on the hull; G10 p62 photo shows flat white panels"],
["radiatorArcDegrees", 60, "ASSUMED", "G10 p62 photo"],
["radiatorLength", 2400, "ASSUMED", "G10 p62 photo"],
["radiatorStandoff", 30, "ASSUMED", "no source"],
["payloadBayTopAngle", 180, "ASSUMED", "which side faced the orbiter's bay doors (RTOd and ShK side) once installed; unsourced"],
["trunnionStrutStandoff", 420, "ASSUMED", "S132 p42 drawing: trunnions stand off the hull on struts"],
["trunnionNearZ", 1300, "ASSUMED", "S132 p42 drawing"],
["trunnionFarZ", 4600, "ASSUMED", "S132 p42 drawing"],
["pvgfZ", 4300, "ASSUMED", "S132 p42 drawing and Commons photo: near the nadir end"],
["pvgfAngleFromTop", 60, "ASSUMED", "S132 p42 drawing"],
["elbowStowedLength", 3900, "ASSUMED", "S132 p42 drawing: about two thirds of the hull; shorter than the 5 m flight limbs"],
["elbowLimbDiameter", 250, "ASSUMED", "no source"],
["elbowJointSize", 450, "ASSUMED", "no source"],
["elbowStandoff", 250, "ASSUMED", "no source"],
["elbowStartZ", 1100, "ASSUMED", "S132 p42 drawing"],
["elbowAngleFromTop", 235, "ASSUMED", "S132 p42 drawing: low on the side away from the PVGF"],
["kursDishDiameter", 350, "ASSUMED", "2024 photo iss072e451610 proportion"],
["kursBoomLength", 900, "ASSUMED", "2024 photo iss072e451610 proportion"],
["kursWhipLength", 450, "ASSUMED", "no source"],
["dockingTargetDiameter", 350, "ASSUMED", "S132 photo labels a docking target near the nadir end; size unsourced"],
["dockingTargetStandoff", 500, "ASSUMED", "no source"],
["tvCameraSize", 250, "ASSUMED", "RSW: TV camera added on the Jan 21 2011 spacewalk; size unsourced"],
["handrailLinesAround", 4, "ASSUMED", "GCTC confirms EVA-path handrails; layout unsourced"],
["handrailsPerLine", 5, "ASSUMED", "no source"],
];
function getRassvet(name) = _findRow(getRassvetDimensions(), name)[1];
// Rows: [name, valueMm, status, source]. STS104 = NASA STS-104 press kit (2001), local
// references/nasa/sts104PressKit.pdf, PDF pages. UMD = U. Maryland ENAE 697 "Airlocks and Suitports"
// slides (2023), local references/umd/. VI = NTRS 20160010799 (EVA Vehicle Interfaces).
// SA = NTRS 20130013500 (NASA case study, Shuttle airlock). Element frame: +Z from the passive
// berthing face (z = 0, at Unity's starboard port) out to the EVA hatch. Wall angles: see
// getQuestWallAngle() in quest.scad.
function getQuestDimensions() = [
["overallLength", 5500, "VERIFIED", "G10 p56, G15 p34: 5.5 m (18 ft); STS104 p23 same; Spaceflight Now 2000 says 20 ft"],
["equipmentLockOuterDiameter", 4000, "VERIFIED", "G10 p56 width 4.0 m; STS104 p23 diameter 4 m (13 ft)"],
["pressurizedVolumeM3", 34, "VERIFIED", "STS104 p23: 34 cubic meters"],
["equipmentLockVolumeM3", 25.7, "VERIFIED", "UMD p8: equipment lock is 25.7 m^3"],
["crewLockVolumeM3", 5.6, "VERIFIED", "UMD p8: crew lock is 5.6 m^3"],
["crewLockInnerDiameter", 1600, "ASSUMED", "STS104 p4: crew lock similar in size to the Shuttle airlock, whose ID is 63 in (SA p3)"],
["evaHatchWidth", 914, "VERIFIED", "VI p3: 36 in by 40 in D-hatch"],
["evaHatchHeight", 1016, "VERIFIED", "VI p3: 36 in by 40 in D-hatch"],
["gasTankCount", 4, "VERIFIED", "STS104 p4, p24: two oxygen, two nitrogen; G10 p56 drawing labels five (unresolved)"],
["oxygenTankCount", 2, "VERIFIED", "STS104 p4, p24"],
["nitrogenTankCount", 2, "VERIFIED", "STS104 p4, p24"],
["gasTankDiameter", 900, "VERIFIED", "STS104 p24: .9 meter (3 ft.), just over 3 feet"],
["gasTankVolumeM3", 0.42, "VERIFIED", "STS104 p24: .42 cubic meter each"],
["trunnionCount", 4, "VERIFIED", "STS104 p19: four pins that latched the airlock in the bay"],
["grappleFixtureCount", 1, "VERIFIED", "STS104 p19: the grapple fixture held by the station arm"],
["toolboxCount", 2, "VERIFIED", "G10 p56 end view: Toolbox 1, Toolbox 2"],
["rackCount", 2, "VERIFIED", "G10 p56 cutaway: Avionics Rack, Cabin Air Rack"],
["equipmentLockShellDiameter", 3800, "ASSUMED", "debris panels standing off like the nodes'"],
["inboardConeLength", 485, "ASSUMED", "G10 p56 drawing proportion"],
["inboardConeSmallDiameter", 2150, "ASSUMED", "same as the nodes"],
["equipmentLockCylinderLength",1500, "ASSUMED", "G10 p56 drawing gives about 1200; lengthened to meet UMD's 25.7 m^3 (tested)"],
["outboardConeLength", 550, "ASSUMED", "G10 p56 drawing proportion"],
["outboardBulkheadDiameter", 3200, "ASSUMED", "wide enough to carry the offset crew lock (tested)"],
["crewLockLength", 2622, "ASSUMED", "whatever the equipment lock leaves of 5.5 m; gives UMD's 5.6 m^3 (tested)"],
["crewLockShellDiameter", 1700, "ASSUMED", "the inner diameter plus a 50 mm wall"],
["crewLockOuterDiameter", 1900, "ASSUMED", "STS104 p21-22 photos: under half the equipment lock"],
["crewLockEndChamfer", 100, "ASSUMED", "G10 p56 drawing: a small bevel at the hatch end"],
["crewLockOffset", 600, "ASSUMED", "STS104 p21-22 photos and G10 p56 end view: off the equipment lock's axis"],
["crewLockOffsetAngle", 50, "ASSUMED", "G10 p56 end view (lower left, read as zenith up, looking inboard)"],
["oxygenTankAngleA", 145, "ASSUMED", "G10 p56 end view position"],
["oxygenTankAngleB", 185, "ASSUMED", "G10 p56 end view position, spread so the shields do not touch"],
["nitrogenTankAngleA", 225, "ASSUMED", "G10 p56 end view position, spread so the shields do not touch"],
["nitrogenTankAngleB", 305, "ASSUMED", "G10 p56 end view position"],
["gasTankShieldAcrossFlats", 1150, "ASSUMED", "G10 p56 end view: octagonal shield around a 0.9 m tank"],
["gasTankShieldLength", 1150, "ASSUMED", "G10 p56 end view proportion"],
["gasTankStandoff", 80, "ASSUMED", "the 'towel bar' mounts (STS104 p17), no size given"],
["trunnionAngle", 90, "ASSUMED", "a pair on each of two opposite sides, clear of the tanks"],
["grappleFixtureAngle", 20, "ASSUMED", "clear of the tanks and trunnions"],
["toolboxAngleA", 280, "ASSUMED", "G10 p56 end view, on the outboard bulkhead"],
["toolboxAngleB", 315, "ASSUMED", "G10 p56 end view, on the outboard bulkhead"],
["toolboxRadius", 1200, "ASSUMED", "clear of the crew lock (tested)"],
["toolboxLength", 600, "ASSUMED", "no source"],
["toolboxWidth", 400, "ASSUMED", "no source"],
["toolboxHeight", 300, "ASSUMED", "no source"],
["hatchThickness", 50, "ASSUMED", "no source; SA p3: the Shuttle airlock hatches are D-shaped"],
["thermalCoverThickness", 40, "ASSUMED", "Wikipedia: outward-hinged thermal cover; no size"],
["shellWallThickness", 40, "ASSUMED", "no source"],
["ringWidth", 120, "ASSUMED", "same as the nodes"],
["ringHeight", 60, "ASSUMED", "same as the nodes"],
["panelsAround", 12, "ASSUMED", "STS104 p21-22 photos"],
["crewLockPanelsAround", 8, "ASSUMED", "STS104 p21-22 photos"],
["crewLockPanelRows", 3, "ASSUMED", "STS104 p21-22 photos"],
["panelThickness", 4, "ASSUMED", "no source"],
["panelGap", 12, "ASSUMED", "no source"],
["rackRadialClearance", 120, "ASSUMED", "same as the nodes (tested)"],
];
function getQuest(name) = _findRow(getQuestDimensions(), name)[1];
// Rows: [name, valueMm, status, source]. EN = RSC Energia "Service Module Zvezda" page
// (energia.ru/en/iss/rs/zvezda.html, archived 2021-11-30); NIZ = Nauka i Zhizn No. 10, 2000,
// "Mezhdunarodnaya kosmicheskaya" (nkj.ru/archive/articles/5422); PK = NASA Zvezda press kit,
// July 2000, local references/nasa/zvezdaPressKit.pdf; RSW = RussianSpaceWeb iss_sm.html;
// WZ = Wikipedia "Zvezda (ISS module)"; WRDS = Wikipedia "Russian Docking System";
// RSU = russpace.ucoz.ru "Modul Zvezda"; SPC07 = space.com, 2007-05-30 SMDP spacewalk.
// Element frame: z = 0 on the forward (Zarya) mating face, +Z toward station aft. The axial
// stack forwardPortHeight + transferSphereDiameter + transferConeLength + workCompartmentLength
// + transferChamberLength + aftPortHeight = overallLength (test_zvezda.scad checks it).
function getZvezdaDimensions() = [
["overallLength", 13110, "VERIFIED", "EN: body length 13 110 mm; G10 p63 13.1 m; ru.wikipedia 12.62 m without fairing adapter"],
["maxDiameter", 4350, "VERIFIED", "EN: 4 350 mm; WZ 4.35 m; G10 p63 says 4.2 m (13.5 ft)"],
["wingSpan", 29730, "VERIFIED", "EN: solar array wing span 29 730 mm; G10 p63 29.7 m; PK p4 98 ft"],
["wingCellAreaSquareMeters", 38, "VERIFIED", "NIZ: two arrays of 38 m2 each; EN 76 m2 of cells in all"],
["solarWingCount", 2, "VERIFIED", "NIZ, EN, PK p8"],
["transferSphereDiameter", 2200, "VERIFIED", "NIZ: transfer compartment a 2.2 m sphere joined to a truncated cone"],
["workSmallDiameter", 2900, "VERIFIED", "NIZ: two cylinders of 2.9 and 4.1 m; capcomespace 2.9 to 4.1 m"],
["workSmallLength", 3500, "VERIFIED", "NIZ: lengths 3.5 and 2.9 m"],
["workLargeDiameter", 4100, "VERIFIED", "NIZ: 4.1 m"],
["workLargeLength", 2900, "VERIFIED", "NIZ: 2.9 m"],
["workCompartmentLength", 7700, "VERIFIED", "EN: total length of the Work Compartment 7.7 m (cone = remainder)"],
["transferChamberDiameter", 2000, "VERIFIED", "NIZ: transfer chamber a cylinder 2 m across"],
["transferChamberLength", 1340, "VERIFIED", "NIZ: 1.34 m long"],
["dockingPortCount", 4, "VERIFIED", "PK p8: aft, and forward/up/down on the sphere; RSW"],
["passageDiameter", 800, "VERIFIED", "WRDS: SSVP tunnel internal diameter 800 mm"],
["hybridLatchCount", 12, "VERIFIED", "WRDS: SSVP-M8000 hybrid uses the APAS-95 collar's 12 latches"],
["ssvpLatchCount", 8, "VERIFIED", "WRDS: standard SSVP-G4000 has 8 hard-capture locks"],
["mainEngineCount", 2, "VERIFIED", "G10 p63; NIZ; RSW (S5.79)"],
["attitudeThrusterCount", 32, "VERIFIED", "G10 p63: 6 clusters, 32 engines; NIZ; RSW"],
["attitudeClusterCount", 6, "VERIFIED", "G10 p63: 6 clusters"],
["propellantTankCount", 4, "VERIFIED", "RSW, WZ: four tanks"],
["forwardWindowCount", 3, "VERIFIED", "PK p8: three 9 in windows in the Transfer Compartment for docking"],
["forwardWindowDiameter", 229, "VERIFIED", "PK p8: 9 in; WZ 22.5 cm"],
["largeWindowDiameter", 406, "VERIFIED", "PK p8: one 16 in window in the Working Compartment; WZ 40 cm"],
["debrisPanelCount", 23, "VERIFIED", "SPC07: 23 SMDP panels when complete (final count, verify)"],
["debrisPanelWidth", 610, "VERIFIED", "SPC07: about 2 by 3 ft"],
["debrisPanelLength", 914, "VERIFIED", "SPC07: about 2 by 3 ft"],
["debrisPanelsOnCone", 12, "ASSUMED", "SPC07: five went on the work cone in 2007; the rest of this ring unsourced"],
["debrisPanelsOnSmall", 11, "ASSUMED", "the 2002 and remaining panels; placement unsourced"],
["forwardPortHeight", 350, "ASSUMED", "hybrid port neck proud of the sphere, photo proportion"],
["radialPortHeight", 350, "ASSUMED", "same neck as the forward port"],
["aftPortHeight", 350, "ASSUMED", "SSVP drogue unit proud of the transfer chamber"],
["transferConeNarrowDiameter", 2000, "ASSUMED", "NIZ confirms a truncated cone; size from photos"],
["portNeckDiameter", 1200, "ASSUMED", "800 mm passage plus structure"],
["hybridCollarDiameter", 1400, "ASSUMED", "APAS-95 style collar, no source"],
["hybridCollarHeight", 120, "ASSUMED", "no source"],
["drogueMouthDiameter", 800, "ASSUMED", "the receiving cone fills the 800 mm passage"],
["drogueDepth", 450, "ASSUMED", "no source"],
["ssvpCollarDiameter", 1300, "ASSUMED", "no source"],
["ssvpCollarHeight", 100, "ASSUMED", "no source"],
["shellWallThickness", 30, "ASSUMED", "no source"],
["radiatorPanelsAround", 8, "ASSUMED", "EN confirms radiators on both cylinders; count from photos"],
["radiatorThickness", 25, "ASSUMED", "outer face sits at maxDiameter on the large cylinder"],
["radiatorGapDegrees", 4, "ASSUMED", "no source"],
["assemblySkirtLength", 900, "ASSUMED", "Zvezda rear photo: the transfer chamber stands out of the skirt"],
["aftDiskDiameter", 2600, "ASSUMED", "Zvezda rear photo: disk carrying four thruster pods"],
["aftDiskThickness", 150, "ASSUMED", "no source"],
["propellantTankDiameter", 800, "ASSUMED", "fits the annulus between the chamber and the skirt"],
["mainEngineNozzleDiameter", 350, "ASSUMED", "no source"],
["mainEngineNozzleLength", 500, "ASSUMED", "no source"],
["aftClusterThrusters", 4, "ASSUMED", "split of the 32 among G10's 6 clusters is unsourced"],
["sideClusterThrusters", 8, "ASSUMED", "4 aft clusters x 4 + 2 side clusters x 8 = 32"],
["thrusterNozzleDiameter", 90, "ASSUMED", "no source"],
["thrusterPodSize", 300, "ASSUMED", "no source"],
["arrayAxisFromWorkStart", 2200, "ASSUMED", "STS-106 photo: wings on the small cylinder, toward its aft end"],
["wingRootStrutLength", 900, "ASSUMED", "STS-106 photo proportion"],
["wingPanelsAlong", 8, "ASSUMED", "STS-106 photo: four hinged pairs of panels each side of the boom"],
["wingPanelGap", 60, "ASSUMED", "no source"],
["wingBoomGap", 250, "ASSUMED", "STS-106 photo: strip between the two rows of panels"],
["wingPanelThickness", 30, "ASSUMED", "no source"],
["wingBoomDiameter", 120, "ASSUMED", "no source"],
["kursDishDiameter", 400, "ASSUMED", "G10 p63 labels two Kurs antennas; size from photos"],
["kursBoomLength", 1200, "ASSUMED", "no source"],
["liraDishDiameter", 1000, "ASSUMED", "G10 p63 labels a Luch satellite antenna; size from photos"],
["liraBoomLength", 1500, "ASSUMED", "no source"],
["handrailsPerRow", 3, "ASSUMED", "NIZ confirms handrails on the work compartment and sphere"],
["dockingTargetAngle", 135, "ASSUMED", "element angle of the docking target on the sphere, from the diagram"],
];
function getZvezda(name) = _findRow(getZvezdaDimensions(), name)[1];
// Rows: [name, valueMm, status, source]. GCTC = Gagarin Cosmonaut Training Center page
// "Funktsionalno-gruzovoy blok Zarya" (gctc.ru/main.php?id=322, archived 2021-10-19) and its
// numbered exterior drawing (GCTCd). Positions read off GCTCd are scaled by its 4.1 m frame
// (about 7.6 mm per pixel at 3x) and are ASSUMED. WZ = Wikipedia "Zarya (ISS module)";
// RUZ = Russian Wikipedia "Zarya (modul MKS)"; NZ = NASA Zarya page; GSP = Gunter's Space
// Page "CM (FGB, Zarya, 77KM)"; WAPAS / WRDS = Wikipedia APAS / Russian Docking System;
// N3CBM = Liddle, "Clearance Analysis of Node 3 Aft CBM to the Stowed FGB Solar Array", NTRS
// 20150003832, local references/nasa/ntrs20150003832Node3AftCbm.pdf. Element frame: z = 0 on
// the forward APAS mating face (toward PMA-1), +Z station aft; +X zenith, +Y port.
function getZaryaDimensions() = [
["overallLength", 12990, "VERIFIED", "G10 p59, G15 p38 print '12,990 m (42.6 ft)', read 12.99 m; GCTC 12.99 m; RUZ 'dlina po korpusu 12 990 mm'"],
["factSheetLength", 12560, "VERIFIED", "NZ 41.2 ft; WZ 12.56 m; GSP 12.6 m. Conflicts with 12.99 m; see aftMatingFaceZ"],
["maxDiameter", 4100, "VERIFIED", "G10 p59 4.1 m (13.5 ft); GCTC: PGO-2 frame 4.1 m; NZ 13.5 ft; WZ 4.11 m"],
["frameDiameter", 4100, "VERIFIED", "GCTC: conical bottom and conical shell of PGO-2 joined by a 4.1 m frame"],
["pgoCylinderDiameter", 2900, "VERIFIED", "GCTC: PGO-3 shell the same diameter as PGO-1, 2.9 m"],
["internalHatchDiameter", 800, "VERIFIED", "GCTC: PGO/GA bulkhead hatch 800 mm; WRDS: SSVP tunnel 800 mm"],
["pressurizedVolumeLitres", 71500, "VERIFIED", "G10 p59 71.5 m3; GCTC 71.5 m3"],
["pgoVolumeLitres", 64500, "VERIFIED", "GCTC: PGO sealed volume 64.5 m3"],
["gaVolumeLitres", 7000, "VERIFIED", "GCTC: GA volume 7.0 m3"],
["apasOuterDiameter", 1550, "VERIFIED", "WAPAS: APAS-89 outer diameter reduced from 2030 to 1550 mm"],
["apasPetalCount", 3, "VERIFIED", "WAPAS: ASA-G has '4 petals instead of 3'"],
["hybridLatchCount", 12, "VERIFIED", "WRDS: the hybrid's APAS-95 collar has twelve latches"],
["propellantTankCount", 16, "VERIFIED", "GCTC: 16 fuel tanks outside the PGO; WZ, GSP 16"],
["propellantCapacityKg", 6100, "VERIFIED", "RUZ 'do 6100 kg'; WZ 6.1 t; GSP 'more than 5.4 tons'"],
["solarWingCount", 2, "VERIFIED", "GCTC, WZ, GSP: two arrays"],
["solarWingLength", 10670, "VERIFIED", "WZ 10.67 m (35 ft); JSC Roundup 1998-12-04 '35-foot-long'; GSP 10.7 m; RUZ 10.7 m"],
["solarWingWidth", 3350, "VERIFIED", "WZ 3.35 m (11 ft); GSP 3.4 m; RUZ 3.3 m"],
["solarArraySpan", 24400, "VERIFIED", "G10 p59 24.4 m (80 ft); RUZ 24 400 mm"],
["solarCellAreaSquareMetres", 28, "VERIFIED", "G10 p59 28 m2; RUZ 'ploshchad fotoelementov 28 m2'"],
["mainEngineCount", 2, "VERIFIED", "WZ, GSP: two large engines"],
["largeThrusterCount", 24, "VERIFIED", "WZ, GSP: 24 large steering jets"],
["smallThrusterCount", 12, "VERIFIED", "WZ, GSP: 12 small steering jets"],
["dksBlockCount", 2, "VERIFIED", "GCTC: two DKS engine blocks at the cone/cylinder junction"],
["pgoDpsBlockCount", 2, "VERIFIED", "GCTC: two DPS/DTS blocks, in pairs at the junction"],
["gaDpsBlockCount", 2, "VERIFIED", "GCTC: two DPS blocks outside the GA"],
["kursAntennaGroupCount", 4, "VERIFIED", "GCTCd items 2, 8, 13, 21"],
["komparusAntennaCount", 2, "VERIFIED", "GCTCd items 9, 18: Komparus command radio antennas"],
["earthSensorCount", 2, "ASSUMED", "GCTCd item 17 draws two; the count is read off the drawing"],
["aftMatingFaceZ", 12560, "ASSUMED", "NZ's 41.2 ft read as APAS face to SSVP-M collar face, 12.99 m as to the probe tip; GCTCd scaled gives ~12.6 and ~13.1 m"],
["shellWallThickness", 40, "ASSUMED", "no source"],
["apasRingHeight", 250, "ASSUMED", "GCTCd proportion"],
["apasPetalHeight", 300, "ASSUMED", "no source"],
["apasNeckDiameter", 1300, "ASSUMED", "GCTCd proportion"],
["gaSphereDiameter", 2200, "ASSUMED", "GCTCd proportion; volume tested against GCTC's 7.0 m3"],
["gaSphereCenterZ", 1300, "ASSUMED", "GCTCd: the nadir port sits under the sphere's center"],
["gaConeStartZ", 2000, "ASSUMED", "GCTCd: short cone from the sphere to PGO-3"],
["pgoStartZ", 2800, "ASSUMED", "GCTCd: GA cone's large end meets PGO-3"],
["conicalShellStartZ", 9100, "ASSUMED", "GCTCd and STS-88 zenith photo sts088-719-059; PGO volume tested against GCTC's 64.5 m3"],
["frameZ", 10550, "ASSUMED", "GCTCd proportion"],
["conicalBottomEndZ", 12100, "ASSUMED", "GCTCd proportion"],
["conicalBottomSmallDiameter", 1800, "ASSUMED", "GCTCd proportion"],
["frameRingWidth", 150, "ASSUMED", "no source"],
["frameRingHeight", 60, "ASSUMED", "no source"],
["ssvpCollarDiameter", 1300, "ASSUMED", "WRDS gives only the 800 mm tunnel and an 80/120 cm SSPA-GM ring"],
["ssvpProbeDiameter", 110, "ASSUMED", "no source"],
["ssvpProbeHeadDiameter", 260, "ASSUMED", "no source"],
["ssvpDrogueDepth", 350, "ASSUMED", "no source"],
["nadirPortFaceRadius", 1300, "ASSUMED", "GCTCd: the passive SSVP stands just proud of the sphere"],
["zenithCoverDiameter", 1000, "ASSUMED", "GCTC: a spherical cover welded where a third port was planned; size unsourced"],
["zenithCoverProud", 120, "ASSUMED", "no source"],
["tankFairingCount", 8, "ASSUMED", "STS-88 zenith photo shows two fairings per side fore and aft; 16 tanks as 2 per fairing"],
["tankFairingFirstAngle", 45, "ASSUMED", "fairings at 45/135/225/315 deg, leaving zenith, nadir and the array sides clear (photo)"],
["tankDiameter", 520, "ASSUMED", "sized so a fairing stays inside the 4.1 m envelope (tested)"],
["tankStandoff", 20, "ASSUMED", "no source"],
["tankFairingCover", 15, "ASSUMED", "no source"],
["forwardTankStartZ", 3100, "ASSUMED", "GCTCd proportion"],
["forwardTankEndZ", 5800, "ASSUMED", "GCTCd proportion"],
["aftTankStartZ", 6200, "ASSUMED", "GCTCd proportion"],
["aftTankEndZ", 9100, "ASSUMED", "ends where the conical shell begins"],
["propellantDensityKgPerCubicMetre", 1180, "ASSUMED", "NTO/UDMH at a typical mixture ratio; not sourced for Zarya"],
["solarArrayRootZ", 5000, "ASSUMED", "GCTC: drives in two niches on PGO-3's sides; z from the STS-88 zenith photo"],
["solarDriveDiameter", 600, "ASSUMED", "no source"],
["solarPanelsPerWing", 6, "ASSUMED", "panel hinge lines counted on sts088-719-059"],
["solarMastWidth", 140, "ASSUMED", "no source"],
["solarPanelThickness", 30, "ASSUMED", "no source"],
["solarFoldAngleToday", 70, "ASSUMED", "N3CBM: retracted 2007-08 but rebounded up to 41 in outboard of the design stow; 70 deg leaves ~3.6 m"],
["dksBlockLength", 900, "ASSUMED", "no source"],
["dksBlockWidth", 700, "ASSUMED", "no source"],
["dksBlockHeight", 450, "ASSUMED", "no source"],
["mainEngineNozzleDiameter", 350, "ASSUMED", "no source"],
["dpsBlockSize", 350, "ASSUMED", "no source"],
["thrusterNozzleDiameter", 90, "ASSUMED", "no source"],
["radiatorStartZ", 6300, "ASSUMED", "ribbed bands beside the aft fairings, STS-88 zenith photo; G10 p59 labels a thermal control radiator"],
["radiatorEndZ", 9000, "ASSUMED", "no source"],
["radiatorSweepDegrees", 40, "ASSUMED", "fits between the fairings"],
["radiatorStandoff", 40, "ASSUMED", "no source"],
["radiatorRibCount", 14, "ASSUMED", "ribs counted on sts088-719-059, roughly"],
["kursMastLength", 1500, "ASSUMED", "GCTCd item 8 proportion"],
["kursMastZ", 6450, "ASSUMED", "GCTCd item 8 position"],
["komparusZenithZ", 7000, "ASSUMED", "GCTCd item 9 position"],
["komparusNadirZ", 6900, "ASSUMED", "GCTCd item 18 position"],
["earthSensorZ", 8600, "ASSUMED", "GCTCd item 17 position"],
["kursBoomLength", 900, "ASSUMED", "GCTCd items 2, 13, 21 proportion"],
["antennaDishDiameter", 300, "ASSUMED", "no source"],
["komparusMastLength", 1300, "ASSUMED", "GCTCd items 9, 18 proportion"],
["earthSensorDiameter", 260, "ASSUMED", "GCTCd item 17 proportion"],
["handrailPitch", 1500, "ASSUMED", "STS-88 press kit PDF p38 confirms rails reach the far end; layout unsourced"],
];
function getZarya(name) = _findRow(getZaryaDimensions(), name)[1];
// Rows: [name, valueMm, status, source]. Nauka (MLM). RSW = russianspaceweb.com iss_fgb2.html
// spec table; NB21 = NASA ISS blog 2021-07-21 (launch); WEN/WRU = en/ru Wikipedia "Nauka (ISS
// module)", read 2026-10-21, secondary; EOP = eoPortal "ISS: ERA". Element frame: z = 0 on the
// active hybrid port's face at Zvezda's nadir port, +Z toward station nadir; installed, element
// +X is station forward and +Y station starboard. Angles are element angles about +Z from +X.
function getNaukaDimensions() = [
["length", 13120, "VERIFIED", "WEN infobox 13.12 m; RSW 13.1 m; NB21 43 ft"],
["maxDiameter", 4250, "VERIFIED", "WEN infobox 4.25 m; WRU rounds to 4.2 m"],
["portCount", 3, "VERIFIED", "RSW: three ports (Zvezda, nadir, forward airlock); WRU infobox 3 with the hermoadapter"],
["solarArrayCount", 2, "VERIFIED", "WRU 'Компоновка': две ориентируемые панели солнечных батарей"],
["solarArraySpan", 23900, "VERIFIED", "RSW spec table: solar panels span 23.9 m"],
["eraBasePointCount", 4, "VERIFIED", "EOP: one of four base plates around the MLM (secondary)"],
["windowDiameter", 426, "VERIFIED", "WRU citing TASS 2020-04-16: иллюминатор диаметром 426 мм"],
["airlockPayloadLength", 1200, "VERIFIED", "WRU and WEN: payloads up to 1200 x 500 x 500 mm"],
["airlockPayloadWidth", 500, "VERIFIED", "WRU and WEN: 1200 x 500 x 500 mm"],
["airlockVolumeLitres", 2100, "VERIFIED", "WRU and WEN: 2.1 m3 (litres, not mm)"],
["largeSectionDiameter", 4100, "ASSUMED", "same FGB hull as Zarya (G10 p59: 4.1 m); the 4.25 m maximum includes outer panels"],
["hullPanelStandoff", 75, "ASSUMED", "chosen so the large section plus its outer panels makes the sourced 4.25 m"],
["smallSectionDiameter", 2900, "ASSUMED", "VKD-51 Cupola photo: about 0.72 of the large section"],
["sphereDiameter", 2400, "ASSUMED", "VKD-51 Cupola photo proportion"],
["aftConeLength", 700, "ASSUMED", "docked photo (NASA, Nauka docked to ISS) proportion"],
["largeSectionLength", 6000, "ASSUMED", "docked photo proportion, about 46 % of the length"],
["transitionConeLength", 300, "ASSUMED", "docked photo proportion"],
["dockingPortDepth", 350, "ASSUMED", "hybrid SSVP-M collar, no source"],
["dockingRingDiameter", 1300, "ASSUMED", "APAS-95 collar size used for the PMAs, no source"],
["radialPortStandoff", 300, "ASSUMED", "forward port collar proud of the sphere, photo"],
["shellWallThickness", 30, "ASSUMED", "no source"],
["hullPanelCount", 4, "ASSUMED", "white body panels on the large section, docked photo"],
["hullPanelWidth", 1500, "ASSUMED", "docked photo"],
["hullPanelLength", 5000, "ASSUMED", "docked photo"],
["arrayMountZ", 7700, "ASSUMED", "docked photo: arrays on the small section next to the step"],
["arrayAngle", 90, "ASSUMED", "VKD-51 Cupola photo: wings stretch port and starboard"],
["arrayChord", 3300, "ASSUMED", "no Nauka source; Zarya-type wing"],
["arrayPanelCount", 4, "ASSUMED", "docked photo"],
["arrayRootOffset", 800, "ASSUMED", "bare boom long enough for the panels to clear the 4.25 m large section"],
["windowAngle", 180, "ASSUMED", "the hermoadapter's window side is unsourced"],
["thrusterBlockCount", 4, "ASSUMED", "orange-capped thruster blocks on the small section, docked photo"],
["airlockOuterDiameter", 1500, "ASSUMED", "sized so the inside holds the sourced 2.1 m3"],
["airlockLength", 1600, "ASSUMED", "sized so the inside holds the sourced 2.1 m3"],
["airlockWallThickness", 60, "ASSUMED", "no source"],
["radiatorPanelCount", 3, "ASSUMED", "Space.com 2023 calls the RTOd a three-panel wing (search excerpt, article not read)"],
["radiatorPanelLength", 2400, "ASSUMED", "ESA blog 2023: stowed it is 'the size of a large wardrobe'"],
["radiatorPanelWidth", 1100, "ASSUMED", "no source"],
["radiatorBaseHeight", 500, "ASSUMED", "no source"],
["radiatorAngle", 0, "ASSUMED", "which side of Nauka carries the RTOd is unsourced; kept clear of ERA"],
["radiatorZ", 2600, "ASSUMED", "no source"],
["workPostZ", 9000, "ASSUMED", "where the PWP sits since VKD-60 (Aug 2023) is unsourced"],
["workPostAngle", 225, "ASSUMED", "unsourced"],
["eraBasePoint1Angle", 180, "ASSUMED", "base point placement unsourced"],
["eraBasePoint1Z", 1600, "ASSUMED", "unsourced"],
["eraBasePoint2Angle", 180, "ASSUMED", "unsourced"],
["eraBasePoint2Z", 6400, "ASSUMED", "unsourced"],
["eraBasePoint3Angle", 270, "ASSUMED", "unsourced"],
["eraBasePoint3Z", 4000, "ASSUMED", "unsourced"],
["eraBasePoint4Angle", 90, "ASSUMED", "unsourced"],
["eraBasePoint4Z", 4000, "ASSUMED", "unsourced"],
["eraHomeBasePoint", 1, "ASSUMED", "which base point ERA holds in the model"],
];
function getNauka(name) = _findRow(getNaukaDimensions(), name)[1];
// Rows: [name, valueMm, status, source]. Prichal (Node Module, UM). SFN21 = Spaceflight Now
// 2021-11-26; RSWN = russianspaceweb.com iss_node.html; WEP = en Wikipedia "Prichal (ISS module)",
// read 2026-10-21, secondary. Element frame: z = 0 on its zenith port's face at Nauka's nadir
// port, +Z toward station nadir; element +X station forward, +Y station starboard.
function getPrichalDimensions() = [
["diameter", 3300, "VERIFIED", "SFN21: about 11 ft (3.3 m); WEP infobox 3.3 m"],
["length", 4910, "VERIFIED", "WEP infobox 4.91 m (uncited there)"],
["portCount", 6, "VERIFIED", "SFN21 and RSWN: six docking ports"],
["launchFairingDiameter", 4100, "VERIFIED", "WEP: Progress M-UM flew in a 4.1 m wide fairing"],
["portCollarDiameter", 1500, "ASSUMED", "boss around each hybrid port, no source"],
];
function getPrichal(name) = _findRow(getPrichalDimensions(), name)[1];
// Rows: [port, what docks there, status, source]. Prichal's six ports as of 2026-10-21.
function getPrichalPortUse() = [
["zenith", "Nauka nadir port (active SSVP-M), 2021-11-26", "VERIFIED", "WEP dockings table; SFN21"],
["nadir", "Soyuz (MS-21, -23, -25, -27 so far); Progress M-UM tug 2021", "VERIFIED", "WEP dockings table"],
["forward", "unused, covered", "VERIFIED", "WEP dockings table"],
["aft", "unused, covered", "VERIFIED", "WEP dockings table"],
["port", "unused, covered", "VERIFIED", "WEP dockings table"],
["starboard", "unused, covered", "VERIFIED", "WEP dockings table"],
];
// Rows: [name, valueMm, status, source]. European Robotic Arm. EFS = ESA ERASMUS fact sheet
// EUC-ESA-FSH-008 rev 1.1 (local references/capcomespace/eraFactSheet.pdf; p2 is a 1:20 drawing
// dimensioned in mm); EBR = ESA ERA brochure 2021 (local references/esa/eraBrochure.pdf);
// CRU = Cruijssen et al., Dutch Space, AMS 2014, NTRS 20150004070 (local); VER = Verhoeven,
// ESMATS 1999, ERA joints (local references/esa/esmats1999VerhoevenEraJoints.pdf).
// Arm frame: end effector 1's tip on z = 0 (on its base point), straight arm along +Z.
function getEraDimensions() = [
["totalLength", 11300, "VERIFIED", "EFS p1-2: 11,300 mm; EBR p4; CRU Table 1; Airbus release 2021-07-05"],
["reach", 9700, "VERIFIED", "EFS: 9,700 mm; EBR p4. CRU Table 1 says range/span 9.2 m"],
["limbLength", 5000, "VERIFIED", "EBR p5: limbs 5 m"],
["jointCount", 7, "VERIFIED", "EBR p5; CRU p2; VER p1"],
["wristJointCount", 3, "VERIFIED", "CRU p2: each wrist is roll, yaw and pitch; elbow is one pitch joint"],
["elbowToWristPitch", 4072, "VERIFIED", "EFS p2 drawing: 4072 mm elbow axis to wrist pitch axis"],
["wristPitchToYaw", 340, "VERIFIED", "EFS p2 drawing: 340 mm"],
["wristYawToTip", 1228, "VERIFIED", "EFS p2 drawing: 1228 mm yaw axis to end effector tip"],
["endEffectorDiameter", 348, "VERIFIED", "EFS p2 drawing: 348 mm"],
["cameraCount", 4, "VERIFIED", "CRU p2: one per end effector, one on each limb by the elbow"],
["wristPitchRangeDegrees", 120, "VERIFIED", "VER p3: pitch +/-120 deg"],
["wristYawRangeDegrees", 120, "VERIFIED", "VER p3: yaw +/-120 deg"],
["wristRollRangeDegrees", 185, "VERIFIED", "VER p3: roll +/-185 deg"],
["elbowMinDegrees", -176, "VERIFIED", "VER p3: elbow +30/-176 deg"],
["elbowMaxDegrees", 30, "VERIFIED", "VER p3: elbow +30/-176 deg"],
["limbDiameter", 200, "ASSUMED", "scaled from the EFS p2 drawing"],
["jointHousingDiameter", 290, "ASSUMED", "scaled from the EFS p2 drawing"],
["rollJointFromTip", 700, "ASSUMED", "EFS p2 drawing proportion"],
["electronicsBoxSize", 400, "ASSUMED", "wrist and elbow electronics boxes, EFS p2 drawing"],
["basePointSize", 450, "ASSUMED", "CRU p6: base points are mechanically identical to grapple fixtures; size unsourced"],
["basePointHeight", 300, "ASSUMED", "unsourced"],
["workPostLength", 1400, "ASSUMED", "Portable Work Post (G10 p62), size unsourced"],
["workPostWidth", 900, "ASSUMED", "unsourced"],
];
function getEra(name) = _findRow(getEraDimensions(), name)[1];
// Rows: [name, valueMm, status, source]. Station frame: +X forward (velocity), +Y port,
// +Z zenith, origin at the center of Destiny. Which module berths to which port is
// well documented; the offsets between them are not, so every offset is ASSUMED.
function getStationLayout() = [
["berthingGap", 400, "VERIFIED", "Wikipedia CBM mated length"],
["mtsStrutSpreadDegrees", 50, "ASSUMED", "where the MTS struts leave Destiny, degrees off zenith"],
];
function getLayout(name) = _findRow(getStationLayout(), name)[1];
function getAssumedRows(table) = [for (row = table) if (row[2] == "ASSUMED") row];
function getCupola(name) = _findRow(getCupolaDimensions(), name)[1];
function getBerthing(name) = _findRow(getBerthingDimensions(), name)[1];
function _findRow(table, name) =
let(matches = [for (row = table) if (row[0] == name) row])
assert(len(matches) == 1, str("no single row named ", name))
matches[0];
function getModuleLength(name) = _findRow(getPressurizedModules(), name)[1];
function getModuleDiameter(name) = _findRow(getPressurizedModules(), name)[2];
function getTrussLength(name) = _findRow(getTrussSegments(), name)[1];
function getTrussWidth(name) = _findRow(getTrussSegments(), name)[2];
function getTrussHeight(name) = _findRow(getTrussSegments(), name)[3];
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
// ============================================================================ // detail.scad — one switch for how finely curves are drawn // Eric Moderbacher // 2026-10-21 // Every part file includes this so it renders on its own. When a part is // pulled in with `use`, the caller's $fa/$fs/$fn win (OpenSCAD keeps a used // file's top-level assignments to itself), so the entry file decides. // Flip roughDetail, or pass -D roughDetail=false, or set $fa/$fs/$fn directly. // Hexagons and hex bolt heads keep an explicit $fn = 6: that is shape, not detail. // ============================================================================ roughDetail = true; $fn = 0; $fa = roughDetail ? 24 : 3; $fs = roughDetail ? 100 : 4; // Parts that add many small members (mast diagonals, cell grids) draw them only when fine. // Reads $fs, which a caller's settings reach even through `use`. function isFineDetail() = $fs < 50;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54
// ============================================================================
// beam.scad — BEAM, the Bigelow Expandable Activity Module, assembly
// Eric Moderbacher
// 2026-10-21
// Berthed to Tranquility's aft port on 2016-04-16 and expanded on 2016-05-28
// (YR2 p5); kept as a stowage module, certified to 2028 (YR2 p23), NASA-owned
// and still attached in 2026 (Wikipedia, secondary).
// Sources (keys in config.scad): FS16, YR2, SHM, TF20, BLOG, CRS8. Packed and
// expanded length and diameter, mass, volume and the part counts are VERIFIED;
// the bulkhead sizes, soft-goods shape and thickness, the core's sizes and the
// FRGF and trunnion positions are ASSUMED and echoed.
// Element frame: z = 0 on the passive CBM's face toward Tranquility, +Z along
// the axis away from it (station aft). Once installed, element +X is station
// zenith, +Y port (getBeamWallAngle).
// ============================================================================
use <../config.scad>
use <../common/passiveCbm.scad>
use <beamLayout.scad>
use <softGoods.scad>
use <bulkheads.scad>
use <internalStructure.scad>
include <../detail.scad>
showExpanded = true;
showSoftGoods = true;
softGoodsCutawayDegrees = 0;
// Installed on Tranquility's aft port with the radial FRGF to starboard (TF20 p10 fig 12b).
function getBeamWallAngle(name) =
name == "zenith" ? 0 : name == "port" ? 90 : name == "nadir" ? 180 : name == "starboard" ? 270 :
assert(false, str("unknown wall ", name));
// Origin on an FRGF's base ("aft" on the aft face, "radial" on its bracket), +Z out along the shaft.
module placeOnBeamGrappleFixture(name, expanded = showExpanded) {
translate([0, 0, getBeamAftBulkheadZ(expanded)]) placeOnBeamAftBulkheadFrgf(name) children();
}
module beam(expanded = showExpanded, softGoodsShown = showSoftGoods, cutawayDegrees = softGoodsCutawayDegrees) {
for (row = getAssumedRows(getBeamDimensions()))
echo(str("ASSUMED: beam ", row[0], " = ", row[1], " (", row[3], ")"));
passiveCbm();
translate([0, 0, getBeamForwardBulkheadZ()]) beamForwardBulkhead();
translate([0, 0, getBeamSoftGoodsStartZ()]) {
if (softGoodsShown) beamSoftGoods(expanded, cutawayDegrees);
if (expanded) beamInternalStructure(getBeamSoftGoodsLength(true));
}
translate([0, 0, getBeamAftBulkheadZ(expanded)]) beamAftBulkhead();
}
beam();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50
// ============================================================================
// beamLayout.scad — where BEAM's parts sit along its axis, packed or expanded
// Eric Moderbacher
// 2026-10-21
// NASA gives BEAM's length with its aft FRGF (YR2 p28: 2.16 m packed, 4.01 m
// expanded), so the soft goods take whatever is left after the PCBM, the two
// bulkheads and the aft FRGF stack. The soft-goods outline is a barrel whose
// shoulders are superellipses running from each bulkhead's rim to the sourced
// diameter; that outline and every bulkhead size are ASSUMED (config.scad).
// Frame: z = 0 on the PCBM face toward Tranquility, +Z along the axis aft.
// ============================================================================
use <../config.scad>
function getBeamFrgfHeight() = getCommon("grappleBaseThickness") + getCommon("grappleShaftHeight");
function getBeamForwardBulkheadZ() = getBerthing("passiveCbmDepth");
function getBeamSoftGoodsStartZ() = getBeamForwardBulkheadZ() + getBeam("forwardBulkheadThickness");
function getBeamOverallLength(expanded) = expanded ? getBeam("expandedLength") : getBeam("packedLength");
function getBeamSoftGoodsLength(expanded) =
getBeamOverallLength(expanded) - getBeamSoftGoodsStartZ() - getBeam("aftBulkheadThickness")
- getBeam("aftFrgfStandoff") - getBeamFrgfHeight();
function getBeamAftBulkheadZ(expanded) = getBeamSoftGoodsStartZ() + getBeamSoftGoodsLength(expanded);
function getBeamAftFaceZ(expanded) = getBeamAftBulkheadZ(expanded) + getBeam("aftBulkheadThickness");
// Outer radius of the expanded soft goods at a fraction (0 forward .. 1 aft) of their length.
function computeBeamSoftGoodsRadius(fraction) =
let(exponent = getBeam("softGoodsShoulderExponent"),
endRadius = (getBeam("forwardBulkheadDiameter") * (1 - fraction)
+ getBeam("aftBulkheadDiameter") * fraction) / 2,
bulge = getBeam("expandedDiameter") / 2 - endRadius,
offCenter = abs(2 * fraction - 1))
endRadius + bulge * pow(max(0, 1 - pow(offCenter, exponent)), 1 / exponent);
function _sumList(values, index = 0) = index >= len(values) ? 0 : values[index] + _sumList(values, index + 1);
// Volume inside the soft goods' inner surface, between the bulkheads, in cubic metres.
function computeBeamInteriorVolumeM3(sliceCount = 400) =
let(length = getBeamSoftGoodsLength(true),
thickness = getBeam("softGoodsThickness"),
areas = [for (slice = [0 : sliceCount - 1])
let(radius = computeBeamSoftGoodsRadius((slice + 0.5) / sliceCount) - thickness)
PI * radius * radius])
_sumList(areas) * length / sliceCount / 1e9;
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109
// ============================================================================
// bulkheads.scad — BEAM's forward (hatch) bulkhead and aft bulkhead
// Eric Moderbacher
// 2026-10-21
// FS16 p2: two metal bulkheads. YR2 p4 and TF20 p10 fig 12a: the forward one
// carries the PCBM through a tunnel adapter, a round door, and six Flight
// Support Equipment trunnions that held BEAM in Dragon's trunk; the aft one
// carries the soft-goods attachment ring (TF20 p11) and two FRGFs, one on its
// face and one on a bracket facing outward. Counts are VERIFIED; every size and
// angle is ASSUMED (config.scad).
// Forward bulkhead: z = 0 on its forward face, +Z aft. Aft bulkhead: z = 0 on
// its forward face (where the soft goods end), +Z aft.
// ============================================================================
use <../config.scad>
use <../common/grappleFixture.scad>
include <../detail.scad>
module _plate(diameter, thickness, holeDiameter = 0) {
difference() {
cylinder(h = thickness, d = diameter);
if (holeDiameter > 0) translate([0, 0, -1]) cylinder(h = thickness + 2, d = holeDiameter);
}
}
module _door() {
diameter = getBeam("hatchDiameter");
thickness = getBeam("hatchThickness");
color("DimGray") {
cylinder(h = thickness / 2, d = diameter - 10);
for (index = [0 : 7])
rotate([0, 0, index * 45]) translate([0, -12, 0]) cube([diameter / 2 - 30, 24, thickness]);
cylinder(h = thickness * 1.4, d = diameter * 0.2);
}
}
module _fseTrunnions() {
radius = getBeam("forwardBulkheadDiameter") / 2;
length = getBeam("fseTrunnionLength");
width = getBeam("fseTrunnionWidth");
count = getBeam("fseTrunnionCount");
color("Goldenrod")
for (index = [0 : count - 1])
rotate([0, 0, getBeam("fseFirstAngle") + index * 360 / count])
translate([radius - width / 2, -width / 2, 0]) cube([length + width / 2, width, getBeam("forwardBulkheadThickness")]);
}
// The tunnel adapter reaches forward from z = 0 into the PCBM ring, which sits ahead of it.
module beamForwardBulkhead() {
thickness = getBeam("forwardBulkheadThickness");
tunnelDepth = getBerthing("passiveCbmDepth");
tunnelDiameter = getBeam("tunnelAdapterDiameter");
color("Silver") _plate(getBeam("forwardBulkheadDiameter"), thickness, getBeam("hatchDiameter"));
color("Gold") translate([0, 0, -tunnelDepth]) _plate(tunnelDiameter, tunnelDepth, tunnelDiameter - 60);
color("Gold") _plate(tunnelDiameter, thickness / 3, getBeam("hatchDiameter"));
translate([0, 0, thickness / 2]) rotate([180, 0, 0]) _door();
_fseTrunnions();
}
module _onAftFaceFrgf() {
thickness = getBeam("aftBulkheadThickness");
rotate([0, 0, getBeam("aftFrgfAngle")]) translate([getBeam("aftFrgfRadius"), 0, thickness]) children();
}
module _onRadialFrgf() {
thickness = getBeam("aftBulkheadThickness");
base = getCommon("grappleBaseSize");
radius = getBeam("aftBulkheadDiameter") / 2;
rotate([0, 0, getBeam("radialFrgfAngle")])
translate([radius - base / 2, 0, thickness + getBeam("aftFrgfStandoff") + base / 2]) rotate([0, 90, 0])
children();
}
module _frgfBrackets() {
standoff = getBeam("aftFrgfStandoff");
base = getCommon("grappleBaseSize");
radius = getBeam("aftBulkheadDiameter") / 2;
thickness = getBeam("aftBulkheadThickness");
color("Gray") {
_onAftFaceFrgf() translate([-base * 0.4, -base * 0.4, 0]) cube([base * 0.8, base * 0.8, standoff]);
rotate([0, 0, getBeam("radialFrgfAngle")])
translate([radius - base * 0.9, -base * 0.4, thickness]) cube([base * 0.4, base * 0.8, standoff + base]);
}
}
module beamAftBulkhead() {
diameter = getBeam("aftBulkheadDiameter");
thickness = getBeam("aftBulkheadThickness");
color("Silver") _plate(diameter, thickness);
color("Gray") translate([0, 0, -thickness / 2]) _plate(diameter + 40, thickness / 2, diameter - 160);
color("DarkGray") translate([0, 0, thickness]) _plate(diameter * 0.7, 15, diameter * 0.6);
_frgfBrackets();
_onAftFaceFrgf() translate([0, 0, getBeam("aftFrgfStandoff")]) grappleFixture();
_onRadialFrgf() grappleFixture();
}
function getBeamFrgfNames() = ["aft", "radial"];
// Origin on the named FRGF's base, +Z out along its grapple shaft.
module placeOnBeamAftBulkheadFrgf(name) {
if (name == "aft") _onAftFaceFrgf() translate([0, 0, getBeam("aftFrgfStandoff")]) children();
else if (name == "radial") _onRadialFrgf() children();
else assert(false, str("unknown BEAM FRGF ", name));
}
beamForwardBulkhead();
translate([0, 0, 1500]) beamAftBulkhead();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84
// ============================================================================
// internalStructure.scad — BEAM's rigid core inside the expanded soft goods
// Eric Moderbacher
// 2026-10-21
// YR2 p4 and TF20 p10 fig 12a: eight shear panels close an octagonal box off
// the aft bulkhead (SHM p4 fig 2 shows the octagon from inside), eight air tanks
// ride on it, four longerons run forward to the forward bulkhead, four ADSS
// struts run beside them, and an IMV duct carries Node 3 air to the aft end.
// Counts are VERIFIED; every size and position is ASSUMED (config.scad).
// Frame: z = 0 at the forward bulkhead's aft face, +Z aft; the aft bulkhead's
// forward face is at z = length.
// ============================================================================
use <../config.scad>
use <../common/placement.scad>
include <../detail.scad>
function _getBoxCornerRadius() =
getBeam("shearBoxAcrossFlats") / 2 / cos(180 / getBeam("shearPanelCount"));
function _getBoxOutline() =
let(count = getBeam("shearPanelCount"), radius = _getBoxCornerRadius())
[for (index = [0 : count - 1]) radius * [cos((index + 0.5) * 360 / count), sin((index + 0.5) * 360 / count)]];
module _shearBox(length) {
boxLength = getBeam("shearBoxLength");
color("LightSlateGray") translate([0, 0, length - boxLength]) linear_extrude(boxLength) difference() {
polygon(_getBoxOutline());
offset(delta = -12) polygon(_getBoxOutline());
}
}
module _airTanks(length) {
count = getBeam("airTankCount");
tankDiameter = getBeam("airTankDiameter");
tankLength = getBeam("airTankLength");
radius = getBeam("shearBoxAcrossFlats") / 2 + tankDiameter / 2;
color("Gold")
for (index = [0 : count - 1])
rotate([0, 0, index * 360 / count])
translate([radius, 0, length - getBeam("shearBoxLength") / 2 - tankLength / 2]) {
cylinder(h = tankLength, d = tankDiameter);
for (end = [0, tankLength]) translate([0, 0, end]) sphere(d = tankDiameter);
}
}
function _getStrutStart(angle) = getBeam("longeronPitchDiameter") / 2 * [cos(angle), sin(angle), 0];
module _longeronsAndStruts(length) {
count = getBeam("longeronCount");
boxFront = length - getBeam("shearBoxLength");
aftRadius = getBeam("aftBulkheadDiameter") / 2 - getBeam("adssStrutDiameter") * 2;
for (index = [0 : count - 1])
let(angle = 180 / getBeam("shearPanelCount") + index * 360 / count) {
color("DarkKhaki")
rodBetween(_getStrutStart(angle),
_getBoxCornerRadius() * [cos(angle), sin(angle), 0] + [0, 0, boxFront],
getBeam("longeronWidth"));
color("Silver")
rodBetween(_getStrutStart(angle + 8),
aftRadius * [cos(angle), sin(angle), 0] + [0, 0, length],
getBeam("adssStrutDiameter"));
}
}
module _imvDuct(length) {
angle = 540 / getBeam("shearPanelCount");
diameter = getBeam("imvDuctDiameter");
color("IndianRed")
rodBetween(0.6 * getBeam("hatchDiameter") * [cos(angle), sin(angle), 0] + [0, 0, diameter],
(_getBoxCornerRadius() + diameter) * [cos(angle), sin(angle), 0] + [0, 0, length - diameter],
diameter);
}
module beamInternalStructure(length) {
_shearBox(length);
_airTanks(length);
_longeronsAndStruts(length);
_imvDuct(length);
}
beamInternalStructure(3000);
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50
// ============================================================================
// softGoods.scad — BEAM's fabric shell, expanded or packed
// Eric Moderbacher
// 2026-10-21
// FS16 p2: layers of soft fabric over a restraint layer and bladder, no windows.
// Expanded: a barrel 3.23 m across (YR2 p28) between the two bulkheads, with
// shoulders curving into each bulkhead rim (TF20 p10 fig 12a). Packed: a drum
// 2.36 m across (YR2 p28). The shoulder curve, the shell thickness and the
// packed fold rings are ASSUMED. cutawayDegrees removes a wedge to show inside.
// Frame: z = 0 at the forward bulkhead's aft face, +Z aft along the axis.
// ============================================================================
use <../config.scad>
use <beamLayout.scad>
include <../detail.scad>
function _getProfileSteps() = isFineDetail() ? 96 : 32;
module _expandedSoftGoods(cutawayDegrees) {
length = getBeamSoftGoodsLength(true);
thickness = getBeam("softGoodsThickness");
steps = _getProfileSteps();
outer = [for (step = [0 : steps]) [computeBeamSoftGoodsRadius(step / steps), step / steps * length]];
inner = [for (step = [steps : -1 : 0]) [computeBeamSoftGoodsRadius(step / steps) - thickness, step / steps * length]];
color("Linen") rotate([0, 0, cutawayDegrees]) rotate_extrude(angle = 360 - cutawayDegrees)
polygon(concat(outer, inner));
}
module _packedSoftGoods() {
length = getBeamSoftGoodsLength(false);
radius = getBeam("packedDiameter") / 2;
foldCount = 4;
color("Linen") difference() {
cylinder(h = length, r = radius);
for (fold = [1 : foldCount - 1])
translate([0, 0, fold * length / foldCount - 10]) difference() {
cylinder(h = 20, r = radius + 1);
translate([0, 0, -1]) cylinder(h = 22, r = radius - 25);
}
}
}
module beamSoftGoods(expanded = true, cutawayDegrees = 0) {
if (expanded) _expandedSoftGoods(cutawayDegrees);
else _packedSoftGoods();
}
beamSoftGoods();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159
// ============================================================================
// bishop.scad — Nanoracks Bishop Airlock assembly
// Eric Moderbacher
// 2026-10-21
// Sources: Nanoracks IDD NR-AIRLOCK-S0009 Rev D (envelope ~70 in long, ~83 in
// across, ~1,060 kg; PCBM to Node 3's ACBM; two PVGFs; six GOLD-2 external
// sites; EVA handrails; WIFI antennas; seat track; no hatch), NASA CRS-21
// overview (1,090 kg flown in Dragon's trunk), Thales Alenia Space (pressure
// shell, MMOD shield, PVGF supports), Wikipedia (2.014 m, 3.99 m3,
// secondary). On Tranquility's outboard ACBM since December 2020; Canadarm2
// lifts it off to jettison trash or deploy payloads. ASSUMED and echoed: the
// head and dome shapes, where the PVGFs, GOLD sites and handrails sit (measured
// off the IDD figures), every PVGF and GOLD size.
// Element frame: z = 0 on the PCBM mating face, +Z outboard (station port).
// Wall angles from getBishopWallAngle(): installed, element +X is station
// forward and +Y nadir, matching Tranquility's frame, so it mounts on
// Tranquility's outboard port with no extra rotation.
// ============================================================================
use <../config.scad>
use <../common/placement.scad>
use <../common/moduleShell.scad>
use <../common/passiveCbm.scad>
use <../common/handrail.scad>
use <pressureShell.scad>
use <debrisShield.scad>
use <powerVideoGrappleFixture.scad>
use <goldConnector.scad>
include <../detail.scad>
showDebrisShield = true;
showPayloadEnvelopes = false;
// IDD p5: airlock Y points ISS zenith and Z ISS forward, so element +X (forward) is IDD Z.
function getBishopWallAngle(name) =
name == "forward" ? 0 : name == "nadir" ? 90 : name == "aft" ? 180 : name == "zenith" ? 270 :
assert(false, str("unknown wall ", name));
function getBishopGoldSiteNames() = ["A", "B", "C", "D", "E", "F"];
function getBishopGoldSiteAngle(siteName) =
let(index = search([siteName], getBishopGoldSiteNames())[0])
assert(index != [] && index < getBishop("goldSiteCount"), str("unknown GOLD site ", siteName))
(getBishop("goldSiteFirstAngle") - index * getBishop("goldSitePitchDegrees") + 360) % 360;
function _angleBetween(first, second) = let(difference = abs(first - second) % 360) min(difference, 360 - difference);
function _getMidSiteAngles() =
[for (index = [0 : getBishop("goldSiteCount") - 1])
(getBishop("goldSiteFirstAngle") - (index + 0.5) * getBishop("goldSitePitchDegrees") + 360) % 360];
function getBishopCylinderHandrailAngles() =
[for (angle = _getMidSiteAngles()) if (_angleBetween(angle, getBishop("secondaryPvgfAngle")) > 30) angle];
function getBishopDomeHandrailAngles() =
[for (angle = _getMidSiteAngles()) if (_angleBetween(angle, getBishop("primaryPvgfAngle")) > 40) angle];
// Pressurized volume of the drawn shell: PCBM bore up to the lower ring, the cylinder, the head.
function computeBishopPressurizedVolumeLiters() =
let(boreRadius = getBerthing("ringInnerDiameter") / 2,
innerRadius = getBishopShellInnerRadius(),
cylinderLength = getBishop("cylinderTopStation") - getBishop("lowerRingStation"),
headHeight = getBishopHeadHeight() - getBishop("shellWallThickness"))
(PI * pow(boreRadius, 2) * getBishop("lowerRingStation")
+ PI * pow(innerRadius, 2) * cylinderLength
+ 2 / 3 * PI * pow(innerRadius, 2) * headHeight) / 1e6;
function _getPrimaryPvgfNormal() =
let(angle = getBishop("primaryPvgfAngle"), tilt = getBishop("primaryPvgfTiltDegrees"))
[sin(tilt) * cos(angle), sin(tilt) * sin(angle), cos(tilt)];
function _getPrimaryPvgfBase() =
let(angle = getBishop("primaryPvgfAngle"), radius = getBishop("primaryPvgfRadius"))
[radius * cos(angle), radius * sin(angle), getBishop("primaryPvgfStation")];
module _primaryPvgfFrame() {
angle = getBishop("primaryPvgfAngle");
alignFrame(_getPrimaryPvgfBase(), [-sin(angle), cos(angle), 0], _getPrimaryPvgfNormal()) children();
}
module _secondaryPvgfFrame() {
surfaceFrame(getBishop("secondaryPvgfAngle"), getBishop("secondaryPvgfStation"), getBishopShieldRadius(), true)
children();
}
module _goldSiteFrame(siteName) {
surfaceFrame(getBishopGoldSiteAngle(siteName), getBishop("goldSiteStation"), getBishopShieldRadius(), true)
children();
}
// Origin on the port's mating face, +Z pointing away from Bishop. Ports: "pcbm",
// "primaryPvgf", "secondaryPvgf", and the GOLD sites "A" to "F".
module placeOnBishopPort(portName) {
if (portName == "pcbm") rotate([180, 0, 0]) children();
else if (portName == "primaryPvgf")
_primaryPvgfFrame() translate([0, 0, getBishop("pvgfHousingHeight")]) children();
else if (portName == "secondaryPvgf")
_secondaryPvgfFrame() translate([0, 0, getBishop("pvgfHousingHeight")]) children();
else _goldSiteFrame(portName) translate([0, 0, getBishop("goldConnectorHeight")]) children();
}
module _primaryPvgfSupport() {
angle = getBishop("primaryPvgfAngle");
padRadius = getBishop("primaryPvgfRadius") - getBishop("pvgfHousingDiameter") * 0.3;
pad = computeBishopDomePoint(padRadius);
color("Plum") hull() {
translate([padRadius * cos(angle), padRadius * sin(angle), pad[1] - 60])
rotate([0, 0, angle]) translate([-150, -150, 0]) cube([300, 300, 60]);
_primaryPvgfFrame() translate([0, 0, -20]) cylinder(h = 20, d = getBishop("pvgfHousingDiameter") * 0.7);
}
}
module _domeHandrails() {
outer = computeBishopDomePoint(getBishopShieldRadius() * 0.8);
inner = computeBishopDomePoint(getBishopShieldRadius() * 0.55);
normal = computeBishopDomeNormal(getBishopShieldRadius() * 0.675);
for (angle = getBishopDomeHandrailAngles())
let(radial = [cos(angle), sin(angle), 0],
start = radial * outer[0] + [0, 0, outer[1]],
end = radial * inner[0] + [0, 0, inner[1]])
alignFrame(start, end - start, radial * normal[0] + [0, 0, normal[1]]) handrail(norm(end - start));
}
module _cylinderHandrails() {
length = getCommon("handrailLength");
middle = (getBishop("skirtStation") + getBishop("shieldDomeBaseStation")) / 2;
for (angle = getBishopCylinderHandrailAngles())
surfaceFrame(angle, middle - length / 2, getBishopShieldRadius(), true) handrail(length);
}
module bishop(debrisShieldShown = showDebrisShield, payloadEnvelopesShown = showPayloadEnvelopes) {
for (row = getAssumedRows(getBishopDimensions()))
echo(str("ASSUMED: bishop ", row[0], " = ", row[1], " (", row[3], ")"));
passiveCbm();
bishopPressureShell();
if (debrisShieldShown) {
bishopDebrisShield();
_cylinderHandrails();
_domeHandrails();
}
_primaryPvgfSupport();
_primaryPvgfFrame() powerVideoGrappleFixture();
shieldGap = getBishopShieldRadius() - getBishopShellRadius();
_secondaryPvgfFrame() {
color("Plum") translate([0, 0, -shieldGap]) cylinder(h = shieldGap, d = getBishop("pvgfHousingDiameter") * 0.8);
powerVideoGrappleFixture();
}
for (siteName = [for (index = [0 : getBishop("goldSiteCount") - 1]) getBishopGoldSiteNames()[index]])
_goldSiteFrame(siteName) {
color("Plum") translate([-getBishop("goldConnectorSize") * 0.4, -getBishop("goldConnectorSize") * 0.4, -shieldGap])
cube([getBishop("goldConnectorSize") * 0.8, getBishop("goldConnectorSize") * 0.8, shieldGap]);
goldConnector(payloadEnvelopesShown);
}
}
bishop();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90
// ============================================================================
// debrisShield.scad — Bishop's MMOD shield and the stovepipe-cap skirt
// Eric Moderbacher
// 2026-10-21
// Nanoracks IDD Rev D p4-6: MMOD shielding over the shell, 83 in (2.1 m)
// across; a flat "Node 3 stovepipe cap MMOD shield" skirt at the base; the
// dome cover split into gores around a central ring. Thales Alenia Space built
// the shield (its 2020-12-04 release). ASSUMED: the dome cover as a half
// ellipsoid from where fig 5.1.4-1 shows it leaving the cylinder up to the
// 1.8 m overall length, the gore count, the skirt's size, station and
// thickness (drawing scale), skin thickness; the skirt's notches are not drawn.
// Frame: z = 0 on the PCBM mating face, +Z outboard.
// ============================================================================
use <../config.scad>
include <../detail.scad>
function getBishopShieldRadius() = getBishop("outerDiameter") / 2;
function getBishopShieldDomeHeight() = getBishop("overallLength") - getBishop("shieldDomeBaseStation");
// Point on the outer dome at a given radius, as [radius, z].
function computeBishopDomePoint(radius) =
[radius, getBishop("shieldDomeBaseStation")
+ getBishopShieldDomeHeight() * sqrt(max(0, 1 - pow(radius / getBishopShieldRadius(), 2)))];
// Outward normal of the outer dome at a given radius, in the [radial, axial] plane.
function computeBishopDomeNormal(radius) =
let(point = computeBishopDomePoint(radius),
normal = [radius / pow(getBishopShieldRadius(), 2),
(point[1] - getBishop("shieldDomeBaseStation")) / pow(getBishopShieldDomeHeight(), 2)])
normal / norm(normal);
module _shieldSolid(radius, domeHeight, bottom) {
top = getBishop("shieldDomeBaseStation");
translate([0, 0, bottom]) cylinder(h = top - bottom, r = radius);
translate([0, 0, top]) intersection() {
scale([1, 1, domeHeight / radius]) sphere(r = radius);
translate([-radius, -radius, 0]) cube([2 * radius, 2 * radius, domeHeight]);
}
}
module _skirt() {
color("LightSteelBlue") translate([0, 0, getBishop("skirtStation")]) difference() {
cylinder(h = getBishop("skirtThickness"), d = getBishop("skirtDiameter"));
translate([0, 0, -1]) cylinder(h = getBishop("skirtThickness") + 2, d = getBishop("pressureShellDiameter"));
}
}
module _goreSeams() {
radius = getBishopShieldRadius();
height = getBishopShieldDomeHeight();
crown = getBishop("crownRingDiameter") / 2;
color("DimGray") {
intersection() {
translate([0, 0, getBishop("shieldDomeBaseStation")]) difference() {
scale([1, 1, (height + 12) / (radius + 12)]) sphere(r = radius + 12);
scale([1, 1, (height - 20) / (radius - 20)]) sphere(r = radius - 20);
}
union() {
for (index = [0 : getBishop("domeGoreCount") - 1])
rotate([0, 0, index * 360 / getBishop("domeGoreCount")])
translate([crown, -15, getBishop("shieldDomeBaseStation")]) cube([radius, 30, height]);
translate([0, 0, computeBishopDomePoint(crown)[1] - 30]) difference() {
cylinder(h = 200, r = crown + 20);
translate([0, 0, -1]) cylinder(h = 202, r = crown - 20);
}
}
}
translate([0, 0, getBishop("shieldDomeBaseStation") - 15]) difference() {
cylinder(h = 30, r = radius + 10);
translate([0, 0, -1]) cylinder(h = 32, r = radius - 10);
}
}
}
module bishopDebrisShield() {
radius = getBishopShieldRadius();
skin = getBishop("shieldSkinThickness");
bottom = getBishop("skirtStation") + getBishop("skirtThickness");
color("LightSteelBlue") difference() {
_shieldSolid(radius, getBishopShieldDomeHeight(), bottom);
_shieldSolid(radius - skin, getBishopShieldDomeHeight() - skin, bottom - 1);
}
_skirt();
_goreSeams();
}
bishopDebrisShield();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
// ============================================================================
// goldConnector.scad — external payload site (Oceaneering GOLD-2 connector)
// Eric Moderbacher
// 2026-10-21
// Nanoracks IDD Rev D p9 sect 4.2: six external sites, each an Oceaneering
// General-purpose Oceaneering Latching Device (GOLD-2) giving mechanical,
// electrical and robotic interfaces; nominal payload 24 x 24 x 28 in, 500 lb.
// Drawn as a bracket, the connector block and its square mating face; sizes
// ASSUMED from drawing proportion. The optional ghost box is the payload
// envelope, oriented with its 28 in along +Z. Base on z = 0, mating face +Z.
// ============================================================================
use <../config.scad>
include <../detail.scad>
module goldConnector(envelopeShown = false) {
size = getBishop("goldConnectorSize");
height = getBishop("goldConnectorHeight");
color("Plum") translate([-size / 2, -size / 2, 0]) cube([size, size, height * 0.4]);
color("DarkGray") translate([-size * 0.35, -size * 0.35, height * 0.4]) cube([size * 0.7, size * 0.7, height * 0.5]);
color("Silver") translate([-size * 0.25, -size * 0.25, height * 0.9]) cube([size * 0.5, size * 0.5, height * 0.1]);
if (envelopeShown)
let(width = getBishop("externalPayloadWidth"))
color("PaleGreen", 0.35) translate([-width / 2, -width / 2, height])
cube([width, width, getBishop("externalPayloadHeight")]);
}
goldConnector(true);
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
// ============================================================================
// powerVideoGrappleFixture.scad — Power and Video Grapple Fixture (PVGF), rough
// Eric Moderbacher
// 2026-10-21
// Nanoracks IDD Rev D p5: Bishop's two Canadarm2 interfaces are PVGFs,
// government-furnished, on support structures Thales Alenia Space built; power
// reaches the airlock through either PVGF once it is off Node 3 (p7). Not an
// FRGF and not a PDGF. Drawn from the IDD figures: a round housing, three
// guide petals on its rim, a central probe with a knob, and a WIFI whip beside
// it. Every size is ASSUMED from drawing scale. Base on z = 0, probe along +Z.
// ============================================================================
use <../config.scad>
include <../detail.scad>
module powerVideoGrappleFixture() {
diameter = getBishop("pvgfHousingDiameter");
height = getBishop("pvgfHousingHeight");
probeLength = getBishop("pvgfProbeLength");
probeDiameter = getBishop("pvgfProbeDiameter");
petalCount = getBishop("pvgfPetalCount");
color("Gainsboro") difference() {
cylinder(h = height, d = diameter);
translate([0, 0, height * 0.4]) cylinder(h = height, d1 = diameter * 0.5, d2 = diameter * 0.85);
}
color("Silver")
for (index = [0 : petalCount - 1])
rotate([0, 0, index * 360 / petalCount])
hull() {
translate([diameter * 0.3, -diameter * 0.12, height - 1]) cube([diameter * 0.18, diameter * 0.24, 1]);
translate([diameter * 0.4, -diameter * 0.05, height + probeLength * 0.5])
cube([diameter * 0.06, diameter * 0.1, 1]);
}
color("DimGray") {
cylinder(h = height + probeLength, d = probeDiameter);
translate([0, 0, height + probeLength]) sphere(d = probeDiameter * 1.8);
translate([0, 0, height * 0.4]) cylinder(h = height * 0.3, d = diameter * 0.3);
}
color("DarkGray") translate([diameter * 0.42, 0, 0]) {
cylinder(h = height + getBishop("antennaLength"), d = 12);
translate([0, 0, height + getBishop("antennaLength")]) sphere(d = 24);
}
}
powerVideoGrappleFixture();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91
// ============================================================================
// pressureShell.scad — Bishop's pressure shell and its internal seat track
// Eric Moderbacher
// 2026-10-21
// Nanoracks IDD NR-AIRLOCK-S0009 Rev D: a bell jar with no hatch (p4), a
// three-piece bolted shell (p5) built by Thales Alenia Space, fastener planes
// at X 12.15 / 54.31 / 59.93 / 63.68 in (p19), sixteen cylinder seat tracks at
// 22.5 deg on a 37.45 in radius (p16), eight dome tracks at 45 deg on a 24 in
// crown ring (p15). Wikipedia gives the 2.014 m shell diameter. ASSUMED: the
// three pieces read as lower ring, cylinder and dome; the head drawn as a half
// ellipsoid rather than the drawing's torispherical knuckle and crown; the
// cylinder/dome joint and crown height scaled off fig 5.1.4-1; wall thickness.
// Frame: z = 0 on the PCBM mating face, +Z outboard; the shell starts at the
// X(12.15) ring and is open at the bottom into the PCBM bore.
// ============================================================================
use <../config.scad>
include <../detail.scad>
function getBishopShellRadius() = getBishop("pressureShellDiameter") / 2;
function getBishopShellInnerRadius() = getBishopShellRadius() - getBishop("shellWallThickness");
function getBishopHeadHeight() = getBishop("pressureDomeCrownStation") - getBishop("cylinderTopStation");
// Interior height of the head at a given radius (the half-ellipsoid inner surface).
function computeBishopInnerHeadZ(radius) =
let(innerRadius = getBishopShellInnerRadius(),
innerHeight = getBishopHeadHeight() - getBishop("shellWallThickness"))
getBishop("cylinderTopStation") + innerHeight * sqrt(max(0, 1 - pow(radius / innerRadius, 2)));
module _halfEllipsoid(radius, height) {
intersection() {
scale([1, 1, height / radius]) sphere(r = radius);
translate([-radius, -radius, 0]) cube([2 * radius, 2 * radius, height]);
}
}
module _bellSolid(radius, headHeight, bottom) {
top = getBishop("cylinderTopStation");
translate([0, 0, bottom]) cylinder(h = top - bottom, r = radius);
translate([0, 0, top]) _halfEllipsoid(radius, headHeight);
}
module _cylinderSeatTracks() {
count = getBishop("cylinderSeatTrackCount");
length = getBishop("cylinderSeatTrackLength");
depth = getBishop("seatTrackDepth");
width = getBishop("seatTrackWidth");
bottom = (getBishop("lowerRingStation") + getBishop("cylinderTopStation") - length) / 2;
color("DarkGray")
for (index = [0 : count - 1])
rotate([0, 0, index * getBishop("cylinderSeatTrackPitchDegrees")])
translate([getBishop("cylinderSeatTrackRadius"), -width / 2, bottom]) cube([depth, width, length]);
}
module _crownRing() {
radius = getBishop("crownRingDiameter") / 2;
width = getBishop("seatTrackWidth");
depth = getBishop("seatTrackDepth");
trackLength = getBishop("domeSeatTrackLength");
trackFace = getBishop("crownRingStation") - depth;
ringTop = computeBishopInnerHeadZ(radius + width) + 5;
color("Silver") translate([0, 0, getBishop("crownRingStation")]) difference() {
cylinder(h = ringTop - getBishop("crownRingStation"), r = radius + width);
translate([0, 0, -1]) cylinder(h = ringTop, r = radius - width);
}
color("DarkGray")
for (index = [0 : getBishop("domeSeatTrackCount") - 1])
rotate([0, 0, index * getBishop("domeSeatTrackPitchDegrees")])
translate([radius - width / 2, -trackLength / 2, trackFace]) cube([width, trackLength, depth]);
}
module bishopPressureShell() {
radius = getBishopShellRadius();
wall = getBishop("shellWallThickness");
bottom = getBishop("lowerRingStation");
color("Gainsboro") difference() {
_bellSolid(radius, getBishopHeadHeight(), bottom);
_bellSolid(radius - wall, getBishopHeadHeight() - wall, bottom - 1);
}
color("Silver") for (z = [bottom, getBishop("cylinderTopStation") - wall])
translate([0, 0, z]) difference() {
cylinder(h = 2 * wall, r = radius + wall);
translate([0, 0, -1]) cylinder(h = 2 * wall + 2, r = radius - 2 * wall);
}
_cylinderSeatTracks();
_crownRing();
}
bishopPressureShell();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64
// ============================================================================
// canadarm2.scad — Canadarm2 (SSRMS), posable
// Eric Moderbacher
// 2026-10-21
// Sources (config.scad): 17.6 m long, 35 cm booms, seven joints (roll, yaw,
// pitch at each end and a pitch elbow), an identical LEE at each end. Boom and
// joint lengths are ASSUMED, sized so the arm totals 17.6 m straight. The chain
// runs from the base LEE (gripping a PDGF) to the free LEE; jointAngles are the
// seven joints in that order: [roll, yaw, pitch, elbow, pitch, yaw, roll], degrees.
// Base LEE mouth at the origin, the straight arm extending +Z.
// ============================================================================
use <../config.scad>
use <latchingEndEffector.scad>
include <../detail.scad>
module _jointHousing(axis) {
diameter = getCanadarm("jointHousingDiameter");
spacing = getCanadarm("jointSpacing");
color("Gainsboro") translate([0, 0, spacing / 2])
rotate(axis == "x" ? [0, 90, 0] : axis == "y" ? [90, 0, 0] : [0, 0, 0])
cylinder(h = axis == "z" ? spacing : diameter * 1.1, d = diameter, center = true);
color("Silver") cylinder(h = spacing, d = getCanadarm("boomDiameter") * 1.15);
}
module _boom() {
color("WhiteSmoke") cylinder(h = getCanadarmBoomLength(), d = getCanadarm("boomDiameter"));
color("DarkGray") for (z = [0.2, 0.5, 0.8])
translate([0, 0, z * getCanadarmBoomLength()]) cylinder(h = 60, d = getCanadarm("boomDiameter") * 1.08);
}
// Each joint: draw its housing, then turn about its axis and move to the next joint.
module _wristChain(angles, axes) {
spacing = getCanadarm("jointSpacing");
if (len(angles) == 0) children();
else {
_jointHousing(axes[0]);
translate([0, 0, spacing])
rotate(axes[0] == "x" ? [angles[0], 0, 0] : axes[0] == "y" ? [0, angles[0], 0] : [0, 0, angles[0]])
_wristChain([for (i = [1 : 1 : len(angles) - 1]) angles[i]],
[for (i = [1 : 1 : len(axes) - 1]) axes[i]]) children();
}
}
module canadarm2(jointAngles = [0, 0, 0, 0, 0, 0, 0]) {
assert(len(jointAngles) == getCanadarm("jointCount"), "Canadarm2 takes seven joint angles");
boom = getCanadarmBoomLength();
rotate([180, 0, 0]) translate([0, 0, -getCanadarm("endEffectorLength")]) latchingEndEffector();
_wristChain([jointAngles[0], jointAngles[1], jointAngles[2]], ["z", "x", "y"]) {
_boom();
translate([0, 0, boom]) _wristChain([jointAngles[3]], ["y"]) {
color("Black") translate([getCanadarm("jointHousingDiameter") / 2, 0, -100]) cube([150, 120, 120]);
_boom();
translate([0, 0, boom])
_wristChain([jointAngles[4], jointAngles[5], jointAngles[6]], ["y", "x", "z"])
translate([0, 0, getCanadarm("endEffectorLength")]) rotate([180, 0, 0]) latchingEndEffector();
}
}
}
canadarm2([0, 0, -50, 100, 40, 0, 0]);
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
// ============================================================================
// latchingEndEffector.scad — Canadarm2's Latching End Effector (LEE)
// Eric Moderbacher
// 2026-10-21
// Wikipedia (Mobile Servicing System): an identical "hand" at each end; three
// snare wires inside catch a grapple fixture's shaft, and it passes power, data
// and video through a PDGF. Drawn as a cylinder with an open mouth, three snare
// wires and a camera; size and camera position are ASSUMED. Mouth at z = 0 facing
// -Z (toward the fixture it grabs), body extends +Z.
// ============================================================================
use <../config.scad>
use <../common/placement.scad>
include <../detail.scad>
module latchingEndEffector() {
length = getCanadarm("endEffectorLength");
diameter = getCanadarm("endEffectorDiameter");
color("WhiteSmoke") difference() {
cylinder(h = length, d = diameter);
translate([0, 0, -1]) cylinder(h = length * 0.45, d = diameter * 0.7);
}
color("DimGray") for (index = [0 : 2])
rotate([0, 0, index * 120])
rodBetween([diameter * 0.3, 0, length * 0.15], [-diameter * 0.15, diameter * 0.26, length * 0.15], 10);
color("Gainsboro") translate([diameter / 2 - 20, -60, length * 0.6]) cube([140, 120, 160]);
color("Black") translate([diameter / 2 + 120, 0, length * 0.68]) rotate([0, 90, 0]) cylinder(h = 10, d = 70);
}
latchingEndEffector();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96
// ============================================================================
// columbus.scad — Columbus (European laboratory) assembly
// Eric Moderbacher
// 2026-10-21
// ESA and G10 p50: 6871 mm long, 4477 mm across at most, berthed at Harmony's
// starboard port, ten ISPRs, four external payload sites on the outboard end,
// a PDGF and a trunnion pin on the outboard end. The shell comes from
// common/moduleShell.scad with a passive ring inboard and a closed outboard
// bulkhead instead of a second ring. Rack walls, payload-site and fixture
// placement are ASSUMED and echoed.
// Element frame: +Z outboard from the berthing face. Wall angles from
// getColumbusWallAngle(); installed on Harmony's starboard port they point at
// station zenith, nadir, forward and aft.
// ============================================================================
use <../config.scad>
use <../common/placement.scad>
use <../common/moduleShell.scad>
use <../common/passiveCbm.scad>
use <../common/nodeHatch.scad>
use <../common/trunnion.scad>
use <../common/handrail.scad>
use <../common/powerDataGrappleFixture.scad>
use <externalPayloadFacility.scad>
include <../detail.scad>
showDebrisShield = true;
showRacks = true;
showPayloads = true;
function getColumbusSpec() =
let(cylinder = getColumbus("overallLength") - getBerthing("passiveCbmDepth")
- getColumbus("inboardConeLength") - getColumbus("outboardConeLength"),
section = cylinder / getColumbus("cylinderSectionCount"))
makeModuleSpec(
getBerthing("passiveCbmDepth"), 0, getColumbus("overallLength"), getColumbus("inboardConeLength"),
[for (index = [1 : getColumbus("cylinderSectionCount") - 1]) section],
getColumbus("pressureShellDiameter"), getColumbus("endConeSmallDiameter"),
getColumbus("outerDiameter"), 0, 0, getColumbus("shellWallThickness"),
getColumbus("reinforcementRingWidth"), getColumbus("reinforcementRingHeight"), false, 0,
getColumbus("outboardConeLength"));
// Installed on Harmony's starboard port, the element frame's +X points station aft and +Y nadir.
function getColumbusWallAngle(name) =
name == "aft" ? 0 : name == "nadir" ? 90 : name == "forward" ? 180 : name == "zenith" ? 270 :
assert(false, str("unknown wall ", name));
function _getColumbusRackPlacements(spec) =
let(middle = (getModuleValue(spec, "coneAEnd") + getModuleValue(spec, "cylinderEnd")) / 2,
pitch = getColumbus("rackPitch"), count = getColumbus("racksPerSideWall"))
concat([for (wall = ["forward", "aft"], slot = [0 : count - 1])
[getColumbusWallAngle(wall), middle + (slot - (count - 1) / 2) * pitch]],
[for (slot = [-0.5, 0.5]) [getColumbusWallAngle("zenith"), middle + slot * pitch]]);
module _outboardBulkhead(spec) {
end = getModuleValue(spec, "overallLength");
color("DarkGray") translate([0, 0, end - 60]) cylinder(h = 60, d = getColumbus("outboardBulkheadDiameter"));
color("Gainsboro") translate([0, 0, end]) cylinder(h = 30, d = getColumbus("outboardBulkheadDiameter") * 0.8);
}
module _externalPayloadFacility(spec, withPayloads) {
outer = getModuleValue(spec, "outerRadius");
z = getModuleValue(spec, "cylinderEnd") + getColumbus("outboardConeLength") * 0.35;
for (wall = ["zenith", "nadir"], offset = [-1, 1])
let(angle = getColumbusWallAngle(wall) + offset * 25)
surfaceFrame(angle, z, outer - 200, false) rotate([0, 0, 90]) externalPayloadSite(withPayloads);
}
module columbus(debrisShieldShown = showDebrisShield, racksShown = showRacks, payloadsShown = showPayloads) {
for (row = getAssumedRows(getColumbusDimensions()))
echo(str("ASSUMED: columbus ", row[0], " = ", row[1], " (", row[3], ")"));
spec = getColumbusSpec();
outer = getModuleValue(spec, "outerRadius");
end = getModuleValue(spec, "overallLength");
moduleShell(spec);
if (debrisShieldShown)
moduleDebrisShield(spec, getColumbus("panelRowsPerBay"), getColumbus("panelsAround"),
getColumbus("panelThickness"), getColumbus("panelGap"));
passiveCbm();
translate([0, 0, getModuleValue(spec, "coneAStart") + 20]) nodeHatch();
_outboardBulkhead(spec);
_externalPayloadFacility(spec, payloadsShown);
translate([0, getColumbus("outboardBulkheadDiameter") * 0.25, end + 30]) powerDataGrappleFixture();
for (wall = ["forward", "aft"])
surfaceFrame(getColumbusWallAngle(wall), getModuleValue(spec, "cylinderEnd") - 250, outer, true) trunnion();
for (angle = [30 : 60 : 330], z = getModuleValue(spec, "ringZs"))
surfaceFrame(angle, getRingHandrailZ(spec, z), outer + 6, false) translate([-getCommon("handrailLength") / 2, 0, 0]) handrail();
if (racksShown)
moduleRacks(spec, getColumbus("rackRadialClearance"), _getColumbusRackPlacements(spec));
}
columbus();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
// ============================================================================
// externalPayloadFacility.scad — one Columbus External Payload Facility site
// Eric Moderbacher
// 2026-10-21
// ESA: four external payload sites, up to 370 kg each; the site names (SOZ,
// SOX, SDN, SDX) put two on the overhead and two on the deck side of the
// starboard end (G10 p50 drawing and photo agree). The support arm, adapter
// plate and payload box are ASSUMED shapes and sizes. Arm root at the origin,
// arm along +Z (outward from the hull), adapter face +X.
// ============================================================================
use <../config.scad>
include <../detail.scad>
module externalPayloadSite(withPayload = true) {
arm = getColumbus("payloadArmLength");
adapter = getColumbus("payloadAdapterSize");
box = getColumbus("payloadBoxSize");
color("Silver") {
translate([-60, -60, 0]) cube([120, 120, arm]);
translate([-200, -200, 0]) cube([400, 400, 60]);
}
color("LightGray") translate([0, -adapter / 2, arm - adapter / 2]) cube([80, adapter, adapter]);
if (withPayload)
color("WhiteSmoke") translate([80, -box / 2, arm - box / 2]) cube([box, box, box]);
}
externalPayloadSite();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142
// ============================================================================
// activeCbm.scad — active half of the Common Berthing Mechanism (ACBM)
// Eric Moderbacher
// 2026-10-21
// Source: NTRS 19920015843 (Illi, Boeing, 1992) figures 3, 5, 8 and 13, and the
// ehdavis.engineering CBM core design description. Verified: ring OD/ID and
// 7.5 in depth, 4 alignment guides on the 0/90/180/270 axes, 16 powered bolts,
// 4 capture latches between the guides, 4 alignment pins on the axes, 2 shear
// ties 180 degrees apart, 4 controller panels. Shapes, and the deployable debris
// cover petals seen in NASA's Node 3 render, are ASSUMED.
// Same convention as passiveCbm: berthing face at z = 0, ring extends +Z into
// the module, guides, latches and pins reach -Z toward the mating module.
// ============================================================================
use <../config.scad>
include <../detail.scad>
function _getActiveBoltRadius() =
(getBerthing("ringOuterDiameter") + getBerthing("ringInnerDiameter")) / 4;
module _activeAnnulus(outerRadius, innerRadius, height) {
difference() {
cylinder(h = height, r = outerRadius);
translate([0, 0, -1]) cylinder(h = height + 2, r = innerRadius);
}
}
module _activeRing() {
outerRadius = getBerthing("ringOuterDiameter") / 2;
innerRadius = getBerthing("ringInnerDiameter") / 2;
depth = getBerthing("activeCbmDepth");
wall = getBerthing("ringWallThickness");
flange = getBerthing("ringFlangeThickness");
color("Silver") {
_activeAnnulus(innerRadius + wall, innerRadius, depth);
_activeAnnulus(outerRadius, innerRadius, flange);
translate([0, 0, depth - flange]) _activeAnnulus(outerRadius, innerRadius, flange);
_activeAnnulus(outerRadius, outerRadius - wall / 2, depth);
}
}
module _activeGuides() {
innerRadius = getBerthing("ringInnerDiameter") / 2;
height = getBerthing("alignmentGuideHeight");
depth = getBerthing("alignmentGuideRadialDepth");
arcWidth = 2 * innerRadius * sin(getBerthing("alignmentGuideArcDegrees") / 2);
flange = getBerthing("ringFlangeThickness");
color("LightSlateGray")
for (index = [0 : getBerthing("activeGuideCount") - 1])
rotate([0, 0, index * 90]) difference() {
hull() {
translate([innerRadius - depth, -arcWidth / 2, 0]) cube([depth, arcWidth, flange]);
translate([innerRadius - depth * 0.55, -10, -height]) cube([depth * 0.3, 20, 1]);
}
translate([innerRadius - depth * 0.45, 0, -height * 0.35])
rotate([90, 0, 0]) cylinder(h = arcWidth * 2, d = height * 0.32, center = true);
}
}
module _alignmentPins() {
radius = _getActiveBoltRadius() + 30;
color("DarkGray")
for (index = [0 : 3])
rotate([0, 0, index * 90 + 4]) translate([radius, 0, -getBerthing("alignmentPinProtrusion")])
cylinder(h = getBerthing("alignmentPinProtrusion"), d = getBerthing("alignmentPinDiameter"));
}
module _poweredBolts() {
radius = _getActiveBoltRadius();
flange = getBerthing("ringFlangeThickness");
diameter = getBerthing("poweredBoltHousingDiameter");
color("DimGray")
for (index = [0 : getBerthing("boltCount") - 1])
rotate([0, 0, (index + 0.5) * 360 / getBerthing("boltCount")]) translate([radius, 0, flange]) {
cylinder(h = getBerthing("activeCbmDepth") - 2 * flange, d = diameter);
translate([0, 0, getBerthing("activeCbmDepth") - 2 * flange]) cylinder(h = 90, d = diameter * 1.3);
}
}
module _captureLatches(deployed) {
innerRadius = getBerthing("ringInnerDiameter") / 2;
arm = getBerthing("captureLatchArmLength");
color("Silver")
for (index = [0 : getBerthing("captureLatchCount") - 1])
rotate([0, 0, 45 + index * 90]) translate([innerRadius - 70, 0, 0]) {
translate([-40, -45, 0]) cube([80, 90, 120]);
rotate([0, deployed ? -15 : 0, 0]) translate([0, -15, -arm * (deployed ? 1 : 0.35)]) {
cube([24, 30, arm * (deployed ? 1 : 0.35)]);
translate([0, 0, 0]) cube([60, 30, 24]);
}
}
}
module _shearTies() {
outerRadius = getBerthing("ringOuterDiameter") / 2;
color("Gray")
for (index = [0 : getBerthing("shearTieCount") - 1])
rotate([0, 0, 112.5 + index * 180]) translate([outerRadius - 10, -60, 10]) cube([25, 120, 140]);
}
module _controllerPanels() {
innerRadius = getBerthing("ringInnerDiameter") / 2;
color("DarkSlateGray")
for (index = [0 : getBerthing("controllerPanelCount") - 1])
rotate([0, 0, 22.5 + index * 90])
translate([innerRadius - 60, -110, getBerthing("activeCbmDepth") + 20]) cube([60, 220, 160]);
}
// Hinged on a straight chord across the flange so the petal swings about a real line.
module _coverPetal(openAngle) {
outerRadius = getBerthing("ringOuterDiameter") / 2;
innerRadius = getBerthing("ringInnerDiameter") / 2;
hingeX = (outerRadius + innerRadius) / 2;
hingeHalfWidth = sqrt(pow(outerRadius, 2) - pow(hingeX, 2));
tipX = hingeX - getBerthing("coverPetalLength");
outline = [[hingeX, -hingeHalfWidth], [hingeX, hingeHalfWidth],
[tipX, hingeHalfWidth * 0.35], [tipX, -hingeHalfWidth * 0.35]];
color("Gainsboro")
translate([hingeX, 0, -6]) rotate([0, -openAngle, 0]) translate([-hingeX, 0, 0])
translate([0, 0, -8]) linear_extrude(8) polygon(outline);
color("DimGray") translate([hingeX, 0, -10]) rotate([90, 0, 0])
cylinder(h = 2 * hingeHalfWidth * 0.8, d = 30, center = true);
}
module activeCbm(coverOpenAngle = 120, latchesDeployed = false) {
_activeRing();
_activeGuides();
_alignmentPins();
_poweredBolts();
_captureLatches(latchesDeployed);
_shearTies();
_controllerPanels();
for (index = [0 : getBerthing("coverPetalCount") - 1])
rotate([0, 0, 45 + index * 360 / getBerthing("coverPetalCount")]) _coverPetal(coverOpenAngle);
}
activeCbm();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74
// ============================================================================
// europeanNode.scad — the shared build of Harmony (Node 2) and Tranquility (Node 3)
// Eric Moderbacher
// 2026-10-21
// NASA Node 3 Description p7: primary structure identical to Node 2. ESA "What is
// Node 2": the half toward the station (the passive berthing end) holds the eight
// racks; the other half carries the end-cone port and the four radial ports.
// So: passive ring at z = 0, rack bay, port bay, active ring outboard, active rings
// on all four radial ports, a hatch behind every port. Handrail and trunnion
// placement is ASSUMED. Each node passes its own numbers and adds its own parts.
// ============================================================================
use <../config.scad>
use <moduleShell.scad>
use <passiveCbm.scad>
use <activeCbm.scad>
use <trunnion.scad>
use <handrail.scad>
include <../detail.scad>
// The rack bay is the leading section; the radial ports sit in the last (outboard) one.
function makeEuropeanNodeSpec(overallLength, endConeLength, portBayLength, shellDiameter, coneSmallDiameter,
outerDiameter, portFaceProud, bossDiameter, wallThickness, ringWidth, ringHeight) =
let(cylinder = overallLength - getBerthing("passiveCbmDepth") - getBerthing("activeCbmDepth") - 2 * endConeLength)
makeModuleSpec(getBerthing("passiveCbmDepth"), getBerthing("activeCbmDepth"), overallLength, endConeLength,
[cylinder - portBayLength], shellDiameter, coneSmallDiameter, outerDiameter, portFaceProud,
bossDiameter, wallThickness, ringWidth, ringHeight, true, -1);
function getEuropeanNodeRackCenters(spec) =
let(middle = (getModuleValue(spec, "coneAEnd") + getModuleValue(spec, "firstSectionEnd")) / 2,
width = getCommon("rackWidth"))
[for (side = [-1, 1]) middle + side * (width / 2 + 10)];
module _europeanNodeHandrails(spec) {
outer = getModuleValue(spec, "outerRadius");
railLength = getCommon("handrailLength");
for (angle = [15 : 30 : 345]) {
surfaceFrame(angle, getModuleValue(spec, "coneAEnd") + 120, outer, false)
translate([-railLength / 2, 0, 0]) handrail();
surfaceFrame(angle, getModuleValue(spec, "cylinderEnd") - 120, outer, false)
translate([-railLength / 2, 0, 0]) handrail();
}
for (angle = [45 : 90 : 315],
z = [getModuleValue(spec, "coneAEnd") + 600, getModuleValue(spec, "firstSectionEnd") - 900])
surfaceFrame(angle, z, outer, true) handrail();
}
module _europeanNodeTrunnions(spec) {
outer = getModuleValue(spec, "outerRadius");
for (angle = [45, 225], z = [getModuleValue(spec, "coneAEnd") + 250, getModuleValue(spec, "cylinderEnd") - 250])
surfaceFrame(angle, z, outer, true) trunnion();
surfaceFrame(315, (getModuleValue(spec, "coneAEnd") + getModuleValue(spec, "firstSectionEnd")) / 2, outer, true)
trunnion();
}
module europeanNode(spec, coverOpenAngle, debrisShieldShown, racksShown, panelRowsPerBay, panelsAround,
panelThickness, panelGap, rackClearance) {
moduleShell(spec);
if (debrisShieldShown)
moduleDebrisShield(spec, panelRowsPerBay, panelsAround, panelThickness, panelGap);
passiveCbm();
translate([0, 0, getModuleValue(spec, "overallLength")]) rotate([180, 0, 0]) activeCbm(coverOpenAngle);
for (angle = [0 : 90 : 270]) placeOnNodeRadialPortFace(spec, angle) activeCbm(coverOpenAngle);
moduleHatches(spec);
_europeanNodeHandrails(spec);
_europeanNodeTrunnions(spec);
if (racksShown)
moduleRacks(spec, rackClearance, getWallRackPlacements(getEuropeanNodeRackCenters(spec)));
}
europeanNode(makeEuropeanNodeSpec(6706, 600, 2500, 4300, 2150, 4480, 30, 2250, 40, 120, 60),
120, true, true, 2, 12, 4, 12, 120);
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
// ============================================================================
// grappleFixture.scad — Flight Releasable Grapple Fixture (FRGF)
// Eric Moderbacher
// 2026-10-21
// A Flight Releasable Grapple Fixture, shared by every module that carries one.
// Shape follows the usual FRGF layout (square base, tapered grapple shaft, cam arm);
// every size and the mounting positions are ASSUMED. Base on z = 0, shaft +Z.
// ============================================================================
use <../config.scad>
include <../detail.scad>
module grappleFixture() {
base = getCommon("grappleBaseSize");
baseThickness = getCommon("grappleBaseThickness");
shaftDiameter = getCommon("grappleShaftDiameter");
shaftHeight = getCommon("grappleShaftHeight");
camArm = getCommon("grappleCamArmLength");
color("LightGray") translate([-base / 2, -base / 2, 0]) cube([base, base, baseThickness]);
color("DimGray")
for (x = [-1, 1], y = [-1, 1])
translate([x * base * 0.4, y * base * 0.4, baseThickness]) cylinder(h = 8, d = 16, $fn = 6);
color("Silver") {
translate([0, 0, baseThickness]) cylinder(h = shaftHeight * 0.15, d = shaftDiameter * 1.8);
translate([0, 0, baseThickness]) cylinder(h = shaftHeight, d1 = shaftDiameter, d2 = shaftDiameter * 0.75);
}
color("DarkGray") translate([0, 0, baseThickness + shaftHeight]) {
translate([-camArm / 2, -shaftDiameter * 0.2, -shaftDiameter * 0.2])
cube([camArm, shaftDiameter * 0.4, shaftDiameter * 0.4]);
sphere(d = shaftDiameter * 0.9);
}
}
grappleFixture();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
// ============================================================================
// grappleTarget.scad — visual target beside a grapple fixture
// Eric Moderbacher
// 2026-10-21
// The robotic arm's camera aligns on a black plate with a white cross and a
// standoff rod. Whether the Cupola carries one beside each FRGF, and every
// size here, is ASSUMED. Plate on z = 0, rod +Z.
// ============================================================================
use <../config.scad>
include <../detail.scad>
module grappleTarget() {
diameter = getCommon("grappleTargetDiameter");
standoff = getCommon("grappleTargetStandoff");
color("Black") cylinder(h = 6, d = diameter);
color("White") translate([0, 0, 6]) {
cube([diameter * 0.8, diameter * 0.06, 2], center = true);
cube([diameter * 0.06, diameter * 0.8, 2], center = true);
}
color("Black") cylinder(h = standoff, d = 18);
color("White") translate([0, 0, standoff]) cylinder(h = 20, d = 40);
}
grappleTarget();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
// ============================================================================
// handrail.scad — EVA handrail: a rectangular bar on two standoffs
// Eric Moderbacher
// 2026-10-21
// Parametric in length so one file serves every rail. Cross-section,
// standoff height and color are ASSUMED. Lies along +X from the origin,
// standoff feet on z = 0, bar above.
// ============================================================================
use <../config.scad>
include <../detail.scad>
module handrail(length = getCommon("handrailLength")) {
width = getCommon("handrailWidth");
depth = getCommon("handrailDepth");
standoff = getCommon("handrailStandoff");
color("Gold") {
hull() {
translate([depth / 2, 0, standoff]) rotate([90, 0, 0]) cylinder(h = width, d = depth, center = true);
translate([length - depth / 2, 0, standoff]) rotate([90, 0, 0])
cylinder(h = width, d = depth, center = true);
}
for (x = [depth / 2, length - depth / 2]) {
translate([x, 0, 0]) cylinder(h = standoff, d = width);
translate([x, 0, 0]) cylinder(h = 6, d = width * 2.2);
}
}
}
handrail();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200
// ============================================================================
// moduleShell.scad — pressure shell and debris shield for US-built modules
// Eric Moderbacher
// 2026-10-21
// The nodes and Destiny share one construction (NASA Node 3 Description p2, p7;
// 2010 guide p47 and p52): a berthing ring at each end, an end cone, one or more
// cylindrical sections joined by reinforcement rings, the other end cone, and
// debris panels standing off the shell. Nodes add four radial port bosses at the
// middle of their first section. A module is described by a spec built with
// makeModuleSpec(); every module here reads its numbers from it.
// Element frame: +Z along the axis from one berthing face (z = 0) to the other
// (z = overallLength); radial ports, when present, at element angles 0/90/180/270.
// ============================================================================
use <../config.scad>
use <placement.scad>
use <nodeHatch.scad>
use <standardRack.scad>
include <../detail.scad>
function _computeRunningEnds(start, lengths, index = 0) =
index >= len(lengths) ? []
: concat([start + lengths[index]], _computeRunningEnds(start + lengths[index], lengths, index + 1));
// leadingSectionLengths: every cylindrical section but the last; the last takes the rest.
// portSectionIndex: which section carries the radial ports at its middle (-1 = the last).
// endConeLength, endConeSmallDiameter: the far end's cone when it differs (-1 = same as the near end).
function makeModuleSpec(startRingDepth, endRingDepth, overallLength, coneLength, leadingSectionLengths,
shellDiameter, coneSmallDiameter, outerDiameter, portFaceProud,
bossDiameter, wallThickness, ringWidth, ringHeight, radialPorts, portSectionIndex = 0,
endConeLength = -1, endConeSmallDiameter = -1) =
let(coneBLength = endConeLength < 0 ? coneLength : endConeLength,
coneBSmallDiameter = endConeSmallDiameter < 0 ? coneSmallDiameter : endConeSmallDiameter,
coneAStart = startRingDepth,
coneAEnd = coneAStart + coneLength,
coneBEnd = overallLength - endRingDepth,
cylinderEnd = coneBEnd - coneBLength,
leadingEnds = _computeRunningEnds(coneAEnd, leadingSectionLengths),
firstSectionEnd = len(leadingEnds) > 0 ? leadingEnds[0] : cylinderEnd,
ringZs = concat([coneAEnd], leadingEnds, [cylinderEnd]),
portSection = portSectionIndex < 0 ? len(ringZs) - 2 : portSectionIndex)
assert(cylinderEnd > ringZs[len(ringZs) - 2], "module too short for its cones and sections")
[["overallLength", overallLength], ["coneAStart", coneAStart], ["coneAEnd", coneAEnd],
["firstSectionEnd", firstSectionEnd], ["cylinderEnd", cylinderEnd], ["coneBEnd", coneBEnd],
["ringZs", ringZs], ["radialPorts", radialPorts],
["shellRadius", shellDiameter / 2], ["smallRadius", coneSmallDiameter / 2],
["smallRadiusB", coneBSmallDiameter / 2],
["outerRadius", outerDiameter / 2], ["portZ", (ringZs[portSection] + ringZs[portSection + 1]) / 2],
["portFaceRadius", outerDiameter / 2 + portFaceProud], ["bossDiameter", bossDiameter],
["wallThickness", wallThickness], ["ringWidth", ringWidth], ["ringHeight", ringHeight]];
function getModuleValue(spec, key) =
let(matches = [for (row = spec) if (row[0] == key) row[1]])
assert(len(matches) == 1, str("module spec has no ", key))
matches[0];
// Origin on the radial port's berthing face, +Z pointing into the node (CBM convention).
module placeOnNodeRadialPortFace(spec, angle) {
rotate([0, 0, angle])
translate([getModuleValue(spec, "portFaceRadius"), 0, getModuleValue(spec, "portZ")])
rotate([0, -90, 0]) children();
}
// Origin on the radial port's berthing face, +Z pointing away (for the module berthed there).
module placeOnNodeRadialPortOutward(spec, angle) {
rotate([0, 0, angle])
translate([getModuleValue(spec, "portFaceRadius"), 0, getModuleValue(spec, "portZ")])
rotate([0, 90, 0]) children();
}
module _shellSolid(spec, radiusOffset, zPad) {
shell = getModuleValue(spec, "shellRadius") + radiusOffset;
small = getModuleValue(spec, "smallRadius") + radiusOffset;
smallB = getModuleValue(spec, "smallRadiusB") + radiusOffset;
coneAStart = getModuleValue(spec, "coneAStart");
coneAEnd = getModuleValue(spec, "coneAEnd");
cylinderEnd = getModuleValue(spec, "cylinderEnd");
coneBEnd = getModuleValue(spec, "coneBEnd");
translate([0, 0, coneAStart - zPad]) cylinder(h = coneAEnd - coneAStart + zPad + 0.01, r1 = small, r2 = shell);
translate([0, 0, coneAEnd]) cylinder(h = cylinderEnd - coneAEnd, r = shell);
translate([0, 0, cylinderEnd - 0.01]) cylinder(h = coneBEnd - cylinderEnd + zPad + 0.01, r1 = shell, r2 = smallB);
}
module _portCylinders(spec, diameter, length) {
if (getModuleValue(spec, "radialPorts"))
for (angle = [0 : 90 : 270])
rotate([0, 0, angle]) translate([0, 0, getModuleValue(spec, "portZ")]) rotate([0, 90, 0])
cylinder(h = length, d = diameter);
}
module moduleShell(spec) {
wall = getModuleValue(spec, "wallThickness");
shell = getModuleValue(spec, "shellRadius");
ringWidth = getModuleValue(spec, "ringWidth");
ringHeight = getModuleValue(spec, "ringHeight");
color("DarkGray") difference() {
union() {
_shellSolid(spec, 0, 0);
_portCylinders(spec, getModuleValue(spec, "bossDiameter"),
getModuleValue(spec, "portFaceRadius") - getBerthing("activeCbmDepth"));
}
_shellSolid(spec, -wall, 1);
_portCylinders(spec, getBerthing("ringInnerDiameter"), getModuleValue(spec, "portFaceRadius") + 10);
}
color("Gray")
for (z = getModuleValue(spec, "ringZs"))
translate([0, 0, z - ringWidth / 2]) difference() {
cylinder(h = ringWidth, r = shell + ringHeight);
translate([0, 0, -1]) cylinder(h = ringWidth + 2, r = shell - 1);
}
}
module _cylinderPanel(radius, thickness, startZ, length, startAngle, sweep) {
rotate([0, 0, startAngle]) rotate_extrude(angle = sweep)
translate([radius - thickness, startZ]) square([thickness, length]);
}
module _conePanel(startZ, endZ, startRadius, endRadius, thickness, startAngle, sweep) {
rotate([0, 0, startAngle]) rotate_extrude(angle = sweep)
polygon([[startRadius - thickness, startZ], [startRadius, startZ],
[endRadius, endZ], [endRadius - thickness, endZ]]);
}
function getModuleBays(spec) =
let(ringZs = getModuleValue(spec, "ringZs"))
[for (index = [0 : len(ringZs) - 2]) if (ringZs[index + 1] - ringZs[index] > 1) [ringZs[index], ringZs[index + 1]]];
function getModulePanelCount(spec, rowsPerBay, around) = len(getModuleBays(spec)) * rowsPerBay * around + 2 * around;
// raisedInset > 0 adds a raised inner plate on every cylinder panel (Unity's look).
// holes: extra [angle, z, diameter] openings, e.g. a window.
module moduleDebrisShield(spec, rowsPerBay, around, panelThickness, gap, raisedInset = 0, holes = []) {
outer = getModuleValue(spec, "outerRadius");
standoff = outer - getModuleValue(spec, "shellRadius");
small = getModuleValue(spec, "smallRadius") + standoff;
smallB = getModuleValue(spec, "smallRadiusB") + standoff;
sweep = 360 / around - gap / outer * 180 / PI;
offsetAngle = 180 / around;
color("Gainsboro") difference() {
union() {
for (bay = getModuleBays(spec), row = [0 : rowsPerBay - 1], column = [0 : around - 1])
let(rowLength = (bay[1] - bay[0]) / rowsPerBay,
startZ = bay[0] + row * rowLength + gap / 2, startAngle = column * 360 / around + offsetAngle) {
_cylinderPanel(outer, panelThickness, startZ, rowLength - gap, startAngle, sweep);
if (raisedInset > 0)
let(insetAngle = raisedInset / outer * 180 / PI)
_cylinderPanel(outer + 12, 12, startZ + raisedInset, rowLength - gap - 2 * raisedInset,
startAngle + insetAngle, sweep - 2 * insetAngle);
}
for (column = [0 : around - 1]) {
_conePanel(getModuleValue(spec, "coneAStart") + 40, getModuleValue(spec, "coneAEnd") - gap / 2,
small, outer, panelThickness, column * 360 / around + offsetAngle, sweep);
_conePanel(getModuleValue(spec, "cylinderEnd") + gap / 2, getModuleValue(spec, "coneBEnd") - 40,
outer, smallB, panelThickness, column * 360 / around + offsetAngle, sweep);
}
}
_portCylinders(spec, getModuleValue(spec, "bossDiameter") + 60, getModuleValue(spec, "portFaceRadius") + 100);
for (hole = holes)
rotate([0, 0, hole[0]]) translate([0, 0, hole[1]]) rotate([0, 90, 0])
cylinder(h = outer + 200, d = hole[2]);
}
}
// Where a ring of handrails sits beside reinforcement ring z: just past it, except at the last
// ring, where past it would be the cone, so just before it.
function getRingHandrailZ(spec, z) = z < getModuleValue(spec, "cylinderEnd") - 1 ? z + 200 : z - 200;
// Frame on the outer surface: origin at radius and z, +Z outward, +X along the axis or around it.
module surfaceFrame(angle, z, radius, alongAxis) {
radial = [cos(angle), sin(angle), 0];
tangent = [-sin(angle), cos(angle), 0];
alignFrame(radial * radius + [0, 0, z], alongAxis ? [0, 0, 1] : tangent, radial) children();
}
// Racks on the walls: each placement is [angle, z]; width along the axis, height around it.
module moduleRacks(spec, rackClearance, placements) {
rackRadius = getModuleValue(spec, "shellRadius") - getModuleValue(spec, "wallThickness")
- rackClearance - getCommon("rackDepth");
for (placement = placements)
let(angle = placement[0], radial = [cos(angle), sin(angle), 0], tangent = [-sin(angle), cos(angle), 0])
alignFrame(radial * rackRadius + [0, 0, placement[1]], [0, 0, 1], tangent) standardRack();
}
function getWallRackPlacements(rackCenters) = [for (angle = [0 : 90 : 270], z = rackCenters) [angle, z]];
// A hatch inside each axial ring, and behind each radial port when the module has them.
module moduleHatches(spec) {
if (getModuleValue(spec, "radialPorts"))
for (angle = [0 : 90 : 270])
rotate([0, 0, angle])
translate([getModuleValue(spec, "shellRadius") - 30, 0, getModuleValue(spec, "portZ")])
rotate([0, 90, 0]) nodeHatch();
translate([0, 0, getModuleValue(spec, "coneAStart") + 20]) nodeHatch();
translate([0, 0, getModuleValue(spec, "coneBEnd") - 20]) rotate([180, 0, 0]) nodeHatch();
}
moduleShell(makeModuleSpec(343, 191, 6706, 600, [2500], 4300, 2150, 4480, 30, 2250, 40, 120, 60, true));
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52
// ============================================================================
// nodeHatch.scad — the square US-segment hatch used at node and lab ports
// Eric Moderbacher
// 2026-10-21
// NASA (G10) gives a 50 in hatch width. The rounded-square door, frame, radial
// ribs with a central hub, and small window follow NASA's Node 3 render; all
// sizes other than the clear width are ASSUMED. Centered on the origin in the
// XY plane; the door's flat face is on z = 0 and its ribs rise +Z.
// ============================================================================
use <../config.scad>
include <../detail.scad>
module _roundedSquare(size, cornerRadius) {
offset(r = cornerRadius) square(size - 2 * cornerRadius, center = true);
}
module nodeHatch() {
clear = getCommon("hatchClearWidth");
frame = getCommon("hatchFrameWidth");
corner = getCommon("hatchCornerRadius");
thickness = getCommon("hatchThickness");
ribCount = getCommon("hatchRibCount");
window = getCommon("hatchWindowDiameter");
color("Silver") translate([0, 0, -thickness / 2]) linear_extrude(thickness * 1.6) difference() {
_roundedSquare(clear + 2 * frame, corner + frame);
_roundedSquare(clear, corner);
}
color("LightGray") difference() {
linear_extrude(thickness / 3) _roundedSquare(clear - 10, corner);
translate([0, clear * 0.3, -1]) cylinder(h = thickness, d = window);
}
color("Gainsboro") intersection() {
linear_extrude(thickness) _roundedSquare(clear - 40, corner);
union() {
for (index = [0 : ribCount - 1])
rotate([0, 0, index * 360 / ribCount])
translate([0, -10, 0]) cube([clear, 20, thickness]);
difference() {
cylinder(h = thickness, d = clear * 0.85);
translate([0, 0, -1]) cylinder(h = thickness + 2, d = clear * 0.85 - 40);
}
}
}
color("DimGray") cylinder(h = thickness * 1.3, d = 220);
color("LightSteelBlue", 0.5) translate([0, clear * 0.3, thickness / 6]) cylinder(h = 6, d = window);
}
nodeHatch();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148
// ============================================================================
// passiveCbm.scad — passive half of the Common Berthing Mechanism (PCBM)
// Eric Moderbacher
// 2026-10-21
// Source: NTRS 19920015843 (Illi, Boeing, 1992) figures 4, 6 and 14, and the
// ehdavis.engineering CBM core design description. Verified: ring OD/ID/depth,
// 16 powered-bolt nuts, 8 alignment guides as 4 left/right pairs straddling
// 0/90/180/270 degrees, 4 capture latch fittings, 4 alignment sockets on the
// axes, a 3-bead seal in 8 segments, 192 ring attach bolts. Cross-sections and
// part shapes are ASSUMED from the 1992 drawings.
// Berthing face at z = 0, ring extends +Z; guides and fittings reach below it.
// ============================================================================
use <../config.scad>
include <../detail.scad>
function _getBoltCircleRadius() =
(getBerthing("ringOuterDiameter") + getBerthing("ringInnerDiameter")) / 4;
function _getNutAngle(index) = (index + 0.5) * 360 / getBerthing("boltCount");
module _annulus(outerRadius, innerRadius, height) {
difference() {
cylinder(h = height, r = outerRadius);
translate([0, 0, -1]) cylinder(h = height + 2, r = innerRadius);
}
}
module _structuralRing() {
outerRadius = getBerthing("ringOuterDiameter") / 2;
innerRadius = getBerthing("ringInnerDiameter") / 2;
depth = getBerthing("passiveCbmDepth");
wall = getBerthing("ringWallThickness");
flange = getBerthing("ringFlangeThickness");
color("Silver") difference() {
union() {
_annulus(innerRadius + wall, innerRadius, depth);
_annulus(outerRadius, innerRadius, flange);
translate([0, 0, depth - flange]) _annulus(outerRadius, innerRadius, flange);
_annulus(outerRadius, outerRadius - wall / 2, depth);
}
for (index = [0 : getBerthing("alignmentSocketCount") - 1])
rotate([0, 0, index * 90])
translate([innerRadius + wall + 20, 0, -1]) cube([40, 18, 30], center = true);
}
}
module _nutHousings() {
radius = _getBoltCircleRadius();
flange = getBerthing("ringFlangeThickness");
color("DarkGray")
for (index = [0 : getBerthing("boltCount") - 1])
rotate([0, 0, _getNutAngle(index)]) {
translate([radius, 0, flange])
cylinder(h = getBerthing("nutHousingHeight"),
d = getBerthing("nutHousingDiameter"));
translate([radius + 55, 0, -8]) cylinder(h = 8, d = 20);
}
}
module _seal() {
radius = _getBoltCircleRadius();
width = getBerthing("sealWidth");
thickness = getBerthing("sealThickness");
segmentCount = getBerthing("sealSegmentCount");
beadCount = getBerthing("sealBeadCount");
color("DimGray") translate([0, 0, -thickness]) difference() {
union() {
_annulus(radius + width / 2, radius - width / 2, thickness);
for (index = [0 : getBerthing("boltCount") - 1])
rotate([0, 0, _getNutAngle(index)]) translate([radius, 0, 0])
cylinder(h = thickness, d = width * 1.6);
for (bead = [0 : beadCount - 1])
translate([0, 0, -2])
_annulus(radius - width / 2 + (bead + 1) * width / (beadCount + 1) + 2,
radius - width / 2 + (bead + 1) * width / (beadCount + 1) - 2, 2);
}
for (index = [0 : getBerthing("boltCount") - 1])
rotate([0, 0, _getNutAngle(index)]) translate([radius, 0, -5])
cylinder(h = thickness + 10, d = 22);
for (index = [0 : segmentCount - 1])
rotate([0, 0, index * 360 / segmentCount])
translate([radius, 0, thickness / 2]) cube([width * 3, 4, thickness + 10], center = true);
}
}
module _alignmentGuide(handedness) {
innerRadius = getBerthing("ringInnerDiameter") / 2;
height = getBerthing("alignmentGuideHeight");
depth = getBerthing("alignmentGuideRadialDepth");
arcWidth = 2 * innerRadius * sin(getBerthing("alignmentGuideArcDegrees") / 2);
flange = getBerthing("ringFlangeThickness");
color("LightSlateGray") difference() {
hull() {
translate([innerRadius - depth, -arcWidth / 2, 0]) cube([depth, arcWidth, flange]);
translate([innerRadius - depth * 0.55, handedness * arcWidth * 0.3 - 10, -height])
cube([depth * 0.3, 20, 1]);
}
translate([innerRadius - depth * 0.45, 0, -height * 0.35])
rotate([90, 0, 0]) cylinder(h = arcWidth * 2, d = height * 0.32, center = true);
}
}
module _alignmentGuides() {
halfSpan = getBerthing("alignmentGuidePairSpanDegrees") / 2;
guideArc = getBerthing("alignmentGuideArcDegrees");
for (axis = [0 : 3], handedness = [-1, 1])
rotate([0, 0, axis * 90 + handedness * (halfSpan - guideArc / 2)])
_alignmentGuide(handedness);
}
module _captureFittings() {
innerRadius = getBerthing("ringInnerDiameter") / 2;
size = getBerthing("captureFittingSize");
color("Silver")
for (index = [0 : getBerthing("captureFittingCount") - 1])
rotate([0, 0, 45 + index * 90]) {
translate([innerRadius - size * 0.6, -size / 2, 0]) cube([size * 0.6, size, size * 1.4]);
translate([innerRadius - size * 0.75, -size / 3, -size * 0.35])
cube([size * 0.25, size * 2 / 3, size * 0.5]);
}
}
module _attachBolts() {
outerRadius = getBerthing("ringOuterDiameter") / 2;
count = getBerthing("ringAttachBoltCount");
color("Gray")
for (index = [0 : count - 1])
rotate([0, 0, index * 360 / count])
translate([outerRadius - 22, 0, getBerthing("passiveCbmDepth")])
cylinder(h = 6, d = 11, $fn = 6);
}
module passiveCbm() {
_structuralRing();
_nutHousings();
_seal();
_alignmentGuides();
_captureFittings();
_attachBolts();
}
passiveCbm();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
// ============================================================================
// placement.scad — small geometry helpers shared across parts
// Eric Moderbacher
// 2026-10-21
// Rods between two points, a frame built from two axes, and the rotation of a
// point about a hinge line parallel to Y (used by shutters to find where their
// drive links attach once open).
// ============================================================================
function normalize(vector) = vector / norm(vector);
module rodBetween(start, end, diameter) {
hull() {
translate(start) sphere(d = diameter);
translate(end) sphere(d = diameter);
}
}
// Local +X along xAxis, local +Z as close to zHint as possible while perpendicular to X.
module alignFrame(origin, xAxis, zHint) {
x = normalize(xAxis);
z = normalize(zHint - (zHint * x) * x);
y = cross(z, x);
multmatrix([[x[0], y[0], z[0], origin[0]],
[x[1], y[1], z[1], origin[1]],
[x[2], y[2], z[2], origin[2]],
[0, 0, 0, 1]])
children();
}
// Matches rotate([0, angle, 0]) applied about the line x = hingeX, z = hingeZ.
function rotateAboutHinge(point, hingeX, hingeZ, angle) = [
hingeX + (point[0] - hingeX) * cos(angle) + (point[2] - hingeZ) * sin(angle),
point[1],
hingeZ - (point[0] - hingeX) * sin(angle) + (point[2] - hingeZ) * cos(angle)];
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
// ============================================================================
// powerDataGrappleFixture.scad — Power Data Grapple Fixture (PDGF), rough
// Eric Moderbacher
// 2026-10-21
// A PDGF passes power and data as well as holding the arm; Tranquility's zenith
// port carries one reserved for Dextre (Wikipedia). Drawn as a box base with a
// connector face and a standard grapple shaft on top; sizes are ASSUMED.
// Base on z = 0, shaft +Z.
// ============================================================================
use <../config.scad>
use <grappleFixture.scad>
include <../detail.scad>
module powerDataGrappleFixture() {
base = getCommon("pdgfBaseSize");
height = getCommon("pdgfBaseHeight");
color("LightGray") translate([-base / 2, -base / 2, 0]) cube([base, base, height]);
color("DimGray")
for (x = [-1, 1]) translate([x * base * 0.3 - 40, -base / 2 - 10, height * 0.3]) cube([80, 12, height * 0.4]);
translate([0, 0, height]) grappleFixture();
}
powerDataGrappleFixture();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
// ============================================================================
// pressurizedModule.scad — stand-in for any module not yet modeled in detail
// Eric Moderbacher
// 2026-10-21
// A cylinder growing from a berthing point along one of
// the six station axes. Replaced module by module as real parts are built.
// ============================================================================
use <../config.scad>
include <../detail.scad>
function _getAxisRotation(direction) =
direction == "+x" ? [0, 90, 0] :
direction == "-x" ? [0, -90, 0] :
direction == "+y" ? [-90, 0, 0] :
direction == "-y" ? [90, 0, 0] :
direction == "+z" ? [0, 0, 0] :
direction == "-z" ? [180, 0, 0] :
assert(false, str("unknown direction ", direction));
module placeAlongAxis(start, direction) {
translate(start) rotate(_getAxisRotation(direction)) children();
}
module pressurizedModule(name, start, direction) {
placeAlongAxis(start, direction)
color("WhiteSmoke") cylinder(h = getModuleLength(name), d = getModuleDiameter(name));
}
pressurizedModule("Destiny", [0, 0, 0], "+x");
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
// ============================================================================
// standardRack.scad — International Standard Payload Rack (ISPR) envelope
// Eric Moderbacher
// 2026-10-21
// Height, width and depth are VERIFIED (79.3 x 41.3 x 33.8 in, Henze,
// IAC-12-E5.3.7). The straight front section, the tapered back that follows the
// hull, and the drawer face are ASSUMED. Width along X and height along Z, both
// centered; the front face is on y = 0 and the rack extends +Y.
// ============================================================================
use <../config.scad>
include <../detail.scad>
module standardRack() {
height = getCommon("rackHeight");
width = getCommon("rackWidth");
depth = getCommon("rackDepth");
frontDepth = getCommon("rackFrontDepth");
backFraction = getCommon("rackBackHeightFraction");
drawerRows = 8;
color("WhiteSmoke") hull() {
translate([-width / 2, 0, -height / 2]) cube([width, frontDepth, height]);
translate([-width * 0.35, depth - 1, -height * backFraction / 2]) cube([width * 0.7, 1, height * backFraction]);
}
color("Gainsboro")
for (row = [0 : drawerRows - 1])
translate([-width / 2 + 40, -12, -height / 2 + 40 + row * (height - 80) / drawerRows])
cube([width - 80, 12, (height - 80) / drawerRows - 14]);
color("DimGray")
for (row = [0 : drawerRows - 1], side = [-1, 1])
translate([side * (width / 2 - 90), -30, -height / 2 + 40 + (row + 0.5) * (height - 80) / drawerRows])
rotate([90, 0, 0]) cylinder(h = 20, d = 26, center = true);
}
standardRack();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
// ============================================================================
// trunnion.scad — payload-bay trunnion: a fitting block with a pin
// Eric Moderbacher
// 2026-10-21
// Shuttle-launched modules carry trunnions that held them in the payload bay;
// NASA's Node 3 render shows them as orange fittings. Every size is ASSUMED.
// Fitting base on z = 0, pin +Z.
// ============================================================================
use <../config.scad>
include <../detail.scad>
module trunnion() {
fitting = getCommon("trunnionFittingSize");
color("DarkOrange") hull() {
translate([-fitting / 2, -fitting / 2, 0]) cube([fitting, fitting, 10]);
translate([-fitting / 4, -fitting / 3, fitting * 0.45]) cube([fitting / 2, fitting * 2 / 3, 1]);
}
color("Silver") cylinder(h = fitting * 0.45 + getCommon("trunnionLength"), d = getCommon("trunnionDiameter"));
}
trunnion();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104
// ============================================================================
// cupola.scad — Cupola assembly
// Eric Moderbacher
// 2026-10-21
// Stacked from the berthing face (z = 0) up: passive CBM, skirt with debris
// bumper, dome with frames, windows and shutters. Grapple fixtures and targets
// sit on bumper panels; EVA handrails run along the dome's corner ribs and
// around the bumper. The interior (close-out panels, handrail rings, robotics
// workstation) shows through the open windows. Echoes every ASSUMED dimension
// it uses, so no guess renders silently.
// ============================================================================
use <../config.scad>
include <../detail.scad>
use <../common/placement.scad>
use <../common/passiveCbm.scad>
use <skirt.scad>
use <dome.scad>
use <window.scad>
use <shutter.scad>
use <../common/handrail.scad>
use <../common/grappleFixture.scad>
use <../common/grappleTarget.scad>
use <interior/closeoutPanels.scad>
use <interior/interiorHandrails.scad>
use <interior/roboticsWorkstation.scad>
sideShutterOpenAngle = 0;
topShutterOpenAngle = 0;
showInterior = true;
module _cornerHandrails() {
height = getCupola("domeHeight");
bottomCorner = getCircumradius(getCupola("domeBottomAcrossFlats"));
topCorner = getCircumradius(getCupola("domeTopAcrossFlats"));
lift = getCupola("edgeRibDiameter") / 2 - 20;
for (corner = [0 : 5])
rotate([0, 0, corner * 60]) {
start = [bottomCorner, 0, 0];
end = [topCorner, 0, height];
along = end - start;
outward = [along[2], 0, -along[0]];
middle = (start + end) / 2 + normalize(outward) * lift;
alignFrame(middle - normalize(along) * getCommon("handrailLength") / 2, along, outward)
handrail();
}
}
module _bumperHardware() {
count = getCupola("bumperPanelCount");
for (index = [0 : count - 1])
placeOnBumperPanel(index) {
if (index == 0 || index == count / 2) grappleFixture();
else if (index == 1 || index == count / 2 + 1)
grappleTarget();
else translate([-getCommon("handrailLength") / 2, 0, 0]) handrail();
}
}
module _interior(skirtBase) {
wallRadius = getCupola("skirtDiameter") / 2 - getCupola("skirtWallThickness");
inset = getCupola("interiorHandrailInset");
translate([0, 0, skirtBase]) closeoutPanels();
interiorHandrails(skirtBase + getCupola("skirtHeight") * 0.5, wallRadius - inset,
skirtBase + getCupola("skirtHeight") + getCupola("shoulderHeight") + 150,
wallRadius - inset * 0.6, wallRadius);
translate([0, -wallRadius + inset + 260, skirtBase + getCupola("skirtHeight") * 0.5 + 40])
roboticsWorkstation();
}
module cupola(sideOpenAngle = sideShutterOpenAngle, topOpenAngle = topShutterOpenAngle,
interior = showInterior) {
for (row = concat(getAssumedRows(getBerthingDimensions()),
getAssumedRows(getCupolaDimensions())))
echo(str("ASSUMED: ", row[0], " = ", row[1], " (", row[3], ")"));
skirtBase = getBerthing("passiveCbmDepth");
domeBase = skirtBase + getCupola("skirtHeight") + getCupola("shoulderHeight");
passiveCbm();
translate([0, 0, skirtBase]) {
cupolaSkirt();
_bumperHardware();
}
if (interior) _interior(skirtBase);
translate([0, 0, domeBase]) {
cupolaDome();
_cornerHandrails();
for (faceIndex = [0 : getCupola("sideWindowCount") - 1])
placeOnSideFace(faceIndex) {
sideWindow();
sideShutter(sideOpenAngle);
}
placeOnTopFace() {
topWindow();
topShutter(topOpenAngle);
}
}
}
cupola();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120
// ============================================================================
// dome.scad — Cupola dome: the forged window frame structure
// Eric Moderbacher
// 2026-10-21
// Sources: ESA (forged Al 2219-T851 dome welded to the skirt); Thales Alenia
// (one 1.8 t forging, six trapezoid window slots and a round viewport);
// Wikipedia (inner steel skeleton, not modeled). The shape is read off photos:
// a shoulder flaring from the skirt, a hexagonal frustum with rounded corner
// ribs, a raised boss for the top window. Every size except the top window is
// ASSUMED (see config.scad).
// placeOnSideFace(): origin at the face center on the outer surface, +Z out,
// +X down the slope, Y along the face's horizontal edges.
// placeOnTopFace(): origin on top of the boss, +Z up.
// The dome's z = 0 is the frustum base; the shoulder hangs below it.
// ============================================================================
use <../config.scad>
use <../common/placement.scad>
use <windowFrame.scad>
include <../detail.scad>
function getCircumradius(acrossFlats) = acrossFlats / 2 / cos(30);
function getSideWindowOutline() = let(
bottom = getCupola("sideWindowBottomWidth"),
top = getCupola("sideWindowTopWidth"),
slant = getCupola("sideWindowSlantHeight"))
[[slant / 2, -bottom / 2], [slant / 2, bottom / 2],
[-slant / 2, top / 2], [-slant / 2, -top / 2]];
function getTopWindowOutline() =
[for (index = [0 : 71]) getCupola("topWindowDiameter") / 2 * [cos(index * 5), sin(index * 5)]];
function getFaceTiltDegrees() = atan2(getCupola("domeHeight"),
(getCupola("domeBottomAcrossFlats") - getCupola("domeTopAcrossFlats")) / 2);
function getFaceSlantLength() = sqrt(pow(getCupola("domeHeight"), 2)
+ pow((getCupola("domeBottomAcrossFlats") - getCupola("domeTopAcrossFlats")) / 2, 2));
module placeOnSideFace(faceIndex) {
bottomInradius = getCupola("domeBottomAcrossFlats") / 2;
topInradius = getCupola("domeTopAcrossFlats") / 2;
rotate([0, 0, 30 + 60 * faceIndex])
translate([(bottomInradius + topInradius) / 2, 0, getCupola("domeHeight") / 2])
rotate([0, getFaceTiltDegrees(), 0])
children();
}
module placeOnTopFace() {
translate([0, 0, getCupola("domeHeight") + getCupola("topBossHeight")]) children();
}
module _frustum(bottomAcrossFlats, topAcrossFlats, height) {
hull() {
linear_extrude(0.01) circle(r = getCircumradius(bottomAcrossFlats), $fn = 6);
translate([0, 0, height - 0.01])
linear_extrude(0.01) circle(r = getCircumradius(topAcrossFlats), $fn = 6);
}
}
module _shoulder() {
height = getCupola("shoulderHeight");
wall = getCupola("domeWallThickness");
difference() {
hull() {
translate([0, 0, -height]) cylinder(h = 0.01, d = getCupola("skirtDiameter"));
linear_extrude(0.01) circle(r = getCircumradius(getCupola("domeBottomAcrossFlats")), $fn = 6);
}
hull() {
translate([0, 0, -height - 1]) cylinder(h = 0.01, d = getCupola("skirtDiameter") - 2 * wall);
translate([0, 0, 0.5]) linear_extrude(0.01)
circle(r = getCircumradius(getCupola("domeBottomAcrossFlats") - 2 * wall), $fn = 6);
}
}
}
module _cornerRibs() {
height = getCupola("domeHeight");
bottomCorner = getCircumradius(getCupola("domeBottomAcrossFlats"));
topCorner = getCircumradius(getCupola("domeTopAcrossFlats"));
for (corner = [0 : 5])
rotate([0, 0, corner * 60])
rodBetween([bottomCorner - 25, 0, 20], [topCorner - 25, 0, height - 20],
getCupola("edgeRibDiameter"));
}
module _domeShell() {
bottom = getCupola("domeBottomAcrossFlats");
top = getCupola("domeTopAcrossFlats");
height = getCupola("domeHeight");
wall = getCupola("domeWallThickness");
difference() {
union() {
_frustum(bottom, top, height);
_shoulder();
_cornerRibs();
translate([0, 0, height - 1])
cylinder(h = getCupola("topBossHeight") + 1, d = getCupola("topBossDiameter"));
}
translate([0, 0, -1]) _frustum(bottom - 2 * wall, top - 2 * wall, height - wall + 1);
for (faceIndex = [0 : getCupola("sideWindowCount") - 1])
placeOnSideFace(faceIndex)
linear_extrude(height = 6 * wall, center = true) polygon(getSideWindowOutline());
placeOnTopFace()
cylinder(h = 6 * (wall + getCupola("topBossHeight")),
d = getCupola("topWindowDiameter"), center = true);
}
}
module cupolaDome() {
color("WhiteSmoke") _domeShell();
for (faceIndex = [0 : getCupola("sideWindowCount") - 1])
placeOnSideFace(faceIndex) windowFrame(getSideWindowOutline());
placeOnTopFace() windowFrame(getTopWindowOutline());
}
cupolaDome();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
// ============================================================================
// closeoutPanels.scad — removable interior panels lining the skirt and shoulder
// Eric Moderbacher
// 2026-10-21
// Thales Alenia: close-out panels cover the harness and water lines and form
// an air distribution path with the outer structure; they are removable.
// Count, inset and thickness are ASSUMED. z = 0 is the skirt's base.
// ============================================================================
use <../../config.scad>
include <../../detail.scad>
module closeoutPanels() {
count = getCupola("closeoutPanelCount");
outerRadius = getCupola("skirtDiameter") / 2 - getCupola("skirtWallThickness")
- getCupola("closeoutPanelInset");
thickness = getCupola("closeoutPanelThickness");
height = getCupola("skirtHeight") + getCupola("shoulderHeight");
color("Ivory")
for (index = [0 : count - 1])
rotate([0, 0, index * 360 / count])
rotate_extrude(angle = 360 / count - 1.5)
translate([outerRadius - thickness, 0]) square([thickness, height]);
}
closeoutPanels();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
// ============================================================================
// interiorHandrails.scad — upper and lower handrail rings inside the cabin
// Eric Moderbacher
// 2026-10-21
// Thales Alenia: upper and lower handrails around the inside of the cabin
// carry most of the equipment. Heights, diameter and bracket count are
// ASSUMED. Takes the two ring heights and radii so the assembly decides
// where they sit.
// ============================================================================
use <../../config.scad>
include <../../detail.scad>
module _handrailRing(radius, wallRadius) {
diameter = getCupola("interiorHandrailDiameter");
color("Silver") {
rotate_extrude() translate([radius, 0]) circle(d = diameter);
for (index = [0 : 5])
rotate([0, 0, index * 60 + 30]) translate([radius, 0, 0]) rotate([0, 90, 0])
cylinder(h = wallRadius - radius, d = diameter * 0.7);
}
}
module interiorHandrails(lowerZ, lowerRadius, upperZ, upperRadius, wallRadius) {
translate([0, 0, lowerZ]) _handrailRing(lowerRadius, wallRadius);
translate([0, 0, upperZ]) _handrailRing(upperRadius, wallRadius);
}
interiorHandrails(0, 800, 400, 900, 960);
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
// ============================================================================
// roboticsWorkstation.scad — Canadarm2 robotics workstation, rough
// Eric Moderbacher
// 2026-10-21
// Wikipedia: the Cupola houses one of two identical robotic workstations.
// Drawn as three displays on a mounting bar with a hand controller at each
// end; layout and sizes are ASSUMED. Bar along X centered on the origin,
// displays tilted back toward +Y.
// ============================================================================
use <../../config.scad>
include <../../detail.scad>
module _handController() {
color("DimGray") {
cylinder(h = 60, d = 120);
translate([0, 0, 60]) cylinder(h = 90, d = 35);
translate([0, 0, 165]) scale([1, 1, 1.4]) sphere(d = 60);
}
}
module roboticsWorkstation() {
width = getCupola("workstationDisplayWidth");
height = getCupola("workstationDisplayHeight");
barLength = 3 * width + 2 * 220;
color("Gray") translate([-barLength / 2, -20, -20]) cube([barLength, 40, 40]);
for (index = [-1 : 1])
translate([index * (width + 20), 0, 20]) rotate([60, 0, 0]) {
color("DarkSlateGray") translate([-width / 2, 0, 0]) cube([width, 25, height]);
color("Black") translate([-width / 2 + 15, -1, 15]) cube([width - 30, 2, height - 30]);
}
for (side = [-1, 1]) translate([side * (barLength / 2 - 100), 0, 20]) _handController();
}
roboticsWorkstation();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97
// ============================================================================
// shutter.scad — a Cupola window shutter with hinge and drive link
// Eric Moderbacher
// 2026-10-21
// ESA: one manually operated shutter per window, Al 6061/7075 with Kevlar and
// Nextel debris layers; Wikipedia: a knob inside drives each through a
// mechanical linkage. Built here as a skinned panel with a perimeter rim,
// raised outer stiffeners, hinge knuckles on fixed brackets, and a drive link
// from a pivot on the dome to the panel. The link is visual: its length is
// not held constant as the shutter opens. Hinge on the +X edge (the bottom
// edge for side windows, so side shutters open like petals) is ASSUMED.
// Works for any window outline (point list) in the window's local frame.
// ============================================================================
use <../config.scad>
use <../common/placement.scad>
use <dome.scad>
include <../detail.scad>
module _shutterPanel(outline) {
thickness = getCupola("shutterThickness");
margin = getCupola("shutterMargin");
rim = getCupola("shutterRimWidth");
ribWidth = getCupola("shutterRibWidth");
ribHeight = getCupola("shutterRibHeight");
minX = min([for (point = outline) point[0]]) - margin;
maxX = max([for (point = outline) point[0]]) + margin;
minY = min([for (point = outline) point[1]]) - margin;
maxY = max([for (point = outline) point[1]]) + margin;
color("Gainsboro") {
translate([0, 0, thickness - 4]) linear_extrude(4) offset(delta = margin) polygon(outline);
linear_extrude(thickness) difference() {
offset(delta = margin) polygon(outline);
offset(delta = margin - rim) polygon(outline);
}
}
color("LightGray") translate([0, 0, thickness]) linear_extrude(ribHeight)
intersection() {
offset(delta = margin - rim) polygon(outline);
union() {
for (fraction = [0.25, 0.5, 0.75])
translate([minX + fraction * (maxX - minX) - ribWidth / 2, minY])
square([ribWidth, maxY - minY]);
translate([minX, -ribWidth / 2]) square([maxX - minX, ribWidth]);
}
}
}
module _hinge(hingeX, hingeZ, hingeSpan) {
count = getCupola("hingeKnuckleCount");
diameter = getCupola("hingeKnuckleDiameter");
length = getCupola("hingeKnuckleLength");
for (index = [0 : count - 1])
let(y = (index - (count - 1) / 2) * hingeSpan / max(1, count - 1) * 0.8) {
color("DarkGray") translate([hingeX, y, hingeZ]) rotate([90, 0, 0])
cylinder(h = length, d = diameter, center = true);
color("Silver") hull() {
translate([hingeX, y, hingeZ]) rotate([90, 0, 0])
cylinder(h = length * 0.6, d = diameter * 0.8, center = true);
translate([hingeX + diameter, y - length * 0.3, 0]) cube([diameter, length * 0.6, 1]);
}
}
}
module windowShutter(outline, openAngle) {
margin = getCupola("shutterMargin");
baseZ = getCupola("windowFrameHeight") + getCupola("shutterGap");
maxX = max([for (point = outline) point[0]]);
hingeX = maxX + margin;
hingeZ = baseZ + getCupola("shutterThickness") / 2;
hingeSpan = 2 * max([for (point = outline) if (point[0] >= maxX - 1) abs(point[1])]);
linkAttach = [hingeX - margin - (maxX - min([for (point = outline) point[0]])) * 0.45, 0, baseZ];
linkPivot = [hingeX + 70, 0, 0];
translate([hingeX, 0, hingeZ]) rotate([0, openAngle, 0]) translate([-hingeX, 0, -hingeZ])
translate([0, 0, baseZ]) _shutterPanel(outline);
_hinge(hingeX, hingeZ, hingeSpan);
color("DimGray") {
rodBetween(linkPivot, rotateAboutHinge(linkAttach, hingeX, hingeZ, openAngle),
getCupola("shutterLinkDiameter"));
translate(linkPivot) cylinder(h = 30, d = 70);
}
}
module sideShutter(openAngle = 0) {
windowShutter(getSideWindowOutline(), openAngle);
}
module topShutter(openAngle = 0) {
windowShutter(getTopWindowOutline(), openAngle);
}
sideShutter(70);
translate([0, 1600, 0]) topShutter(70);
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61
// ============================================================================
// skirt.scad — Cupola skirt: pressure cylinder plus its debris bumper
// Eric Moderbacher
// 2026-10-21
// ESA: Al 2219-T851 skirt with an aluminum bumper on the cylindrical portion;
// the passive berthing ring bolts to its lower edge. The bumper is drawn as
// flat panels on standoffs; panel count and spacing are ASSUMED.
// Sits on z = 0, extends +Z.
// ============================================================================
use <../config.scad>
include <../detail.scad>
module _pressureShell() {
diameter = getCupola("skirtDiameter");
height = getCupola("skirtHeight");
wall = getCupola("skirtWallThickness");
color("Gainsboro") difference() {
cylinder(h = height, d = diameter);
translate([0, 0, -1]) cylinder(h = height + 2, d = diameter - 2 * wall);
}
}
function getBumperRadius() = getCupola("skirtDiameter") / 2 + getCupola("bumperStandoff");
function getBumperPanelAngle(index) = (index + 0.5) * 360 / getCupola("bumperPanelCount");
module placeOnBumperPanel(index) {
rotate([0, 0, getBumperPanelAngle(index)])
translate([getBumperRadius() + getCupola("bumperThickness"), 0, getCupola("skirtHeight") / 2])
rotate([0, 90, 0]) rotate([0, 0, 90]) children();
}
module _bumper() {
count = getCupola("bumperPanelCount");
radius = getBumperRadius();
height = getCupola("skirtHeight") - 30;
thickness = getCupola("bumperThickness");
panelWidth = 2 * radius * tan(180 / count) - 12;
standoff = getCupola("bumperStandoff");
for (index = [0 : count - 1])
rotate([0, 0, getBumperPanelAngle(index)]) {
color("WhiteSmoke")
translate([radius, -panelWidth / 2, (getCupola("skirtHeight") - height) / 2])
cube([thickness, panelWidth, height]);
color("DarkGray")
for (y = [-panelWidth / 3, panelWidth / 3], z = [height * 0.25, height * 0.75])
translate([radius - standoff, y, z + 15]) rotate([0, 90, 0])
cylinder(h = standoff + thickness + 4, d = 16);
}
}
module cupolaSkirt() {
_pressureShell();
_bumper();
}
cupolaSkirt();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
// ============================================================================
// window.scad — one Cupola window's pane stack, for any outline
// Eric Moderbacher
// 2026-10-21
// One file serves the six trapezoidal side windows and the round top window:
// they differ only in outline and pressure-pane thickness. The child is the 2D
// outline. Layers, outside in: debris, pressure, pressure, scratch (Wikipedia).
// The stack's outer face sits at local z = 0 and builds inward (-z).
// ============================================================================
use <../config.scad>
use <dome.scad>
include <../detail.scad>
module windowPaneStack(pressurePaneThickness) {
thinPane = getCupola("outerInnerPaneThickness");
gap = getCupola("paneGap");
thicknesses = [thinPane, pressurePaneThickness, pressurePaneThickness, thinPane];
depths = [for (index = [0 : 3])
_computeTotal(thicknesses, index) + index * gap];
color("LightSteelBlue", 0.45)
for (index = [0 : 3])
translate([0, 0, -depths[index]])
linear_extrude(thicknesses[index]) children();
}
function _computeTotal(values, lastIndex) =
lastIndex < 0 ? 0 : values[lastIndex] + _computeTotal(values, lastIndex - 1);
module sideWindow() {
windowPaneStack(getCupola("sidePressurePaneThickness")) polygon(getSideWindowOutline());
}
module topWindow() {
windowPaneStack(getCupola("topPressurePaneThickness"))
circle(d = getCupola("topWindowDiameter"));
}
sideWindow();
translate([0, 1500, 0]) topWindow();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
// ============================================================================
// windowFrame.scad — raised retaining frame and bolt ring around a window
// Eric Moderbacher
// 2026-10-21
// Takes the window's outline as a point list (convex, any winding) so the
// trapezoidal side windows and the round top window share one part. Frame
// width, height and bolt spacing are ASSUMED. Sits on z = 0, rises +Z.
// ============================================================================
use <../config.scad>
include <../detail.scad>
function _getCentroid(points) = [for (axis = [0, 1])
_computeSum([for (point = points) point[axis]], len(points) - 1) / len(points)];
function _computeSum(values, lastIndex) =
lastIndex < 0 ? 0 : values[lastIndex] + _computeSum(values, lastIndex - 1);
function computeEdgeBoltPoints(points, spacing, outwardDistance) =
let(count = len(points), centroid = _getCentroid(points))
[for (index = [0 : count - 1])
let(start = points[index], end = points[(index + 1) % count],
length = norm(end - start), direction = (end - start) / length,
normal = [direction[1], -direction[0]],
middle = (start + end) / 2,
outward = ((middle - centroid) * normal) >= 0 ? normal : -normal,
boltCount = max(1, floor(length / spacing)))
for (bolt = [0 : boltCount - 1])
start + direction * (bolt + 0.5) * length / boltCount + outward * outwardDistance];
module windowFrame(outline) {
width = getCupola("windowFrameWidth");
height = getCupola("windowFrameHeight");
color("Silver") linear_extrude(height) difference() {
offset(delta = width) polygon(outline);
polygon(outline);
}
color("DimGray")
for (point = computeEdgeBoltPoints(outline, getCupola("windowBoltSpacing"), width / 2))
translate([point[0], point[1], height])
cylinder(h = 6, d = getCupola("windowBoltDiameter"), $fn = 6);
}
windowFrame([[280, -440], [280, 440], [-280, 220], [-280, -220]]);
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123
// ============================================================================
// destiny.scad — Destiny (US Laboratory) assembly
// Eric Moderbacher
// 2026-10-21
// G10 p49 and Wikipedia: aluminum, three cylindrical sections and two end
// cones, passive berthing ring aft (to Unity) and active ring forward (to
// Harmony), 24 rack locations (six per wall) around four corner utility
// standoffs, one 50.9 cm science window with a shutter in an open rack bay,
// a PDGF (Canadarm2's first base), and the Lab Cradle on the aft and mid rings
// that holds the S0 truss. Shell and debris panels come from
// common/moduleShell.scad. Which wall the window faces, and every position not
// listed above, is ASSUMED.
// Element frame: +Z from the aft face to the forward face. Once installed,
// element +X is nadir, +Y port, +Z station forward.
// ============================================================================
use <../config.scad>
use <../common/placement.scad>
use <../common/moduleShell.scad>
use <../common/passiveCbm.scad>
use <../common/activeCbm.scad>
use <../common/trunnion.scad>
use <../common/handrail.scad>
use <../common/grappleFixture.scad>
use <../common/powerDataGrappleFixture.scad>
use <scienceWindow.scad>
use <labCradle.scad>
include <../detail.scad>
showDebrisShield = true;
showRacks = true;
windowShutterOpenAngle = 0;
forwardCoverOpenAngle = 120;
function getDestinySpec() =
let(cylinderLength = getDestiny("overallLength") - getBerthing("passiveCbmDepth")
- getBerthing("activeCbmDepth") - 2 * getDestiny("endConeLength"),
section = cylinderLength / getDestiny("cylinderSectionCount"))
makeModuleSpec(
getBerthing("passiveCbmDepth"), getBerthing("activeCbmDepth"),
getDestiny("overallLength"), getDestiny("endConeLength"),
[for (index = [1 : getDestiny("cylinderSectionCount") - 1]) section],
getDestiny("pressureShellDiameter"), getDestiny("endConeSmallDiameter"),
getDestiny("outerDiameter"), 0, 0, getDestiny("shellWallThickness"),
getDestiny("reinforcementRingWidth"), getDestiny("reinforcementRingHeight"), false);
// Element angles once installed (element +X is nadir, +Y port).
function getDestinyWallAngle(name) =
name == "nadir" ? 0 : name == "port" ? 90 : name == "zenith" ? 180 : name == "starboard" ? 270 :
assert(false, str("unknown wall ", name));
function getDestinyCylinderCenter(spec) =
(getModuleValue(spec, "coneAEnd") + getModuleValue(spec, "cylinderEnd")) / 2;
function getDestinyRackCenters(spec) =
[for (slot = [0 : getDestiny("racksPerWall") - 1])
getDestinyCylinderCenter(spec) + (slot - (getDestiny("racksPerWall") - 1) / 2) * getDestiny("rackPitch")];
// The science window takes the nadir rack slot just forward of the module's middle.
function getDestinyWindowZ(spec) = getDestinyRackCenters(spec)[getDestiny("racksPerWall") / 2];
module _cornerStandoffs(spec) {
size = getDestiny("cornerStandoffSize");
radius = getModuleValue(spec, "shellRadius") - getModuleValue(spec, "wallThickness") - size * 0.75;
start = getModuleValue(spec, "coneAEnd");
color("LightGray")
for (angle = [45 : 90 : 315])
rotate([0, 0, angle]) translate([radius - size / 2, -size / 2, start])
cube([size, size, getModuleValue(spec, "cylinderEnd") - start]);
}
module _destinyHandrails(spec) {
outer = getModuleValue(spec, "outerRadius") + 6;
railLength = getCommon("handrailLength");
for (angle = [30 : 60 : 330], z = getModuleValue(spec, "ringZs"))
surfaceFrame(angle, getRingHandrailZ(spec, z), outer, false) translate([-railLength / 2, 0, 0]) handrail();
}
module _destinyTrunnions(spec) {
outer = getModuleValue(spec, "outerRadius");
for (angle = [90, 270], z = [getModuleValue(spec, "coneAEnd") + 300, getModuleValue(spec, "cylinderEnd") - 300])
surfaceFrame(angle, z, outer, true) trunnion();
surfaceFrame(0, getDestinyCylinderCenter(spec) - 1400, outer, true) trunnion();
}
module destiny(debrisShieldShown = showDebrisShield, racksShown = showRacks,
shutterOpenAngle = windowShutterOpenAngle, coverOpenAngle = forwardCoverOpenAngle) {
for (row = getAssumedRows(getDestinyDimensions()))
echo(str("ASSUMED: destiny ", row[0], " = ", row[1], " (", row[3], ")"));
spec = getDestinySpec();
outer = getModuleValue(spec, "outerRadius");
ringZs = getModuleValue(spec, "ringZs");
windowZ = getDestinyWindowZ(spec);
nadir = getDestinyWallAngle("nadir");
moduleShell(spec);
if (debrisShieldShown)
moduleDebrisShield(spec, getDestiny("panelRowsPerBay"), getDestiny("panelsAround"),
getDestiny("panelThickness"), getDestiny("panelGap"), 0,
[[nadir, windowZ, getDestiny("windowDiameter") + 2 * getDestiny("windowFrameWidth") + 60]]);
passiveCbm();
translate([0, 0, getModuleValue(spec, "overallLength")]) rotate([180, 0, 0]) activeCbm(coverOpenAngle);
moduleHatches(spec);
surfaceFrame(nadir, windowZ, outer, true) rotate([0, 0, 90]) scienceWindow(shutterOpenAngle);
surfaceFrame(getDestinyWallAngle("zenith"), ringZs[0], outer, true) labCradle(ringZs[1] - ringZs[0]);
surfaceFrame(getDestinyWallAngle("port"), getDestinyCylinderCenter(spec), outer, true)
powerDataGrappleFixture();
surfaceFrame(getDestinyWallAngle("starboard") - 30, getDestinyCylinderCenter(spec) + 1500, outer, true)
grappleFixture();
_destinyHandrails(spec);
_destinyTrunnions(spec);
if (racksShown) {
_cornerStandoffs(spec);
moduleRacks(spec, getDestiny("rackRadialClearance"),
[for (placement = getWallRackPlacements(getDestinyRackCenters(spec)))
if (!(placement[0] == nadir && abs(placement[1] - windowZ) < 1)) placement]);
}
}
destiny();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
// ============================================================================
// labCradle.scad — Lab Cradle Assembly, the active half of the S0 attachment
// Eric Moderbacher
// 2026-10-21
// Wikipedia (Integrated Truss Structure): the Lab Cradle Assembly is bolted to
// Destiny's aft and mid rings and receives the grapple bar and alignment pins
// on S0. Drawn as a saddle on each ring, two longitudinal beams, and a capture
// latch between them; every size is ASSUMED. Origin on the outer surface over
// the aft ring, +Z outward (zenith), +X along the module toward the mid ring.
// ============================================================================
use <../config.scad>
include <../detail.scad>
module labCradle(ringSpacing) {
height = getDestiny("cradleHeight");
width = getDestiny("cradleWidth");
beam = getDestiny("cradleBeamSize");
color("Silver") {
for (x = [0, ringSpacing]) hull() {
translate([x - beam / 2, -width / 2, -40]) cube([beam, width, 40]);
translate([x - beam / 2, -width / 4, height - beam]) cube([beam, width / 2, beam]);
}
for (y = [-width / 4, width / 4 - beam])
translate([0, y, height - beam]) cube([ringSpacing, beam, beam]);
}
color("DimGray") translate([ringSpacing / 2, 0, height]) {
translate([-150, -150, 0]) cube([300, 300, 60]);
for (x = [-1, 1]) translate([x * 110, 0, 60]) cylinder(h = 120, d = 50);
}
}
labCradle(2500);
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
// ============================================================================
// scienceWindow.scad — Destiny's science window with its external shutter
// Eric Moderbacher
// 2026-10-21
// G10 p49: one window, 50.9 cm across. Wikipedia: on one side of the center
// segment, in an open rack bay, protected by a shutter. The frame, the pane
// stack and the shutter's shape and hinge are ASSUMED. Origin on the outer
// surface at the window center, +Z outward; the shutter hinges on its +X edge.
// ============================================================================
use <../config.scad>
include <../detail.scad>
module scienceWindow(shutterOpenAngle = 0) {
diameter = getDestiny("windowDiameter");
frame = getDestiny("windowFrameWidth");
shutterThickness = getDestiny("windowShutterThickness");
hingeX = diameter / 2 + frame;
color("Silver") difference() {
translate([0, 0, -80]) cylinder(h = 120, d = diameter + 2 * frame);
translate([0, 0, -100]) cylinder(h = 200, d = diameter);
}
color("LightSteelBlue", 0.5) translate([0, 0, -60]) cylinder(h = 30, d = diameter + 10);
color("Gainsboro")
translate([hingeX, 0, 45]) rotate([0, -shutterOpenAngle, 0]) translate([-hingeX, 0, 0]) {
cylinder(h = shutterThickness, d = diameter + 2 * frame);
for (angle = [0, 90]) rotate([0, 0, angle])
translate([0, 0, shutterThickness + 8]) cube([diameter, 30, 16], center = true);
}
color("DimGray") translate([hingeX, 0, 45 + shutterThickness / 2]) rotate([90, 0, 0])
cylinder(h = diameter * 0.6, d = 36, center = true);
}
scienceWindow(60);
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53
// ============================================================================
// harmony.scad — Harmony (Node 2) assembly
// Eric Moderbacher
// 2026-10-21
// The shared Node 2/3 build (common/europeanNode.scad): primary structure
// identical to Node 3 (NASA Node 3 Description p7), so the shell takes
// Tranquility's numbers, with Harmony's 98 debris panels (Wikipedia, ESA).
// Harmony adds the PDGF Canadarm2 can work from (Wikipedia); where it sits on
// the hull is ASSUMED. The four racks of crew quarters and four avionics racks
// are drawn as standard racks.
// Once installed, element +X is nadir, +Y port, +Z station forward.
// ============================================================================
use <../config.scad>
use <../common/moduleShell.scad>
use <../common/europeanNode.scad>
use <../common/powerDataGrappleFixture.scad>
include <../detail.scad>
showDebrisShield = true;
showRacks = true;
radialCoverOpenAngle = 120;
function getHarmonySpec() = makeEuropeanNodeSpec(
getHarmony("overallLength"), getTranquility("endConeLength"), getTranquility("portBayLength"),
getTranquility("pressureShellDiameter"), getTranquility("endConeSmallDiameter"),
getHarmony("outerDiameter"), getTranquility("radialPortFaceProud"),
getTranquility("radialPortBossDiameter"), getTranquility("shellWallThickness"),
getTranquility("reinforcementRingWidth"), getTranquility("reinforcementRingHeight"));
// Element angles of the radial ports once installed (element +X is nadir, +Y port).
function getHarmonyPortAngle(name) =
name == "nadir" ? 0 : name == "port" ? 90 : name == "zenith" ? 180 : name == "starboard" ? 270 :
assert(false, str("unknown port ", name));
module harmony(debrisShieldShown = showDebrisShield, racksShown = showRacks,
coverOpenAngle = radialCoverOpenAngle) {
for (row = getAssumedRows(getHarmonyDimensions()))
echo(str("ASSUMED: harmony ", row[0], " = ", row[1], " (", row[3], ")"));
spec = getHarmonySpec();
europeanNode(spec, coverOpenAngle, debrisShieldShown, racksShown,
getHarmony("panelRowsPerBay"), getHarmony("panelsAround"),
getTranquility("panelThickness"), getTranquility("panelGap"),
getTranquility("rackRadialClearance"));
surfaceFrame(135, (getModuleValue(spec, "coneAEnd") + getModuleValue(spec, "firstSectionEnd")) / 2,
getModuleValue(spec, "outerRadius"), true)
powerDataGrappleFixture();
}
harmony();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
// ============================================================================
// experimentLogisticsModule.scad — Kibo's ELM Pressurized Section (ELM-PS)
// Eric Moderbacher
// 2026-10-21
// JAXA Kibo Handbook p21-22: 4.4 m across, 4.2 m long including its berthing
// ring, 8 racks; it sits on the PM's zenith port. Shell from
// common/moduleShell.scad with a passive ring at the bottom and a closed top;
// cone lengths, the rack layout (two per wall) and panel layout are ASSUMED.
// Berthing face at z = 0, body +Z.
// ============================================================================
use <../config.scad>
use <../common/moduleShell.scad>
use <../common/passiveCbm.scad>
use <../common/nodeHatch.scad>
include <../detail.scad>
function getElmSpec() = makeModuleSpec(
getBerthing("passiveCbmDepth"), 0, getKibo("elmLength"), 450, [],
getKibo("pmShellDiameter"), getKibo("endConeSmallDiameter"), getKibo("elmOuterDiameter"), 0, 0,
getKibo("shellWallThickness"), getKibo("reinforcementRingWidth"), getKibo("reinforcementRingHeight"), false);
module experimentLogisticsModule(debrisShieldShown = true, racksShown = true) {
spec = getElmSpec();
moduleShell(spec);
if (debrisShieldShown)
moduleDebrisShield(spec, 1, getKibo("panelsAround"), getKibo("panelThickness"), getKibo("panelGap"));
passiveCbm();
translate([0, 0, getModuleValue(spec, "coneAStart") + 20]) nodeHatch();
color("DarkGray") translate([0, 0, getModuleValue(spec, "coneBEnd") - 40])
cylinder(h = 40, d = getKibo("endConeSmallDiameter"));
if (racksShown)
moduleRacks(spec, getKibo("rackRadialClearance"),
[for (angle = [0 : 90 : 270])
[angle, (getModuleValue(spec, "coneAEnd") + getModuleValue(spec, "cylinderEnd")) / 2]]);
}
experimentLogisticsModule();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54
// ============================================================================
// exposedFacility.scad — Kibo's Exposed Facility (EF) with its payload sites
// Eric Moderbacher
// 2026-10-21
// JAXA Kibo Handbook p42-43: a box-shaped platform 5.0 m wide, 5.6 m long, 4.0 m
// high (the current JAXA page says 5.2 m long and 3.8 m high), twelve payload
// attachment sites, standard payloads up to 1.85 x 1.0 x 0.8 m. Drawn as a
// braced box with five sites down each long side and two on the outboard end;
// the site layout is ASSUMED from the handbook's p22 diagram. Inboard face at
// z = 0 centered on the origin, length +Z, width along X, height along Y.
// ============================================================================
use <../config.scad>
use <../common/placement.scad>
include <../detail.scad>
function getExposedFacilitySites() =
let(length = getKibo("efLength"), width = getKibo("efWidth"), pitch = length / 5)
concat([for (side = [-1, 1], slot = [0 : 4]) [[side * width / 2, 0, (slot + 0.5) * pitch], [side, 0, 0]]],
[for (slot = [-1, 1]) [[slot * width / 4, 0, length], [0, 0, 1]]]);
module _efPayload() {
color("WhiteSmoke") translate([0, -getKibo("efPayloadWidth") / 2, -getKibo("efPayloadLength") / 2])
cube([getKibo("efPayloadHeight"), getKibo("efPayloadWidth"), getKibo("efPayloadLength")]);
color("Gold") translate([getKibo("efPayloadHeight"), -getKibo("efPayloadWidth") / 2 + 50, -getKibo("efPayloadLength") / 2 + 50])
cube([6, getKibo("efPayloadWidth") - 100, getKibo("efPayloadLength") - 100]);
}
module exposedFacility(payloadsShown = true) {
length = getKibo("efLength");
width = getKibo("efWidth");
height = getKibo("efHeight");
bar = 150;
corners = [[-width / 2, -height / 2], [width / 2, -height / 2], [width / 2, height / 2], [-width / 2, height / 2]];
color("Gainsboro") {
for (corner = corners) translate([corner[0] - bar / 2, corner[1] - bar / 2, 0]) cube([bar, bar, length]);
for (z = [0, length / 2, length], side = [0 : 3])
let(a = corners[side], b = corners[(side + 1) % 4])
rodBetween([a[0], a[1], z], [b[0], b[1], z], bar * 0.8);
}
color("LightGray") translate([-width / 2 + 200, -height / 2 + 200, 200])
cube([width - 400, height - 400, length - 400]);
for (site = getExposedFacilitySites())
let(position = site[0], normal = site[1])
translate(position) rotate(normal[2] > 0 ? [0, -90, 0] : normal[0] > 0 ? [0, 0, 0] : [0, 0, 180]) {
color("DimGray") translate([0, -350, -350]) cube([80, 700, 700]);
if (payloadsShown) translate([80, 0, 0]) _efPayload();
}
}
exposedFacility();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
// ============================================================================
// jemAirlock.scad — Kibo's scientific airlock between the PM and the EF
// Eric Moderbacher
// 2026-10-21
// JAXA Kibo Handbook p35: cylindrical, 1.7 m across on the outer side, 1.4 m on
// the inner (PM) side, 2.0 m long; an inner hatch with a small window and an
// outer hatch; a slide table carries items through; not for crew. How far it
// stands out of the PM bulkhead and the hatch shapes are ASSUMED. Outer hatch
// face at z = 0 facing +Z; the body runs back into the PM along -Z.
// ============================================================================
use <../config.scad>
include <../detail.scad>
module jemAirlock(protrusion = 900) {
length = getKibo("airlockLength");
outer = getKibo("airlockOuterDiameter");
inner = getKibo("airlockInnerDiameter");
color("Silver") translate([0, 0, -protrusion]) cylinder(h = protrusion, d = outer);
color("Gray") translate([0, 0, -length]) cylinder(h = length - protrusion, d = inner);
color("Gainsboro") translate([-outer * 0.32, -outer * 0.32, 0]) cube([outer * 0.64, outer * 0.64, 40]);
color("DimGray") for (x = [-1, 1]) translate([x * outer * 0.36, 0, 20]) cylinder(h = 60, d = 80);
}
jemAirlock();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71
// ============================================================================
// jemRemoteManipulator.scad — JEMRMS Main Arm with the Small Fine Arm
// Eric Moderbacher
// 2026-10-21
// JAXA Kibo Handbook p21, p54: the Main Arm is 10 m with six joints; the Small
// Fine Arm (2.2 m, six joints) works from the Main Arm's end. Joint order and
// axes, boom lengths and diameters are ASSUMED (two equal booms sized to 10 m).
// jointAngles: [shoulder yaw, shoulder pitch, elbow pitch, wrist pitch, wrist
// yaw, wrist roll], degrees. Base plate at the origin, straight arm along +Z.
// withFineArm hangs the Small Fine Arm straight off the Main Arm's end.
// ============================================================================
use <../config.scad>
include <../detail.scad>
function _getMainBoomLength() =
(getKibo("mainArmLength") - 6 * getKibo("armJointSpacing") - 500) / 2;
module _armJoint(axis) {
spacing = getKibo("armJointSpacing");
color("Gainsboro") translate([0, 0, spacing / 2])
rotate(axis == "x" ? [0, 90, 0] : axis == "y" ? [90, 0, 0] : [0, 0, 0])
cylinder(h = axis == "z" ? spacing : getKibo("mainArmBoomDiameter") * 1.5,
d = getKibo("mainArmBoomDiameter") * 1.4, center = true);
}
module _armChain(angles, axes) {
spacing = getKibo("armJointSpacing");
if (len(angles) == 0) children();
else {
_armJoint(axes[0]);
translate([0, 0, spacing])
rotate(axes[0] == "x" ? [angles[0], 0, 0] : axes[0] == "y" ? [0, angles[0], 0] : [0, 0, angles[0]])
_armChain([for (i = [1 : 1 : len(angles) - 1]) angles[i]],
[for (i = [1 : 1 : len(axes) - 1]) axes[i]]) children();
}
}
module _armBoom(length, diameter) {
color("WhiteSmoke") cylinder(h = length, d = diameter);
}
module smallFineArm() {
length = getKibo("smallFineArmLength");
color("WhiteSmoke") cylinder(h = length * 0.4, d = 160);
color("Gainsboro") translate([0, 0, length * 0.4]) sphere(d = 220);
color("WhiteSmoke") translate([0, 0, length * 0.4]) cylinder(h = length * 0.5, d = 140);
color("DimGray") translate([0, 0, length * 0.9]) cylinder(h = length * 0.1, d = 200);
}
module jemRemoteManipulator(jointAngles = [0, 0, 0, 0, 0, 0], withFineArm = true) {
assert(len(jointAngles) == getKibo("mainArmJointCount"), "the JEMRMS Main Arm takes six joint angles");
boom = _getMainBoomLength();
diameter = getKibo("mainArmBoomDiameter");
color("LightGray") translate([-400, -400, 0]) cube([800, 800, 120]);
_armChain([jointAngles[0], jointAngles[1]], ["z", "y"]) {
_armBoom(boom, diameter);
translate([0, 0, boom]) _armChain([jointAngles[2]], ["y"]) {
_armBoom(boom, diameter);
translate([0, 0, boom]) _armChain([jointAngles[3], jointAngles[4], jointAngles[5]], ["y", "x", "z"]) {
color("DimGray") cylinder(h = 500 - 2 * getKibo("armJointSpacing") + 200, d = diameter * 1.6);
if (withFineArm) translate([0, 0, 300]) smallFineArm();
}
}
}
}
jemRemoteManipulator([0, 60, -100, 40, 0, 0]);
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121
// ============================================================================
// kibo.scad — Kibo (Japanese Experiment Module) assembly
// Eric Moderbacher
// 2026-10-21
// JAXA Kibo Handbook (2007): the Pressurized Module (11.2 m, 4.4 m across, 23
// racks) on Harmony's port port; the ELM-PS on its zenith port toward the
// outboard end; on the outboard bulkhead the JEM Airlock, two windows, the
// JEMRMS base plate above and the EF berthing mechanism below; the Exposed
// Facility hanging below the PM's axis beyond its end. The ELM-ES shown in the
// handbook's diagram has since been returned to Earth and is not drawn. Shell
// and panels come from common/moduleShell.scad. Positions the handbook only
// draws are ASSUMED and echoed.
// Element frame: +Z outboard from the PM's berthing face; installed, +X points
// station aft and +Y zenith.
// ============================================================================
use <../config.scad>
use <../common/placement.scad>
use <../common/moduleShell.scad>
use <../common/passiveCbm.scad>
use <../common/activeCbm.scad>
use <../common/nodeHatch.scad>
use <../common/handrail.scad>
use <jemAirlock.scad>
use <experimentLogisticsModule.scad>
use <exposedFacility.scad>
use <jemRemoteManipulator.scad>
include <../detail.scad>
showDebrisShield = true;
showRacks = true;
showPayloads = true;
// JEMRMS Main Arm: [shoulder yaw, shoulder pitch, elbow pitch, wrist pitch, wrist yaw, wrist roll].
jemArmJointAngles = [0, 75, -150, 75, 0, 0];
function getKiboPmSpec() =
let(cylinder = getKibo("pmLength") - getBerthing("passiveCbmDepth")
- getKibo("pmInboardConeLength") - getKibo("pmOutboardConeLength"),
section = cylinder / getKibo("pmSectionCount"))
makeModuleSpec(
getBerthing("passiveCbmDepth"), 0, getKibo("pmLength"), getKibo("pmInboardConeLength"),
[for (index = [1 : getKibo("pmSectionCount") - 1]) section],
getKibo("pmShellDiameter"), getKibo("endConeSmallDiameter"), getKibo("pmOuterDiameter"),
getKibo("elmPortFaceProud"), 0, getKibo("shellWallThickness"),
getKibo("reinforcementRingWidth"), getKibo("reinforcementRingHeight"), false);
// Installed on Harmony's port port, element +X is station aft and +Y zenith.
function getKiboWallAngle(name) =
name == "aft" ? 0 : name == "zenith" ? 90 : name == "forward" ? 180 : name == "nadir" ? 270 :
assert(false, str("unknown wall ", name));
function getKiboElmPortRadius() = getKibo("pmOuterDiameter") / 2 + getKibo("elmPortFaceProud");
function _getKiboRackPlacements(spec) =
let(middle = (getModuleValue(spec, "coneAEnd") + getModuleValue(spec, "cylinderEnd")) / 2,
count = getKibo("racksPerWall"), pitch = getKibo("rackPitch"),
slots = [for (angle = [0 : 90 : 270], slot = [0 : count - 1]) [angle, middle + (slot - (count - 1) / 2) * pitch]])
[for (index = [0 : len(slots) - 1]) if (index < getKibo("pmRackCount")) slots[index]];
module _elmPort(spec) {
zenith = getKiboWallAngle("zenith");
z = getKibo("elmPortAlongPm");
radius = getKiboElmPortRadius();
color("DarkGray") rotate([0, 0, zenith]) translate([0, 0, z]) rotate([0, 90, 0])
cylinder(h = radius - getBerthing("activeCbmDepth"), d = getBerthing("ringOuterDiameter") + 150);
rotate([0, 0, zenith]) translate([radius, 0, z]) rotate([0, -90, 0]) activeCbm(160);
}
module _outboardEnd(spec) {
end = getModuleValue(spec, "overallLength");
bulkhead = getKibo("outboardBulkheadDiameter");
zenith = [cos(getKiboWallAngle("zenith")), sin(getKiboWallAngle("zenith")), 0];
color("DarkGray") translate([0, 0, end - 60]) cylinder(h = 60, d = bulkhead);
translate([0, 0, end]) {
translate(-zenith * bulkhead * 0.12) jemAirlock();
color("DimGray") translate(-zenith * bulkhead * 0.42 - [0, 0, 0]) cylinder(h = 350, d = 700);
for (side = [-1, 1])
color("LightSteelBlue", 0.6)
translate(zenith * bulkhead * 0.28 + [side * bulkhead * 0.22, 0, 0]) rotate([0, 0, 0])
cylinder(h = 40, d = getKibo("windowDiameter"));
}
}
module kibo(debrisShieldShown = showDebrisShield, racksShown = showRacks, payloadsShown = showPayloads,
armAngles = jemArmJointAngles) {
for (row = getAssumedRows(getKiboDimensions()))
echo(str("ASSUMED: kibo ", row[0], " = ", row[1], " (", row[3], ")"));
spec = getKiboPmSpec();
outer = getModuleValue(spec, "outerRadius");
end = getModuleValue(spec, "overallLength");
zenithAngle = getKiboWallAngle("zenith");
zenith = [cos(zenithAngle), sin(zenithAngle), 0];
bulkhead = getKibo("outboardBulkheadDiameter");
moduleShell(spec);
if (debrisShieldShown)
moduleDebrisShield(spec, getKibo("panelRowsPerBay"), getKibo("panelsAround"),
getKibo("panelThickness"), getKibo("panelGap"), 0,
[[zenithAngle, getKibo("elmPortAlongPm"), getBerthing("ringOuterDiameter") + 250]]);
passiveCbm();
translate([0, 0, getModuleValue(spec, "coneAStart") + 20]) nodeHatch();
_elmPort(spec);
_outboardEnd(spec);
for (angle = [30 : 60 : 330], z = getModuleValue(spec, "ringZs"))
surfaceFrame(angle, getRingHandrailZ(spec, z), outer + 6, false) translate([-getCommon("handrailLength") / 2, 0, 0]) handrail();
if (racksShown) moduleRacks(spec, getKibo("rackRadialClearance"), _getKiboRackPlacements(spec));
rotate([0, 0, zenithAngle]) translate([getKiboElmPortRadius(), 0, getKibo("elmPortAlongPm")])
rotate([0, 90, 0]) rotate([0, 0, 180]) experimentLogisticsModule(debrisShieldShown, racksShown);
translate(-zenith * getKibo("efDropBelowAxis") + [0, 0, end + getKibo("efBerthingGap")])
rotate([0, 0, zenithAngle - 90]) exposedFacility(payloadsShown);
translate(zenith * bulkhead * 0.45 + [0, 0, end]) rotate([0, 0, zenithAngle - 90])
jemRemoteManipulator(armAngles);
}
kibo();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
// ============================================================================
// aftAccessClosure.scad — Leonardo's aft bulkhead and Aft Access Closure (AAC)
// Eric Moderbacher
// 2026-10-21
// NTRS 20120016610 p12-13 names the Aft Access Closure and its large-diameter
// seal; NTRS 20100042363 p2 says racks are loaded through it on the ground with
// the Rack Insertion Device, so it stays shut in orbit. Its diameter and the
// bulkhead's are ASSUMED (getLeonardoDimensions in config.scad).
// Frame: origin at the centre of the aft end face, +Z outward (station forward
// once installed); the part lies entirely at z <= 0 so the module keeps its
// sourced 6.67 m.
// ============================================================================
use <../config.scad>
include <../detail.scad>
module aftAccessClosure() {
bulkhead = getLeonardo("endConeSmallDiameter");
closure = getLeonardo("aftAccessClosureDiameter");
height = getLeonardo("aftAccessClosureHeight");
boltCount = 24;
color("DarkGray") translate([0, 0, -height]) cylinder(h = height * 0.5, d = bulkhead);
color("Silver") difference() {
translate([0, 0, -height]) cylinder(h = height, d = closure + 120);
translate([0, 0, -height - 1]) cylinder(h = height + 2, d = closure);
}
color("Gainsboro") translate([0, 0, -height * 0.75]) cylinder(h = height * 0.6, d = closure);
color("DimGray")
for (index = [0 : boltCount - 1])
rotate([0, 0, index * 360 / boltCount]) translate([closure / 2 + 30, 0, -1])
cylinder(h = 1, d = 24, $fn = 6);
}
aftAccessClosure();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125
// ============================================================================
// leonardo.scad — Leonardo, the Permanent Multipurpose Module (PMM)
// Eric Moderbacher
// 2026-10-21
// G10 p58 and G15 p36: 6.67 m long, 4.5 m outside and 4.21 m inside, up to 16
// racks, bag stowage in the aft end cone. NASA MSFC "What is MPLM?": the
// cylinder is 4.8 m, the hull carries external meteoroid shields, and the MPLM
// had two grapple fixtures. For the PMM (STS-133 press kit p29-30; Spaceflight
// Now; Wikipedia) Thales Alenia added Nextel/Kevlar blankets under the shields,
// new forward end cone shields and visiting-vehicle retro-reflectors, and
// removed the +Y grapple fixture. A passive CBM at the forward cone (PMM Life
// Extension p13), a closed aft cone with the Aft Access Closure. Cone lengths,
// section count, rack walls, fixture, trunnion and reflector positions, and
// the blankets' arc are ASSUMED and echoed.
// Element frame: +Z from the passive berthing face (z = 0, on Tranquility's
// forward port) to the aft end. Installed, element +X is station starboard,
// +Y nadir, +Z station forward (getLeonardoWallAngle).
// ============================================================================
use <../config.scad>
use <../common/placement.scad>
use <../common/moduleShell.scad>
use <../common/passiveCbm.scad>
use <../common/nodeHatch.scad>
use <../common/trunnion.scad>
use <../common/handrail.scad>
use <../common/grappleFixture.scad>
use <aftAccessClosure.scad>
use <shieldBlanket.scad>
use <retroReflector.scad>
include <../detail.scad>
showDebrisShield = true;
showAddedBlanket = true;
showRacks = true;
function getLeonardoShellDiameter() = getLeonardo("interiorDiameter") + 2 * getLeonardo("shellWallThickness");
function getLeonardoSpec() =
let(section = getLeonardo("cylinderLength") / getLeonardo("cylinderSectionCount"))
makeModuleSpec(
getBerthing("passiveCbmDepth"), 0, getLeonardo("overallLength"), getLeonardo("endConeLength"),
[for (index = [1 : getLeonardo("cylinderSectionCount") - 1]) section],
getLeonardoShellDiameter(), getLeonardo("endConeSmallDiameter"), getLeonardo("outerDiameter"),
0, 0, getLeonardo("shellWallThickness"),
getLeonardo("reinforcementRingWidth"), getLeonardo("reinforcementRingHeight"), false);
// On Tranquility's forward port (station.scad: placeOnNodeRadialPortOutward, no extra roll),
// element +X is station starboard and +Y nadir. The CBM's roll about the axis is ASSUMED.
function getLeonardoWallAngle(name) =
name == "starboard" ? 0 : name == "nadir" ? 90 : name == "port" ? 180 : name == "zenith" ? 270 :
assert(false, str("unknown wall ", name));
// The only port is the passive CBM; origin on its face, +Z toward Tranquility.
module placeOnLeonardoPort(portName) {
assert(portName == "passiveCbm", str("Leonardo has no port named ", portName));
rotate([180, 0, 0]) children();
}
function getLeonardoRackCenters(spec) =
let(middle = (getModuleValue(spec, "coneAEnd") + getModuleValue(spec, "cylinderEnd")) / 2,
count = getLeonardo("racksPerWall"))
[for (slot = [0 : count - 1]) middle + (slot - (count - 1) / 2) * getLeonardo("rackPitch")];
module _leonardoGrappleFixture(spec) {
start = getModuleValue(spec, "coneAEnd");
z = start + getLeonardo("grappleFixtureAlongCylinder") * (getModuleValue(spec, "cylinderEnd") - start);
surfaceFrame(getLeonardo("grappleFixtureDegrees"), z, getModuleValue(spec, "outerRadius"), true)
grappleFixture();
}
// Longeron trunnions at right angles to the grapple side, the keel trunnion opposite it.
module _leonardoTrunnions(spec) {
outer = getModuleValue(spec, "outerRadius");
grapple = getLeonardo("grappleFixtureDegrees");
longeronZs = [getModuleValue(spec, "coneAEnd") + 250, getModuleValue(spec, "cylinderEnd") - 250];
for (side = [-90, 90], z = longeronZs)
surfaceFrame(grapple + side, z, outer, true) trunnion();
surfaceFrame(grapple + 180, (longeronZs[0] + longeronZs[1]) / 2, outer, true) trunnion();
}
module _leonardoHandrails(spec) {
outer = getModuleValue(spec, "outerRadius") + 6;
railLength = getCommon("handrailLength");
for (angle = [30 : 60 : 330], z = getModuleValue(spec, "ringZs"))
surfaceFrame(angle, getRingHandrailZ(spec, z), outer, false)
translate([-railLength / 2, 0, 0]) handrail();
}
module _leonardoRetroReflectors(spec) {
count = getLeonardo("retroReflectorCount");
for (index = [0 : count - 1])
surfaceFrame(getLeonardoWallAngle("starboard") + (index - (count - 1) / 2) * 25,
getModuleValue(spec, "cylinderEnd") - 400, getModuleValue(spec, "outerRadius"), false)
retroReflector();
}
module leonardo(debrisShieldShown = showDebrisShield, blanketShown = showAddedBlanket, racksShown = showRacks) {
for (row = getAssumedRows(getLeonardoDimensions()))
echo(str("ASSUMED: leonardo ", row[0], " = ", row[1], " (", row[3], ")"));
spec = getLeonardoSpec();
end = getModuleValue(spec, "overallLength");
moduleShell(spec);
if (debrisShieldShown)
moduleDebrisShield(spec, getLeonardo("panelRowsPerBay"), getLeonardo("panelsAround"),
getLeonardo("panelThickness"), getLeonardo("panelGap"));
if (blanketShown)
shieldBlanket(getModuleValue(spec, "shellRadius"), getModuleValue(spec, "coneAEnd"),
getModuleValue(spec, "cylinderEnd") - getModuleValue(spec, "coneAEnd"));
passiveCbm();
translate([0, 0, getModuleValue(spec, "coneAStart") + 20]) nodeHatch();
translate([0, 0, end]) aftAccessClosure();
_leonardoGrappleFixture(spec);
_leonardoTrunnions(spec);
_leonardoHandrails(spec);
_leonardoRetroReflectors(spec);
if (racksShown)
moduleRacks(spec, getLeonardo("rackRadialClearance"), getWallRackPlacements(getLeonardoRackCenters(spec)));
}
leonardo();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
// ============================================================================
// retroReflector.scad — visiting-vehicle retro-reflector on Leonardo's hull
// Eric Moderbacher
// 2026-10-21
// Wikipedia "Leonardo (ISS module)" lists visiting-vehicle retro-reflectors among
// the PMM modifications. Count, size and shape are ASSUMED: a corner-cube
// cluster on a short standoff.
// Frame: origin on the hull surface, +Z outward.
// ============================================================================
use <../config.scad>
include <../detail.scad>
module retroReflector() {
diameter = getLeonardo("retroReflectorDiameter");
color("DimGray") cylinder(h = diameter * 0.4, d = diameter * 0.5);
color("Silver") translate([0, 0, diameter * 0.4]) cylinder(h = diameter * 0.15, d = diameter);
color("LightCyan")
for (index = [0 : 6])
let(ring = index == 0 ? 0 : diameter * 0.3)
rotate([0, 0, index * 60]) translate([ring, 0, diameter * 0.55])
cylinder(h = diameter * 0.05, d = diameter * 0.25, $fn = 6);
}
retroReflector();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
// ============================================================================
// shieldBlanket.scad — the Nextel/Kevlar layer added for the PMM conversion
// Eric Moderbacher
// 2026-10-21
// Spaceflight Now (Oct 2010): rather than change Leonardo's metal debris
// shields, 50 Nextel/Kevlar "mattresses" were laid on the insulation under
// them, over about two-thirds of the surface, chosen by where the module sits.
// The STS-133 press kit (PDF p29) calls it an improved micrometeoroid debris
// protective shield. Which two-thirds, and the layer's thickness, are ASSUMED.
// Drawn on the cylinder only, as one arc split into its mattresses.
// Frame: the module's element frame; +Z along the axis.
// ============================================================================
use <../config.scad>
include <../detail.scad>
// radius: the pressure shell's outer radius; startZ/length: the cylinder's span.
module shieldBlanket(radius, startZ, length) {
thickness = getLeonardo("addedBlanketThickness");
arc = 360 * getLeonardo("addedBlanketCoverageFraction");
count = getLeonardo("addedBlanketCount");
rows = 2;
columns = round(count / rows);
seamDegrees = 0.4;
seam = 10;
start = getLeonardo("addedBlanketCenterDegrees") - arc / 2;
color("Tan")
for (row = [0 : rows - 1], column = [0 : columns - 1])
rotate([0, 0, start + column * arc / columns])
rotate_extrude(angle = arc / columns - seamDegrees)
translate([radius, startZ + row * length / rows + seam / 2])
square([thickness, length / rows - seam]);
}
shieldBlanket(2145, 1100, 4800);
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
// ============================================================================
// eraBasePoint.scad — a base point (BTL) the European Robotic Arm grapples
// Eric Moderbacher
// 2026-10-21
// Cruijssen et al. 2014 (NTRS 20150004070) p6: base points are mechanically
// identical to ERA grapple fixtures and also carry power, data and video to
// the arm. eoPortal: four base plates around Nauka. Plate size, shaft and
// target shapes are ASSUMED.
// Frame: mounting plate on z = 0, grapple shaft up +Z; the end effector's tip
// meets it at z = getEra("basePointHeight") (placeOnEraBasePointTop).
// ============================================================================
use <../config.scad>
include <../detail.scad>
module placeOnEraBasePointTop() {
translate([0, 0, getEra("basePointHeight")]) children();
}
module eraBasePoint() {
size = getEra("basePointSize");
height = getEra("basePointHeight");
effector = getEra("endEffectorDiameter");
color("LightGray") translate([-size / 2, -size / 2, 0]) cube([size, size, 40]);
color("Silver") cylinder(h = height * 0.6, d1 = effector * 1.1, d2 = effector * 0.8);
color("DimGray") translate([0, 0, height * 0.6]) cylinder(h = height * 0.4, d1 = effector * 0.35, d2 = effector * 0.2);
color("Black") translate([size / 2 - 90, -60, 40]) cube([60, 120, 120]);
}
eraBasePoint();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116
// ============================================================================
// europeanRoboticArm.scad — the European Robotic Arm (ERA), posable
// Eric Moderbacher
// 2026-10-21
// ESA fact sheet EUC-ESA-FSH-008 (1:20 drawing): 4072 mm elbow axis to wrist
// pitch axis, 340 mm pitch to yaw axis, 1228 mm yaw axis to the end effector
// tip, 348 mm end effector; 11.3 m long overall. Cruijssen et al. 2014: two
// limbs, one pitch elbow, a roll-yaw-pitch wrist at each end, symmetric so
// either end is the shoulder. Verhoeven 1999 gives the joint ranges, which
// europeanRoboticArm() asserts. Limb and housing diameters (scaled from the
// drawing), the roll joint's place, boxes and cameras are ASSUMED.
// jointAngles: [roll 1, yaw 1, pitch 1, elbow, pitch 2, yaw 2, roll 2],
// degrees, from the end effector holding the base (end 1) outward.
// Frame: end effector 1's tip on z = 0 facing -Z (onto its base point);
// with all angles zero the arm runs straight up +Z. Yaw turns about X,
// pitch and elbow about Y, roll about Z.
// ============================================================================
use <../config.scad>
include <../detail.scad>
function getEraStraightLength() =
2 * (getEra("wristYawToTip") + getEra("wristPitchToYaw") + getEra("elbowToWristPitch"));
module _eraEndEffector() {
roll = getEra("rollJointFromTip");
diameter = getEra("endEffectorDiameter");
color("Gainsboro") cylinder(h = roll, d = diameter);
color("DimGray") cylinder(h = 60, d = diameter * 0.9);
color("Silver") translate([diameter / 2 - 20, -90, roll * 0.3]) cube([160, 180, 200]);
}
module _eraJointHousing(axis) {
diameter = getEra("jointHousingDiameter");
color("LightGray")
rotate(axis == "x" ? [0, 90, 0] : axis == "y" ? [90, 0, 0] : [0, 0, 0])
cylinder(h = diameter * 1.1, d = diameter, center = true);
}
module _eraLimb(withBox) {
length = getEra("elbowToWristPitch");
housing = getEra("jointHousingDiameter");
box = getEra("electronicsBoxSize");
color("WhiteSmoke") translate([0, 0, housing / 2]) cylinder(h = length - housing, d = getEra("limbDiameter"));
color("Gainsboro") {
translate([0, 0, housing / 2]) cylinder(h = 300, d1 = housing, d2 = getEra("limbDiameter"));
translate([0, 0, length - housing / 2 - 300]) cylinder(h = 300, d1 = getEra("limbDiameter"), d2 = housing);
}
if (withBox) color("Silver") translate([-box / 2, -box / 2, housing / 2 + 200]) cube([box, box, box * 1.4]);
color("DimGray") translate([getEra("limbDiameter") / 2, 0, length - housing - 400]) cube([160, 120, 160], center = true);
}
module _eraWrist(rollAngle, yawAngle, pitchAngle) {
roll = getEra("rollJointFromTip");
toYaw = getEra("wristYawToTip");
toPitch = getEra("wristPitchToYaw");
_eraEndEffector();
translate([0, 0, roll]) rotate([0, 0, rollAngle]) {
color("LightGray") cylinder(h = toYaw - roll, d = getEra("endEffectorDiameter") * 0.85);
translate([0, 0, toYaw - roll]) rotate([yawAngle, 0, 0]) {
_eraJointHousing("x");
color("LightGray") cylinder(h = toPitch, d = getEra("jointHousingDiameter") * 0.8);
translate([0, 0, toPitch]) rotate([0, pitchAngle, 0]) {
_eraJointHousing("y");
children();
}
}
}
}
module _assertEraJointAngles(jointAngles) {
assert(len(jointAngles) == getEra("jointCount"), "ERA takes seven joint angles");
for (index = [0, 6])
assert(abs(jointAngles[index]) <= getEra("wristRollRangeDegrees"), str("ERA roll ", index, " out of range"));
for (index = [1, 5])
assert(abs(jointAngles[index]) <= getEra("wristYawRangeDegrees"), str("ERA yaw ", index, " out of range"));
for (index = [2, 4])
assert(abs(jointAngles[index]) <= getEra("wristPitchRangeDegrees"), str("ERA pitch ", index, " out of range"));
assert(jointAngles[3] >= getEra("elbowMinDegrees") && jointAngles[3] <= getEra("elbowMaxDegrees"),
"ERA elbow out of range");
}
module _eraFarWrist(pitchAngle, yawAngle, rollAngle) {
roll = getEra("rollJointFromTip");
toYaw = getEra("wristYawToTip");
toPitch = getEra("wristPitchToYaw");
rotate([0, pitchAngle, 0]) {
color("LightGray") cylinder(h = toPitch, d = getEra("jointHousingDiameter") * 0.8);
translate([0, 0, toPitch]) rotate([yawAngle, 0, 0]) {
_eraJointHousing("x");
color("LightGray") cylinder(h = toYaw - roll, d = getEra("endEffectorDiameter") * 0.85);
translate([0, 0, toYaw - roll]) rotate([0, 0, rollAngle])
translate([0, 0, roll]) rotate([180, 0, 0]) _eraEndEffector();
}
}
}
module europeanRoboticArm(jointAngles = [0, 0, 0, 0, 0, 0, 0]) {
_assertEraJointAngles(jointAngles);
_eraWrist(jointAngles[0], jointAngles[1], jointAngles[2]) {
_eraLimb(true);
translate([0, 0, getEra("elbowToWristPitch")]) rotate([0, jointAngles[3], 0]) {
_eraJointHousing("y");
color("Silver") translate([-250, getEra("jointHousingDiameter") / 2, -250]) cube([500, 250, 500]);
_eraLimb(false);
translate([0, 0, getEra("elbowToWristPitch")]) {
_eraJointHousing("y");
_eraFarWrist(jointAngles[4], jointAngles[5], jointAngles[6]);
}
}
}
}
europeanRoboticArm([0, 0, 30, -120, 60, 0, 0]);
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
// ============================================================================
// hybridDockingPort.scad — Russian SSVP-M "hybrid" docking port
// Eric Moderbacher
// 2026-10-21
// en Wikipedia (Nauka, Prichal): an SSVP-M port pairs the probe-and-drogue
// soft-capture of the older SSVP with an APAS-95 style hard-dock collar;
// Nauka's Zvezda port and Prichal's zenith port are active (probe), the rest
// passive (drogue). Collar size and depth come from getNauka(); both are
// ASSUMED, as are the probe and drogue proportions. Serves Nauka and Prichal.
// Frame: mating face on z = 0 facing +Z, collar extends -Z into the module;
// an active port's probe stands proud of the face along +Z.
// ============================================================================
use <../config.scad>
include <../detail.scad>
module hybridDockingPort(active = false) {
ring = getNauka("dockingRingDiameter");
depth = getNauka("dockingPortDepth");
color("Silver") translate([0, 0, -depth]) difference() {
cylinder(h = depth, d = ring);
translate([0, 0, depth * 0.4]) cylinder(h = depth, d1 = ring * 0.3, d2 = ring - 160);
}
color("DimGray") for (index = [0 : 11]) rotate([0, 0, index * 30 + 15])
translate([ring / 2 - 50, 0, 0]) cylinder(h = 25, d = 50);
if (active)
color("LightGray") {
translate([0, 0, -depth * 0.6]) cylinder(h = depth * 0.6 + ring * 0.25, d = ring * 0.08);
translate([0, 0, ring * 0.25]) sphere(d = ring * 0.1);
}
}
hybridDockingPort(true);
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88
// ============================================================================
// nauka.scad — Nauka (Multipurpose Laboratory Module) with Prichal and ERA
// Eric Moderbacher
// 2026-10-21
// Nauka docked to Zvezda's nadir port on 2021-07-29 (NASA blog) and Prichal
// to Nauka's nadir port on 2021-11-26 (Spaceflight Now). Drawn as installed
// by 2023: the hull and its three ports (naukaHull.scad), two solar array
// wings (23.9 m span, russianspaceweb), the science airlock on the forward
// port and the RTOd radiator (both moved from Rassvet by ERA in 2023), the
// Portable Work Post (moved on VKD-60), four ERA base points, ERA standing on
// one of them, and Prichal on the nadir port. Wing chord and panel layout,
// base point, radiator and work post positions and the arm's pose are
// ASSUMED; every ASSUMED row of the three tables is echoed.
// Element frame: z = 0 on the Zvezda port's mating face, +Z toward station
// nadir; installed, element +X is station forward, +Y station starboard, so
// station.scad places it with rotate([180, 0, 0]) on Zvezda's nadir face.
// ============================================================================
use <../config.scad>
use <../common/placement.scad>
use <../common/moduleShell.scad>
use <../solarArrays/rigidPanelWing.scad>
use <naukaHull.scad>
use <naukaAirlock.scad>
use <naukaRadiator.scad>
use <portableWorkPost.scad>
use <eraBasePoint.scad>
use <europeanRoboticArm.scad>
use <prichal.scad>
include <../detail.scad>
showPrichal = true;
showEra = true;
// ERA, end 1 on the home base point: [roll 1, yaw 1, pitch 1, elbow, pitch 2, yaw 2, roll 2].
eraJointAngles = [0, 0, 80, -160, 80, 0, 0];
function getNaukaEraBasePoints() =
[for (index = [1 : 9])
let(angles = [for (row = getNaukaDimensions()) if (row[0] == str("eraBasePoint", index, "Angle")) row])
if (len(angles) == 1) [angles[0][1], getNauka(str("eraBasePoint", index, "Z"))]];
module placeOnNaukaEraBasePoint(index) {
point = getNaukaEraBasePoints()[index - 1];
surfaceFrame(point[0], point[1], getNaukaHullRadiusAt(point[1]), true) placeOnEraBasePointTop() children();
}
module _naukaSolarWings() {
small = getNauka("smallSectionDiameter") / 2;
root = getNauka("arrayRootOffset");
wingLength = getNauka("solarArraySpan") / 2 - small - root;
for (side = [0, 180])
let(angle = getNauka("arrayAngle") + side, radial = [cos(angle), sin(angle), 0],
tangent = [-sin(angle), cos(angle), 0]) {
color("Gray") rodBetween(radial * small + [0, 0, getNauka("arrayMountZ")],
radial * (small + root) + [0, 0, getNauka("arrayMountZ")], 120);
alignFrame(radial * (small + root) + [0, 0, getNauka("arrayMountZ")], radial, tangent)
rigidPanelWing(wingLength, getNauka("arrayChord"), getNauka("arrayPanelCount"));
}
}
module _echoAssumed() {
for (row = getAssumedRows(getNaukaDimensions()))
echo(str("ASSUMED: nauka ", row[0], " = ", row[1], " (", row[3], ")"));
for (row = getAssumedRows(getPrichalDimensions()))
echo(str("ASSUMED: prichal ", row[0], " = ", row[1], " (", row[3], ")"));
for (row = getAssumedRows(getEraDimensions()))
echo(str("ASSUMED: era ", row[0], " = ", row[1], " (", row[3], ")"));
}
module nauka(prichalShown = showPrichal, eraShown = showEra, eraAngles = eraJointAngles) {
_echoAssumed();
naukaHull();
_naukaSolarWings();
placeOnNaukaPort("forward") naukaAirlock();
surfaceFrame(getNauka("radiatorAngle"), getNauka("radiatorZ"), getNauka("largeSectionDiameter") / 2, true)
naukaRadiator();
surfaceFrame(getNauka("workPostAngle"), getNauka("workPostZ"), getNauka("smallSectionDiameter") / 2, true)
portableWorkPost();
for (point = getNaukaEraBasePoints())
surfaceFrame(point[0], point[1], getNaukaHullRadiusAt(point[1]), true) eraBasePoint();
if (eraShown)
placeOnNaukaEraBasePoint(getNauka("eraHomeBasePoint")) europeanRoboticArm(eraAngles);
if (prichalShown) placeOnNaukaPort("nadir") prichal();
}
nauka();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
// ============================================================================
// naukaAirlock.scad — Nauka's science airlock (ShK), on the forward port
// Eric Moderbacher
// 2026-10-21
// G10 p62 / G15 p41: launched on Rassvet in 2010 as MLM outfitting; moved
// to Nauka's forward port by ERA on VKD-57, 2023-05-03 (ru Wikipedia). ru/en
// Wikipedia: payloads up to 1200 x 500 x 500 mm, 2.1 m3. en Wikipedia (NASA
// blog, VKD-55): a grapple fixture on it was changed so ERA can use it as a
// base point. Outer diameter, length and wall are ASSUMED, sized so the
// inside holds the sourced volume; hatch and base point position ASSUMED.
// Frame: passive port's mating face on z = 0 (facing -Z, toward Nauka),
// body along +Z away from Nauka.
// ============================================================================
use <../config.scad>
use <hybridDockingPort.scad>
use <eraBasePoint.scad>
include <../detail.scad>
function getNaukaAirlockInnerVolumeLitres() =
let(inner = getNauka("airlockOuterDiameter") / 2 - getNauka("airlockWallThickness"),
length = getNauka("airlockLength") - 2 * getNauka("airlockWallThickness"))
PI * inner * inner * length / 1e6;
module naukaAirlock() {
outer = getNauka("airlockOuterDiameter");
length = getNauka("airlockLength");
depth = getNauka("dockingPortDepth");
rotate([180, 0, 0]) hybridDockingPort(false);
color("Beige") {
cylinder(h = depth, d = getNauka("dockingRingDiameter"));
translate([0, 0, depth]) cylinder(h = length - depth, d = outer);
}
color("LightGray") translate([0, 0, length]) cylinder(h = 80, d = outer * 0.85);
color("DimGray") translate([0, 0, length + 80]) cylinder(h = 30, d = outer * 0.6);
translate([outer / 2, 0, length * 0.6]) rotate([0, 90, 0]) eraBasePoint();
}
naukaAirlock();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144
// ============================================================================
// naukaHull.scad — Nauka's pressure hull: instrument-pressurized compartment
// and spherical hermoadapter, with its three docking ports
// Eric Moderbacher
// 2026-10-21
// ru Wikipedia ("Компоновка"): Nauka is a pressurized instrument compartment
// (ПГО) carrying the active hybrid port to Zvezda, and a spherical
// hermoadapter (ГА) behind a hatch, with an axial SSVP-M port (Prichal) and a
// forward port (the science airlock). Length 13.12 m and 4.25 m maximum
// diameter are VERIFIED (getNauka). The FGB-style step from a large to a small
// section, every section length and diameter, the sphere size, the outer body
// panels, thruster blocks and window side are ASSUMED from photos and echoed
// by nauka.scad. The hull is drawn solid; no interior.
// Element frame: z = 0 on the Zvezda port's mating face, +Z toward station
// nadir; installed, element +X is station forward and +Y station starboard.
// ============================================================================
use <../config.scad>
use <../common/placement.scad>
use <../common/moduleShell.scad>
use <../common/handrail.scad>
use <hybridDockingPort.scad>
include <../detail.scad>
function getNaukaWallAngle(name) =
name == "forward" ? 0 : name == "starboard" ? 90 : name == "aft" ? 180 : name == "port" ? 270 :
assert(false, str("unknown Nauka wall ", name));
function getNaukaLargeSectionStart() = getNauka("dockingPortDepth") + getNauka("aftConeLength");
function getNaukaLargeSectionEnd() = getNaukaLargeSectionStart() + getNauka("largeSectionLength");
function getNaukaSmallSectionStart() = getNaukaLargeSectionEnd() + getNauka("transitionConeLength");
function getNaukaSphereCenterZ() =
getNauka("length") - getNauka("dockingPortDepth") - getNauka("sphereDiameter") / 2;
function getNaukaSmallSectionEnd() = getNaukaSphereCenterZ() - getNauka("sphereDiameter") * 0.4;
function getNaukaHullRadiusAt(z) =
z >= getNaukaLargeSectionStart() && z <= getNaukaLargeSectionEnd() ? getNauka("largeSectionDiameter") / 2 :
z >= getNaukaSmallSectionStart() && z <= getNaukaSmallSectionEnd() ? getNauka("smallSectionDiameter") / 2 :
assert(false, str("no cylindrical Nauka section at z = ", z));
function getNaukaForwardPortFaceRadius() = getNauka("sphereDiameter") / 2 + getNauka("radialPortStandoff");
function getNaukaPortNames() = ["zenith", "nadir", "forward"];
// Origin on the named port's mating face, +Z pointing away from Nauka. Axial ports keep
// element +X; on the forward port local +X points along element +Z (station nadir).
module placeOnNaukaPort(name) {
if (name == "zenith") rotate([180, 0, 0]) children();
else if (name == "nadir") translate([0, 0, getNauka("length")]) children();
else if (name == "forward")
let(angle = getNaukaWallAngle("forward"), radial = [cos(angle), sin(angle), 0])
alignFrame(radial * getNaukaForwardPortFaceRadius() + [0, 0, getNaukaSphereCenterZ()],
[0, 0, 1], radial) children();
else assert(false, str("unknown Nauka port ", name));
}
module _naukaPressureHull() {
portRing = getNauka("dockingRingDiameter");
large = getNauka("largeSectionDiameter") / 2;
small = getNauka("smallSectionDiameter") / 2;
sphere = getNauka("sphereDiameter") / 2;
smallEnd = getNaukaSmallSectionEnd();
neckRadius = sphere * 0.8;
neckZ = getNaukaSphereCenterZ() - sphere * 0.6;
color("Beige") rotate_extrude() polygon([
[0, getNauka("dockingPortDepth")],
[portRing / 2 + 100, getNauka("dockingPortDepth")],
[large, getNaukaLargeSectionStart()],
[large, getNaukaLargeSectionEnd()],
[small, getNaukaSmallSectionStart()],
[small, smallEnd],
[neckRadius, neckZ],
[0, neckZ]]);
color("Beige") translate([0, 0, getNaukaSphereCenterZ()]) sphere(r = sphere);
}
module _naukaPortCollars() {
depth = getNauka("dockingPortDepth");
color("Beige") translate([0, 0, getNaukaSphereCenterZ()])
cylinder(h = getNauka("length") - depth - getNaukaSphereCenterZ(), d = getNauka("dockingRingDiameter") + 200);
color("Beige") rotate([0, 0, getNaukaWallAngle("forward")]) translate([0, 0, getNaukaSphereCenterZ()])
rotate([0, 90, 0]) cylinder(h = getNaukaForwardPortFaceRadius() - depth, d = getNauka("dockingRingDiameter") + 200);
rotate([180, 0, 0]) hybridDockingPort(true);
translate([0, 0, getNauka("length")]) hybridDockingPort(false);
placeOnNaukaPort("forward") hybridDockingPort(true);
}
module _naukaBodyPanels() {
outer = getNauka("maxDiameter") / 2;
thickness = 40;
width = getNauka("hullPanelWidth");
sweep = width / outer * 180 / PI;
panelZ = (getNaukaLargeSectionStart() + getNaukaLargeSectionEnd() - getNauka("hullPanelLength")) / 2;
count = getNauka("hullPanelCount");
color("WhiteSmoke") for (index = [0 : count - 1])
rotate([0, 0, 360 / count * (index + 0.5) - sweep / 2]) rotate_extrude(angle = sweep)
translate([outer - thickness, panelZ]) square([thickness, getNauka("hullPanelLength")]);
}
module _naukaThrusterBlocks() {
small = getNauka("smallSectionDiameter") / 2;
count = getNauka("thrusterBlockCount");
for (index = [0 : count - 1])
surfaceFrame(360 / count * (index + 0.5), getNaukaSmallSectionEnd() - 600, small, true) {
color("Tan") translate([-250, -200, 0]) cube([500, 400, 220]);
color("OrangeRed") translate([0, 0, 220]) cylinder(h = 80, d = 260);
}
}
module _naukaWindow() {
angle = getNauka("windowAngle");
sphere = getNauka("sphereDiameter") / 2;
color("DarkSlateGray") rotate([0, 0, angle]) translate([0, 0, getNaukaSphereCenterZ()])
rotate([0, 90, 0]) translate([0, 0, sphere - 40]) cylinder(h = 80, d = getNauka("windowDiameter"));
color("Silver") rotate([0, 0, angle]) translate([0, 0, getNaukaSphereCenterZ()])
rotate([0, 90, 0]) translate([0, 0, sphere - 60]) difference() {
cylinder(h = 70, d = getNauka("windowDiameter") + 160);
translate([0, 0, -1]) cylinder(h = 72, d = getNauka("windowDiameter"));
}
}
module _naukaHandrails() {
large = getNauka("largeSectionDiameter") / 2;
small = getNauka("smallSectionDiameter") / 2;
for (angle = [0 : 90 : 270]) {
for (z = [getNaukaLargeSectionStart() + 300 : 1500 : getNaukaLargeSectionEnd() - 600])
surfaceFrame(angle, z, large, true) handrail(900);
for (z = [getNaukaSmallSectionStart() + 200 : 1200 : getNaukaSmallSectionEnd() - 900])
surfaceFrame(angle + 45, z, small, true) handrail(700);
}
}
module naukaHull() {
_naukaPressureHull();
_naukaPortCollars();
_naukaBodyPanels();
_naukaThrusterBlocks();
_naukaWindow();
_naukaHandrails();
}
naukaHull();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
// ============================================================================
// naukaRadiator.scad — Nauka's add-on radiator (RTOd), deployed
// Eric Moderbacher
// 2026-10-21
// G10 p62: launched on Rassvet in 2010 as MLM outfitting. ru Wikipedia: moved
// to Nauka by ERA on VKD-56 (2023-04-19), panels unfolded and filled on
// VKD-58 (2023-05-12); it leaked in October 2023 and its loops were cut off
// on VKD-61. Panel count (a search excerpt of Space.com says three), panel
// sizes, the base and the deploy direction are ASSUMED.
// Frame: base on the hull at z = 0, +Z outward; panels stack outward along
// +Z, broad faces normal to Y.
// ============================================================================
use <../config.scad>
include <../detail.scad>
module naukaRadiator() {
count = getNauka("radiatorPanelCount");
panelLength = getNauka("radiatorPanelLength");
panelWidth = getNauka("radiatorPanelWidth");
base = getNauka("radiatorBaseHeight");
gap = 60;
color("LightGray") translate([-panelWidth / 2, -250, 0]) cube([panelWidth, 500, base]);
color("Silver") translate([0, 0, base]) cylinder(h = count * (panelLength + gap), d = 80);
color("White") for (index = [0 : count - 1])
translate([-panelWidth / 2, -15, base + gap + index * (panelLength + gap)]) cube([panelWidth, 30, panelLength]);
}
naukaRadiator();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
// ============================================================================
// portableWorkPost.scad — ERA's Portable Work Post, stowed on Nauka
// Eric Moderbacher
// 2026-10-21
// G10 p62 / G15 p41: the Portable Work Post (PWP) gives a spacewalker a
// worksite on the end of ERA; it rode Rassvet up in 2010 and was moved to
// Nauka on VKD-60, 2023-08-09 (ru/en Wikipedia). Its size and shape (a frame,
// foot restraint, and a fixture ERA's end effector grips) are ASSUMED.
// ERA's spare elbow stays on Rassvet (G10 p62) and is not drawn here.
// Frame: mounting face on z = 0, frame up +Z.
// ============================================================================
use <../config.scad>
use <../common/placement.scad>
include <../detail.scad>
module portableWorkPost() {
length = getEra("workPostLength");
width = getEra("workPostWidth");
color("DimGray") translate([-length / 2, -width / 2, 0]) cube([length, width, 80]);
color("Gold") for (x = [-1, 1], y = [-1, 1])
rodBetween([x * length * 0.45, y * width * 0.45, 80], [x * length * 0.3, y * width * 0.3, 700], 40);
color("Black") translate([-length * 0.35, -width * 0.35, 700]) cube([length * 0.7, width * 0.7, 60]);
color("Silver") translate([0, 0, 760]) cylinder(h = 200, d = getEra("endEffectorDiameter") * 0.8);
}
portableWorkPost();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71
// ============================================================================
// prichal.scad — Prichal (Node Module, UM), the docking ball on Nauka's nadir
// Eric Moderbacher
// 2026-10-21
// Spaceflight Now 2021-11-26: about 3.3 m across, six docking ports;
// russianspaceweb: six ports facing every direction, docked at Nauka's nadir
// port 2021-11-26; en Wikipedia: 4.91 m long (port face to port face as
// drawn here), zenith port active SSVP-M (to Nauka), the other five passive
// hybrid ports. Port use comes from getPrichalPortUse(). Collar sizes,
// handrails and the equipment boxes are ASSUMED.
// Element frame: z = 0 on the zenith port's mating face at Nauka's nadir
// port, +Z toward station nadir; element +X station forward, +Y starboard.
// ============================================================================
use <../config.scad>
use <../common/placement.scad>
use <../common/handrail.scad>
use <hybridDockingPort.scad>
include <../detail.scad>
function getPrichalPortNames() = [for (row = getPrichalPortUse()) row[0]];
function getPrichalPortDirection(name) =
name == "zenith" ? [0, 0, -1] : name == "nadir" ? [0, 0, 1] :
name == "forward" ? [1, 0, 0] : name == "aft" ? [-1, 0, 0] :
name == "starboard" ? [0, 1, 0] : name == "port" ? [0, -1, 0] :
assert(false, str("unknown Prichal port ", name));
function getPrichalCenter() = [0, 0, getPrichal("length") / 2];
function _startsWith(text, prefix) =
len(text) >= len(prefix) && [for (i = [0 : len(prefix) - 1]) text[i]] == [for (i = [0 : len(prefix) - 1]) prefix[i]];
function isPrichalPortUsed(name) =
let(rows = [for (row = getPrichalPortUse()) if (row[0] == name) row])
assert(len(rows) == 1, str("no use recorded for Prichal port ", name))
!_startsWith(rows[0][1], "unused");
// Origin on the named port's mating face, +Z pointing away from Prichal. Axial ports keep
// element +X; on radial ports local +X points along element +Z (station nadir).
module placeOnPrichalPort(name) {
direction = getPrichalPortDirection(name);
alignFrame(getPrichalCenter() + direction * getPrichal("length") / 2,
abs(direction[2]) > 0.5 ? [1, 0, 0] : [0, 0, 1], direction) children();
}
module prichal() {
for (row = getPrichalPortUse()) echo(str("Prichal port ", row[0], ": ", row[1]));
radius = getPrichal("diameter") / 2;
reach = getPrichal("length") / 2 - getNauka("dockingPortDepth");
color("Beige") translate(getPrichalCenter()) sphere(r = radius);
for (name = getPrichalPortNames()) {
direction = getPrichalPortDirection(name);
color("Beige") alignFrame(getPrichalCenter(), abs(direction[2]) > 0.5 ? [1, 0, 0] : [0, 0, 1], direction)
cylinder(h = reach, d = getPrichal("portCollarDiameter"));
placeOnPrichalPort(name) hybridDockingPort(name == "zenith");
if (!isPrichalPortUsed(name))
color("WhiteSmoke") placeOnPrichalPort(name) cylinder(h = 40, d = getNauka("dockingRingDiameter") + 40);
}
for (angle = [45 : 90 : 315])
let(radial = [cos(angle), sin(angle), 0])
alignFrame(getPrichalCenter() + radial * radius - [0, 0, 350], [0, 0, 1], radial) handrail(700);
color("Tan") for (angle = [45, 225])
let(radial = [cos(angle), sin(angle), 0])
alignFrame(getPrichalCenter() + radial * radius * 0.92 + [0, 0, radius * 0.35], [0, 0, 1], radial)
translate([-250, -200, 0]) cube([500, 400, 250]);
}
prichal();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
// ============================================================================
// apasPort.scad — passive APAS-95 docking port at a PMA's narrow end
// Eric Moderbacher
// 2026-10-21
// Wikipedia (PMA): each PMA carries a passive APAS-95 port. APAS is the
// androgynous Russian-designed system with three guide petals around its ring;
// the ring size, petal shape and latch bumps are ASSUMED. Mating face at
// z = 0 facing +Z, ring extends -Z into the PMA.
// ============================================================================
use <../config.scad>
include <../detail.scad>
module apasPort() {
ring = getPma("apasRingDiameter");
height = getPma("apasRingHeight");
petal = getPma("apasPetalHeight");
color("Silver") translate([0, 0, -height]) difference() {
cylinder(h = height, d = ring);
translate([0, 0, -1]) cylinder(h = height + 2, d = ring - 160);
}
color("LightGray") for (index = [0 : 2]) rotate([0, 0, index * 120])
hull() {
translate([ring / 2 - 90, -ring * 0.22, -10]) cube([60, ring * 0.44, 10]);
translate([ring / 2 - 140, -40, petal - 10]) cube([40, 80, 10]);
}
color("DimGray") for (index = [0 : 11]) rotate([0, 0, index * 30 + 15])
translate([ring / 2 - 50, 0, 0]) cylinder(h = 30, d = 50);
}
apasPort();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
// ============================================================================
// internationalDockingAdapter.scad — International Docking Adapter (IDA)
// Eric Moderbacher
// 2026-10-21
// Wikipedia (IDA): 110 cm high, 160 cm wide, about 240 cm across with docking
// targets and laser retro-reflectors at its edge; a modified APAS ring, a short
// tunnel and a passive hard-mate assembly that converts APAS-95 to the NASA
// Docking System (IDSS). IDA-2 is on PMA-2, IDA-3 on PMA-3. Target and
// reflector count and layout are ASSUMED. Bottom (APAS side) at z = 0, the
// docking face at the top facing +Z.
// ============================================================================
use <../config.scad>
include <../detail.scad>
module internationalDockingAdapter() {
height = getPma("idaHeight");
width = getPma("idaWidth");
outer = getPma("idaOuterDiameter");
color("Silver") cylinder(h = 150, d = getPma("apasRingDiameter"));
color("WhiteSmoke") translate([0, 0, 150]) cylinder(h = height - 300, d = width * 0.85);
color("LightGray") translate([0, 0, height - 150]) difference() {
cylinder(h = 150, d = width);
translate([0, 0, -1]) cylinder(h = 152, d = width - 300);
}
color("DimGray") for (index = [0 : 2]) rotate([0, 0, index * 120 + 60])
translate([width / 2 - 200, -40, height]) cube([60, 80, 200]);
for (index = [0 : 3]) rotate([0, 0, index * 90 + 45]) {
color("Gray") translate([width * 0.4, -30, height * 0.6]) cube([outer / 2 - width * 0.4, 60, 60]);
color("Black") translate([outer / 2 - 40, 0, height * 0.6 + 30]) rotate([0, 0, 0]) cylinder(h = 80, d = 140);
color("White") translate([outer / 2 - 40, 0, height * 0.6 + 110]) cylinder(h = 10, d = 60);
}
}
internationalDockingAdapter();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61
// ============================================================================
// pressurizedMatingAdapter.scad — Pressurized Mating Adapter (PMA)
// Eric Moderbacher
// 2026-10-21
// G10 p64: 1.86 m long, 1.9 m across at the wide end, 1.37 m at the narrow end.
// rdataspace (ISS Components): a truncated cone with a 24 in offset between its
// end rings. Wikipedia: a passive CBM on the wide end, a passive APAS-95 port on
// the narrow end, pressurized and heated, power and data passed through. The
// shell is an oblique cone with parallel end rings; offsetAngle turns the
// direction the narrow end steps off the axis (unsourced, ASSUMED per PMA).
// Wide (berthing) face at z = 0, narrow (docking) face at z = length.
// ============================================================================
use <../config.scad>
use <../common/passiveCbm.scad>
use <../common/handrail.scad>
use <../common/placement.scad>
use <apasPort.scad>
include <../detail.scad>
function getPmaNarrowCenter(offsetAngle) =
[getPma("endOffset") * cos(offsetAngle), getPma("endOffset") * sin(offsetAngle), getPma("length")];
module _obliqueCone(wideDiameter, narrowDiameter, narrowCenter, zStart, zEnd) {
hull() {
translate([0, 0, zStart]) cylinder(h = 0.01, d = wideDiameter);
translate([narrowCenter[0], narrowCenter[1], zEnd - 0.01]) cylinder(h = 0.01, d = narrowDiameter);
}
}
// Origin on the narrow (APAS) face, +Z pointing away from the PMA.
module placeOnPmaNarrowEnd(offsetAngle) {
translate(getPmaNarrowCenter(offsetAngle)) children();
}
module pressurizedMatingAdapter(offsetAngle = 0) {
length = getPma("length");
wide = getPma("wideDiameter");
narrow = getPma("narrowDiameter");
wall = getPma("shellWallThickness");
narrowCenter = getPmaNarrowCenter(offsetAngle);
ringDepth = getBerthing("passiveCbmDepth");
passiveCbm();
color("WhiteSmoke") difference() {
_obliqueCone(wide, narrow, narrowCenter, ringDepth, length - getPma("apasRingHeight"));
_obliqueCone(wide - 2 * wall, narrow - 2 * wall, narrowCenter, ringDepth - 1,
length - getPma("apasRingHeight") + 1);
}
color("WhiteSmoke") translate([0, 0, ringDepth - 120]) cylinder(h = 130, d1 = getBerthing("ringOuterDiameter"), d2 = wide);
placeOnPmaNarrowEnd(offsetAngle) apasPort();
for (angle = [0 : 120 : 240])
let(radial = [cos(angle), sin(angle), 0],
start = radial * wide / 2 + [0, 0, ringDepth + 100],
end = radial * narrow / 2 + [narrowCenter[0], narrowCenter[1], length - 300])
alignFrame(start, end - start, radial) handrail(norm(end - start) * 0.8);
}
pressurizedMatingAdapter(0);
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
// ============================================================================
// dockingTarget.scad — the docking target for ships approaching Poisk's zenith port
// Eric Moderbacher
// 2026-10-21
// Wikipedia Poisk and RSW: the Jan 2010 EVA installed a docking target. Its
// shape (a base disc, a post and a standing cross) and sizes are ASSUMED.
// Origin at the base, +Z along the post toward the approaching ship.
// ============================================================================
use <../config.scad>
include <../detail.scad>
module poiskDockingTarget() {
post = getPoisk("dockingTargetPostLength");
cross = getPoisk("dockingTargetCrossSize");
color("Black") translate([0, 0, post * 0.4]) cylinder(h = 15, d = cross * 1.4);
color("White") {
cylinder(h = post, d = 40);
translate([-cross / 2, -15, post - cross * 0.15]) cube([cross, 30, 30]);
translate([-15, -15, post - cross * 0.15]) cube([30, 30, cross * 0.5]);
}
}
poiskDockingTarget();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
// ============================================================================
// evaHatch.scad — one of Poisk's two 1 m EVA hatches, with its movable handrail
// Eric Moderbacher
// 2026-10-21
// RUWP: two exit hatches of 1000 mm; G10 p60 shows each in a raised rim with a
// porthole in the cover and a movable handrail ringing hatch 2. Rim height,
// width, porthole and rail sizes are ASSUMED; the rim height is chosen so the
// rims across both hatches span G10's 2.55 m maximum diameter.
// Origin on the hull's widest radius at the hatch centre, +Z outward, +X along
// the module axis. The rim reaches down into the hull to meet its curve.
// ============================================================================
use <../config.scad>
include <../detail.scad>
module poiskEvaHatch(handrailShown = true) {
hatch = getPoisk("evaHatchDiameter");
rimWidth = getPoisk("evaHatchRimWidth");
proud = getPoisk("evaHatchRimProud");
rimOuter = hatch + 2 * rimWidth;
sink = 220;
rail = getPoisk("movableHandrailDiameter");
color("Gainsboro") difference() {
translate([0, 0, -sink]) cylinder(h = sink + proud, d = rimOuter);
translate([0, 0, -sink - 1]) cylinder(h = sink + proud + 2, d = hatch);
}
color("WhiteSmoke") translate([0, 0, proud - 90]) cylinder(h = 50, d = hatch + 10);
color("DarkSlateGray") translate([0, 0, proud - 41]) cylinder(h = 6, d = getPoisk("evaHatchWindowDiameter"));
color("Silver") for (side = [-1, 1])
translate([side * hatch * 0.28, -hatch * 0.08, proud - 41]) cube([40, hatch * 0.16, 30]);
if (handrailShown) {
ringRadius = rimOuter / 2 + 80;
color("Gold") {
translate([0, 0, proud - rail]) rotate_extrude() translate([ringRadius, 0]) circle(d = rail);
for (angle = [45 : 90 : 315]) rotate([0, 0, angle])
translate([rimOuter / 2 - 20, -rail / 2, proud - rail * 1.5]) cube([ringRadius - rimOuter / 2 + 20, rail, rail]);
}
}
}
poiskEvaHatch();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
// ============================================================================
// externalWorkstation.scad — a Poisk external payload workstation
// Eric Moderbacher
// 2026-10-21
// G15 p40: five external workstations, one active with three ports and four
// passive, for upper-hemisphere observation and exposure payloads. Plate size,
// standoff and connector shapes are ASSUMED.
// Origin on the hull surface, +Z outward.
// ============================================================================
use <../config.scad>
include <../detail.scad>
module poiskExternalWorkstation(active = false) {
plate = getPoisk("workstationPlateSize");
standoff = getPoisk("workstationStandoff");
ports = getPoisk("activeWorkstationPortCount");
color("LightGray") for (x = [-1, 1], y = [-1, 1])
translate([x * plate * 0.35, y * plate * 0.35, -20]) cylinder(h = standoff + 20, d = 40);
color("Silver") translate([-plate / 2, -plate / 2, standoff]) cube([plate, plate, 25]);
color("DimGray") translate([0, 0, standoff + 25]) cylinder(h = 30, d = plate * 0.3);
if (active)
color("Goldenrod") for (index = [0 : ports - 1])
translate([(index - (ports - 1) / 2) * plate * 0.28, plate * 0.32, standoff + 25]) cylinder(h = 50, d = 50);
}
poiskExternalWorkstation(true);
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
// ============================================================================
// hybridDockingUnit.scad — Poisk's active hybrid docking unit (SSVP-M)
// Eric Moderbacher
// 2026-10-21
// RSW: the Zvezda end carries an active hybrid SSVP-M unit. WDB: the hybrid
// pairs a probe-and-drogue soft dock with an APAS-95 hard-dock collar around an
// 800 mm passage. RSW gives 4.91 m with the probe extended against RUWP's
// 4049 mm between planes, which fixes how far the probe reaches. Collar, hook
// count and probe sizes are ASSUMED. Once docked the probe is retracted, so it
// is drawn only when asked.
// Mating plane at z = 0 facing -Z (toward Zvezda); the unit extends +Z into
// Poisk. An extended probe reaches -Z.
// ============================================================================
use <../config.scad>
include <../detail.scad>
function getPoiskProbeReach() = getPoisk("lengthWithProbeExtended") - getPoisk("lengthBetweenDockingPlanes");
module poiskHybridDockingUnit(probeExtended = false) {
outer = getPoisk("activeUnitDiameter");
passage = getPoisk("dockingPassageDiameter");
hooks = getPoisk("hybridHookCount");
flange = 70;
color("Silver") difference() {
cylinder(h = flange, d = outer + 40);
translate([0, 0, -1]) cylinder(h = flange + 2, d = passage);
}
color("DimGray") for (index = [0 : hooks - 1]) rotate([0, 0, (index + 0.5) * 360 / hooks])
translate([outer / 2 - 70, -35, flange]) cube([60, 70, 90]);
if (probeExtended) {
reach = getPoiskProbeReach();
color("LightGray") {
for (index = [0 : 2]) rotate([0, 0, index * 120])
hull() {
translate([passage / 2 - 40, 0, flange]) sphere(d = 50);
translate([0, 0, -reach * 0.35]) sphere(d = 50);
}
translate([0, 0, -reach]) cylinder(h = reach + flange, d = getPoisk("probeDiameter"));
translate([0, 0, -reach]) cylinder(h = getPoisk("probeHeadDiameter") * 0.6,
d1 = getPoisk("probeDiameter") * 0.5, d2 = getPoisk("probeHeadDiameter"));
}
}
}
poiskHybridDockingUnit(true);
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
// ============================================================================
// passiveDockingUnit.scad — Poisk's outboard passive SSVP (drogue) unit
// Eric Moderbacher
// 2026-10-21
// RSW: the outboard (zenith) end is a standard passive port for Soyuz and
// Progress. WDB: SSVP transfer passage 800 mm. The flange, the receiving cone's
// mouth and depth, and the hook housings are ASSUMED.
// Mating plane at z = 0 facing +Z (away from Poisk); the unit extends -Z.
// ============================================================================
use <../config.scad>
include <../detail.scad>
module poiskPassiveDockingUnit() {
outer = getPoisk("passiveUnitDiameter");
passage = getPoisk("dockingPassageDiameter");
mouth = getPoisk("drogueMouthDiameter");
depth = getPoisk("drogueConeDepth");
flange = 70;
color("Silver") translate([0, 0, -flange]) difference() {
cylinder(h = flange, d = outer + 40);
translate([0, 0, -1]) cylinder(h = flange + 2, d = mouth);
}
color("LightGray") difference() {
translate([0, 0, -depth]) cylinder(h = depth, d1 = getPoisk("probeHeadDiameter") + 60, d2 = mouth);
translate([0, 0, -depth + 15]) cylinder(h = depth - 14, d1 = getPoisk("probeHeadDiameter") + 30, d2 = mouth - 30);
}
color("DimGray") for (index = [0 : 7]) rotate([0, 0, index * 45 + 22.5])
translate([(mouth + outer) / 4 - 30, -30, -flange - 80]) cube([60, 60, 80]);
}
poiskPassiveDockingUnit();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112
// ============================================================================
// poisk.scad — Poisk, Mini-Research Module 2 (MRM2), assembly
// Eric Moderbacher
// 2026-10-21
// G10 p61 and G15 p40: Pirs's near twin, an Orlan EVA airlock with two side
// hatches, a passive Soyuz/Progress port at the zenith end, five external
// workstations (one three-port active) and three internal ones by the side
// windows, two high-gain Kurs dishes, attitude-control and wide-beam antennas.
// RUWP and RSW: 4049 mm between docking planes, 4.91 m with the probe out,
// active hybrid unit toward Zvezda; the Strela GStM-1 boom moved here in 2012.
// The hull profile, every external position, and which station side the
// hatches face are ASSUMED (see config.scad).
// Element frame: z = 0 on the mating plane to Zvezda's zenith port, +Z station
// zenith. Element +X is station forward, +Y station port (ASSUMED clocking).
// ============================================================================
use <../config.scad>
use <../common/moduleShell.scad>
use <../common/handrail.scad>
use <poiskHull.scad>
use <hybridDockingUnit.scad>
use <passiveDockingUnit.scad>
use <evaHatch.scad>
use <poiskAntennas.scad>
use <strelaCrane.scad>
use <externalWorkstation.scad>
use <dockingTarget.scad>
include <../detail.scad>
showProbeExtended = false;
strelaBoomLength = getPoisk("strelaRetractedLength");
// Element angles once installed on Zvezda's zenith port.
function getPoiskWallAngle(name) =
name == "forward" ? 0 : name == "port" ? 90 : name == "aft" ? 180 : name == "starboard" ? 270 :
assert(false, str("unknown wall ", name));
function getPoiskEquipmentCenterZ() = (getPoisk("airlockSectionEndZ") + getPoisk("equipmentSectionEndZ")) / 2;
function getPoiskHatchAngles() = [getPoisk("evaHatch1Angle"), getPoisk("evaHatch1Angle") + getPoisk("evaHatchSpacingDegrees")];
function getPoiskWorkstationAngles() =
[for (index = [0 : getPoisk("externalWorkstationCount") - 1]) 30 + index * 60];
// "zenith": origin on the outboard docking plane, +Z away from Poisk (where Soyuz/Progress dock).
// "zvezda": origin on the Zvezda mating plane, +Z toward Zvezda.
module placeOnPoiskPort(portName) {
if (portName == "zenith") translate([0, 0, getPoisk("lengthBetweenDockingPlanes")]) children();
else if (portName == "zvezda") rotate([180, 0, 0]) children();
else assert(false, str("unknown Poisk port ", portName));
}
module _onHull(angle, z, alongAxis = true, sink = 10) {
surfaceFrame(angle, z, getPoiskHullRadiusAt(z) - sink, alongAxis) children();
}
module _poiskWindows() {
radius = getPoisk("equipmentSectionDiameter") / 2;
window = getPoisk("sideWindowDiameter");
for (index = [0 : getPoisk("sideWindowCount") - 1])
surfaceFrame(60 + index * 120, getPoisk("airlockSectionEndZ") + window * 0.4, radius - 10, true) {
color("Gainsboro") difference() {
cylinder(h = 35, d = window + 80);
translate([0, 0, 20]) cylinder(h = 20, d = window);
}
color("DarkSlateGray") cylinder(h = 25, d = window);
}
}
module _poiskHandrails() {
rail = getCommon("handrailLength");
centerZ = getPoiskEquipmentCenterZ() + 80;
count = getPoisk("handrailRowCount");
for (index = [0 : count - 1])
_onHull(index * 360 / count, centerZ, true, 0) translate([-rail / 2, 0, 0]) handrail();
for (angle = [getPoiskWallAngle("forward"), getPoiskWallAngle("aft")])
_onHull(angle, getPoisk("hullMaxZ"), false, 0) translate([-rail / 2, 0, 0]) handrail();
}
module _poiskAntennas() {
kursZ = 700;
for (index = [0 : getPoisk("kursHighGainAntennaCount") - 1])
_onHull(index * 180, kursZ) poiskKursAntenna();
_onHull(135, kursZ) poiskStubAntenna();
_onHull(225, kursZ) poiskStubAntenna();
_onHull(135, getPoisk("equipmentSectionEndZ") + 150) poiskStubAntenna();
}
module poisk(probeExtended = showProbeExtended, strelaLength = strelaBoomLength) {
for (row = getAssumedRows(getPoiskDimensions()))
echo(str("ASSUMED: poisk ", row[0], " = ", row[1], " (", row[3], ")"));
length = getPoisk("lengthBetweenDockingPlanes");
poiskHull();
poiskHybridDockingUnit(probeExtended);
translate([0, 0, length]) poiskPassiveDockingUnit();
for (angle = getPoiskHatchAngles())
surfaceFrame(angle, getPoisk("hullMaxZ"), getPoisk("hullMaxDiameter") / 2, true) poiskEvaHatch();
for (index = [0 : len(getPoiskWorkstationAngles()) - 1])
_onHull(getPoiskWorkstationAngles()[index], getPoiskEquipmentCenterZ() - 120, true, 0)
poiskExternalWorkstation(index < getPoisk("activeExternalWorkstationCount"));
_onHull(getPoisk("strelaAngle"), getPoisk("equipmentSectionEndZ") - 150, true, 0)
rotate([0, 0, 180]) strelaCrane(strelaLength);
_poiskWindows();
_poiskHandrails();
_poiskAntennas();
rotate([0, 0, 45]) translate([900, 0, 3600]) poiskDockingTarget();
}
poisk();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
// ============================================================================
// poiskAntennas.scad — Poisk's high-gain Kurs dishes and stub antennas
// Eric Moderbacher
// 2026-10-21
// G10 p61 labels two High-Gain Kurs Antennas (dishes on booms), an attitude
// control antenna and a wide-beam antenna; RSW says the Jan 2010 EVA deployed
// the AR-VKA and 2AR-VKA antennas. Every size and shape here is ASSUMED.
// Origin at the mount on the hull, +Z outward.
// ============================================================================
use <../config.scad>
use <../common/placement.scad>
include <../detail.scad>
module poiskKursAntenna() {
boom = getPoisk("kursBoomLength");
dish = getPoisk("kursDishDiameter");
color("LightGray") {
cylinder(h = 30, d = 160);
rodBetween([0, 0, 0], [0, 0, boom], 50);
}
color("WhiteSmoke") translate([0, 0, boom]) difference() {
cylinder(h = dish * 0.2, d1 = 80, d2 = dish);
translate([0, 0, 8]) cylinder(h = dish * 0.2, d1 = 60, d2 = dish - 16);
}
color("DimGray") rodBetween([0, 0, boom], [0, 0, boom + dish * 0.35], 25);
}
module poiskStubAntenna() {
length = getPoisk("stubAntennaLength");
color("LightGray") {
cylinder(h = 30, d = 120);
cylinder(h = length, d = 30);
translate([0, 0, length]) cylinder(h = 20, d = 140);
}
}
poiskKursAntenna();
translate([800, 0, 0]) poiskStubAntenna();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68
// ============================================================================
// poiskHull.scad — Poisk (MRM2) pressure hull, a body of revolution
// Eric Moderbacher
// 2026-10-21
// RUWP gives 4049 mm between the docking-unit planes; RSW says the body is no
// wider than 2.2 m; G10 p61 calls Poisk almost identical to Pirs. The profile
// follows NASA's Pirs cutaway (G10 p60): a short docking collar, an egg-shaped
// airlock section widest at the two EVA hatches, a cylindrical equipment
// section, a lower dome and the outboard collar. Every station along the
// profile is ASSUMED from that drawing's proportions.
// Element frame: z = 0 on the mating plane to Zvezda, +Z toward the outboard
// (zenith) docking plane at z = lengthBetweenDockingPlanes.
// ============================================================================
use <../config.scad>
include <../detail.scad>
function getPoiskProfileSteps() = isFineDetail() ? 24 : 8;
// [z, outerRadius] from the Zvezda face to the outboard face.
function getPoiskHullProfile(steps = getPoiskProfileSteps()) =
let(length = getPoisk("lengthBetweenDockingPlanes"),
activeRadius = getPoisk("activeUnitDiameter") / 2,
activeLength = getPoisk("activeUnitLength"),
bulkheadRadius = getPoisk("upperBulkheadDiameter") / 2,
maxRadius = getPoisk("hullMaxDiameter") / 2,
maxZ = getPoisk("hullMaxZ"),
airlockEnd = getPoisk("airlockSectionEndZ"),
equipmentRadius = getPoisk("equipmentSectionDiameter") / 2,
equipmentEnd = getPoisk("equipmentSectionEndZ"),
domeEnd = getPoisk("lowerDomeEndZ"),
passiveRadius = getPoisk("passiveUnitDiameter") / 2)
concat(
[[0, activeRadius], [activeLength, activeRadius], [activeLength, bulkheadRadius]],
[for (step = [1 : steps]) let(t = step / steps)
[activeLength + (maxZ - activeLength) * t, bulkheadRadius + (maxRadius - bulkheadRadius) * sin(90 * t)]],
[for (step = [1 : steps]) let(t = step / steps)
[maxZ + (airlockEnd - maxZ) * t, equipmentRadius + (maxRadius - equipmentRadius) * cos(90 * t)]],
[[equipmentEnd, equipmentRadius]],
[for (step = [1 : steps]) let(t = step / steps)
[equipmentEnd + (domeEnd - equipmentEnd) * t, passiveRadius + (equipmentRadius - passiveRadius) * cos(90 * t)]],
[[domeEnd, passiveRadius], [length, passiveRadius]]);
function getPoiskHullRadiusAt(z, profile = getPoiskHullProfile(48)) =
let(spans = [for (index = [0 : len(profile) - 2])
let(a = profile[index], b = profile[index + 1])
if (z >= a[0] && z <= b[0] && b[0] > a[0]) a[1] + (b[1] - a[1]) * (z - a[0]) / (b[0] - a[0])])
assert(len(spans) > 0, str("z = ", z, " is off the Poisk hull"))
spans[0];
// Litres enclosed by the inner wall, summed as frusta.
function computePoiskInnerVolumeLitres(profile = getPoiskHullProfile(48)) =
let(wall = getPoisk("shellWallThickness"))
PI / 3 * [for (index = [0 : len(profile) - 2])
let(a = profile[index], b = profile[index + 1], r1 = a[1] - wall, r2 = b[1] - wall)
(b[0] - a[0]) * (r1 * r1 + r1 * r2 + r2 * r2)] * [for (index = [0 : len(profile) - 2]) 1] / 1e6;
module poiskHull() {
profile = getPoiskHullProfile();
wall = getPoisk("shellWallThickness");
color("WhiteSmoke") rotate_extrude()
polygon(concat([for (point = profile) [point[1], point[0]]],
[for (index = [len(profile) - 1 : -1 : 0]) [profile[index][1] - wall, profile[index][0]]]));
}
poiskHull();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
// ============================================================================
// strelaCrane.scad — Strela (GStM-1) telescoping cargo boom on its pedestal
// Eric Moderbacher
// 2026-10-21
// WPS: unpowered telescoping pole, about 1.82 m collapsed, 14 m extended (RUWP
// says 15 m); moved from Pirs to Poisk in Feb 2012 (RUWP). Pedestal height,
// boom diameter and section count are ASSUMED. Boom lies along +X.
// Origin at the pedestal foot on the hull, +Z outward.
// ============================================================================
use <../config.scad>
use <../common/placement.scad>
include <../detail.scad>
module strelaCrane(boomLength = getPoisk("strelaRetractedLength")) {
baseHeight = getPoisk("strelaBaseHeight");
diameter = getPoisk("strelaBoomDiameter");
retracted = getPoisk("strelaRetractedLength");
length = min(max(boomLength, retracted), getPoisk("strelaExtendedLength"));
sections = 5;
extension = (length - retracted) / (sections - 1);
color("LightGray") {
cylinder(h = 40, d = 360);
cylinder(h = baseHeight, d = 160);
translate([0, 0, baseHeight]) sphere(d = 240);
}
color("Silver") translate([0, 0, baseHeight]) rotate([0, 90, 0])
for (index = [0 : sections - 1])
translate([0, 0, index * extension])
cylinder(h = retracted, d = diameter * (1 - index * 0.12));
color("DimGray") translate([length - 60, 0, baseHeight]) rotate([0, 90, 0]) cylinder(h = 120, d = diameter * 1.3);
}
strelaCrane();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80
// ============================================================================
// crewLock.scad — Quest's crew lock: the small chamber with the EVA hatch
// Eric Moderbacher
// 2026-10-21
// G10 p56 and the STS-104 press kit p4: derived from, and similar in size to,
// the Shuttle airlock (63 in inside diameter, NTRS 20130013500 p3); the EVA
// hatch is a 36 by 40 in D-hatch (NTRS 20160010799 p3) in the outboard end,
// the intravehicular hatch in the bulkhead it shares with the equipment lock.
// The inside diameter is the Shuttle airlock's (ASSUMED); length, end bevel,
// debris panels and handrail layout are ASSUMED.
// Frame: z = 0 on the equipment lock's outboard bulkhead, +Z along the crew
// lock's own axis to the hatch end at z = crewLockLength.
// ============================================================================
use <../config.scad>
use <../common/placement.scad>
use <../common/handrail.scad>
use <dHatch.scad>
include <../detail.scad>
// Pressurized volume inside the shell in cubic meters, for checking against the sourced figure.
function computeCrewLockVolumeM3() =
PI / 4 * pow(getQuest("crewLockInnerDiameter"), 2) * getQuest("crewLockLength") / 1e9;
function getCrewLockEndFaceDiameter() = getQuest("crewLockShellDiameter") - 2 * getQuest("crewLockEndChamfer");
module _crewLockShell() {
length = getQuest("crewLockLength");
shell = getQuest("crewLockShellDiameter") / 2;
chamfer = getQuest("crewLockEndChamfer");
wall = getQuest("shellWallThickness");
color("DarkGray") difference() {
union() {
cylinder(h = length - chamfer, r = shell);
translate([0, 0, length - chamfer - 0.01])
cylinder(h = chamfer + 0.01, r1 = shell, r2 = shell - chamfer);
}
translate([0, 0, -1]) cylinder(h = length - wall + 1, d = getQuest("crewLockInnerDiameter"));
translate([0, 0, length - wall - 1]) linear_extrude(wall + 2) dHatchOutline();
}
}
module _crewLockDebrisShield() {
outer = getQuest("crewLockOuterDiameter") / 2;
around = getQuest("crewLockPanelsAround");
rows = getQuest("crewLockPanelRows");
thickness = getQuest("panelThickness");
gap = getQuest("panelGap");
startZ = 40;
rowLength = (getQuest("crewLockLength") - getQuest("crewLockEndChamfer") - 2 * startZ) / rows;
sweep = 360 / around - gap / outer * 180 / PI;
color("Gainsboro")
for (row = [0 : rows - 1], column = [0 : around - 1])
rotate([0, 0, (column + 0.5) * 360 / around]) rotate_extrude(angle = sweep)
translate([outer - thickness, startZ + row * rowLength + gap / 2]) square([thickness, rowLength - gap]);
color("Gray") cylinder(h = startZ, r = outer);
}
module _crewLockHandrails() {
outer = getQuest("crewLockOuterDiameter") / 2;
railLength = getQuest("crewLockLength") * 0.6;
for (angle = [0 : 90 : 270])
let(radial = [cos(angle), sin(angle), 0])
alignFrame(radial * outer + [0, 0, getQuest("crewLockLength") * 0.2], [0, 0, 1], radial)
handrail(railLength);
}
module crewLock(debrisShieldShown = true, thermalCoverOpenAngle = 0) {
length = getQuest("crewLockLength");
_crewLockShell();
if (debrisShieldShown) _crewLockDebrisShield();
_crewLockHandrails();
translate([0, 0, length]) dHatch();
translate([0, 0, length]) dHatchThermalCover(thermalCoverOpenAngle);
rotate([180, 0, 0]) dHatch();
}
crewLock(thermalCoverOpenAngle = 100);
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46
// ============================================================================
// dHatch.scad — the D-shaped hatch of Quest's crew lock, and its thermal cover
// Eric Moderbacher
// 2026-10-21
// NTRS 20160010799 p3: the crew lock egresses through a 36 in by 40 in D-hatch;
// the Shuttle airlock it derives from has D-shaped hatches (NTRS 20130013500
// p3). The hatch opens inward; Wikipedia gives an outward-hinged thermal
// cover. Thickness and the cover's look are ASSUMED. The same shape serves
// the intravehicular hatch between the two locks (ASSUMED).
// The D lies in the XY plane centered on its circle, the flat side on -Y;
// the door's outer face is on z = 0 and it extends -Z.
// ============================================================================
use <../config.scad>
include <../detail.scad>
// 40 in across the round part, 36 in from the flat side to the far edge.
module dHatchOutline(margin = 0) {
across = getQuest("evaHatchHeight") + 2 * margin;
deep = getQuest("evaHatchWidth") + 2 * margin;
intersection() {
circle(d = across);
translate([-across / 2, across / 2 - deep]) square([across, deep]);
}
}
module dHatch() {
thickness = getQuest("hatchThickness");
color("LightGray") translate([0, 0, -thickness]) linear_extrude(thickness) dHatchOutline(-10);
color("Silver") translate([0, 0, -thickness - 40]) cylinder(h = 40, d = 160);
}
// Hinged on the flat side; openAngle 0 lies closed over the hatch, 180 folds flat outward.
module dHatchThermalCover(openAngle = 0) {
across = getQuest("evaHatchHeight");
hingeY = across / 2 - getQuest("evaHatchWidth") - 40;
thickness = getQuest("thermalCoverThickness");
color("WhiteSmoke") translate([0, hingeY, 0]) rotate([openAngle, 0, 0]) translate([0, -hingeY, 0])
linear_extrude(thickness) dHatchOutline(40);
color("DimGray") translate([0, hingeY, 0]) rotate([0, 90, 0]) cylinder(h = across * 0.6, d = 30, center = true);
}
dHatch();
dHatchThermalCover(60);
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102
// ============================================================================
// equipmentLock.scad — Quest's equipment lock: shell, rings, debris panels
// Eric Moderbacher
// 2026-10-21
// G10 p56 and the STS-104 press kit: the larger of Quest's two chambers, 4 m
// across, with the passive berthing ring at its inboard end and the crew lock
// on its outboard bulkhead. A short cone, a drum and a second cone to a flat
// bulkhead follow the G10 p56 cutaway; the lengths, the shell diameter under
// the debris panels and the panel layout are ASSUMED (the drum is lengthened
// from the drawing to meet the sourced 25.7 m^3). The opening for the offset
// crew lock is cut in the bulkhead.
// Element frame: z = 0 is the berthing face, +Z outboard; the inboard cone
// starts where the passive ring ends.
// ============================================================================
use <../config.scad>
include <../detail.scad>
function getQuestConeStart() = getBerthing("passiveCbmDepth");
function getQuestDrumStart() = getQuestConeStart() + getQuest("inboardConeLength");
function getQuestDrumEnd() = getQuestDrumStart() + getQuest("equipmentLockCylinderLength");
function getQuestBulkheadZ() = getQuestDrumEnd() + getQuest("outboardConeLength");
function getQuestDrumMiddle() = (getQuestDrumStart() + getQuestDrumEnd()) / 2;
function getQuestCrewLockCenter() =
getQuest("crewLockOffset") * [cos(getQuest("crewLockOffsetAngle")), sin(getQuest("crewLockOffsetAngle"))];
function _computeFrustumVolume(height, bottomDiameter, topDiameter) =
PI * height / 12 * (bottomDiameter * bottomDiameter + bottomDiameter * topDiameter + topDiameter * topDiameter);
// Pressurized volume inside the shell in cubic meters, for checking against the sourced figure.
function computeEquipmentLockVolumeM3() =
let(wall = 2 * getQuest("shellWallThickness"),
shell = getQuest("equipmentLockShellDiameter") - wall)
(_computeFrustumVolume(getQuest("inboardConeLength"), getQuest("inboardConeSmallDiameter") - wall, shell)
+ PI / 4 * shell * shell * getQuest("equipmentLockCylinderLength")
+ _computeFrustumVolume(getQuest("outboardConeLength"), shell, getQuest("outboardBulkheadDiameter") - wall))
/ 1e9;
function _getShellProfile(radiusOffset, zPad) = [
[0, getQuestConeStart() - zPad],
[getQuest("inboardConeSmallDiameter") / 2 + radiusOffset, getQuestConeStart() - zPad],
[getQuest("equipmentLockShellDiameter") / 2 + radiusOffset, getQuestDrumStart()],
[getQuest("equipmentLockShellDiameter") / 2 + radiusOffset, getQuestDrumEnd()],
[getQuest("outboardBulkheadDiameter") / 2 + radiusOffset, getQuestBulkheadZ() + radiusOffset],
[0, getQuestBulkheadZ() + radiusOffset]];
module _equipmentLockShell() {
wall = getQuest("shellWallThickness");
shell = getQuest("equipmentLockShellDiameter") / 2;
color("DarkGray") difference() {
rotate_extrude() polygon(_getShellProfile(0, 0));
rotate_extrude() polygon(_getShellProfile(-wall, 1));
translate(concat(getQuestCrewLockCenter(), [getQuestBulkheadZ() - 2 * wall]))
cylinder(h = 3 * wall, d = getQuest("crewLockInnerDiameter"));
}
color("Gray")
for (z = [getQuestDrumStart(), getQuestDrumEnd()])
translate([0, 0, z - getQuest("ringWidth") / 2]) difference() {
cylinder(h = getQuest("ringWidth"), r = shell + getQuest("ringHeight"));
translate([0, 0, -1]) cylinder(h = getQuest("ringWidth") + 2, r = shell - 1);
}
}
module _drumPanel(radius, thickness, startZ, length, startAngle, sweep) {
rotate([0, 0, startAngle]) rotate_extrude(angle = sweep)
translate([radius - thickness, startZ]) square([thickness, length]);
}
module _conePanel(startZ, endZ, startRadius, endRadius, thickness, startAngle, sweep) {
rotate([0, 0, startAngle]) rotate_extrude(angle = sweep)
polygon([[startRadius - thickness, startZ], [startRadius, startZ],
[endRadius, endZ], [endRadius - thickness, endZ]]);
}
module _equipmentLockDebrisShield() {
outer = getQuest("equipmentLockOuterDiameter") / 2;
standoff = outer - getQuest("equipmentLockShellDiameter") / 2;
around = getQuest("panelsAround");
thickness = getQuest("panelThickness");
gap = getQuest("panelGap");
sweep = 360 / around - gap / outer * 180 / PI;
color("Gainsboro")
for (column = [0 : around - 1]) {
startAngle = (column + 0.5) * 360 / around;
_drumPanel(outer, thickness, getQuestDrumStart() + gap / 2,
getQuest("equipmentLockCylinderLength") - gap, startAngle, sweep);
_conePanel(getQuestConeStart() + 40, getQuestDrumStart() - gap / 2,
getQuest("inboardConeSmallDiameter") / 2 + standoff, outer, thickness, startAngle, sweep);
_conePanel(getQuestDrumEnd() + gap / 2, getQuestBulkheadZ() - 40,
outer, getQuest("outboardBulkheadDiameter") / 2 + standoff, thickness, startAngle, sweep);
}
}
module equipmentLock(debrisShieldShown = true) {
_equipmentLockShell();
if (debrisShieldShown) _equipmentLockDebrisShield();
}
equipmentLock();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
// ============================================================================
// gasTank.scad — High Pressure Gas Tank (oxygen or nitrogen) on Quest
// Eric Moderbacher
// 2026-10-21
// STS-104 press kit p24: a carbon-fiber-wrapped tank 0.9 m across holding
// 0.42 m^3, under a meteor debris shield and insulation blanket; p19: each
// carries a grapple fixture. Drawn as a near-spherical tank (a short straight
// section makes up the sourced volume) inside an octagonal shield, from the
// G10 p56 end view; shield size and the color band are ASSUMED.
// Frame: mounting face on z = 0, +Z away from the airlock, X along Quest's axis.
// ============================================================================
use <../config.scad>
use <../common/grappleFixture.scad>
include <../detail.scad>
// Length of the straight section that brings a 0.9 m sphere up to the sourced volume.
function computeGasTankStraightLength() =
let(radius = getQuest("gasTankDiameter") / 2)
(getQuest("gasTankVolumeM3") * 1e9 - 4 / 3 * PI * pow(radius, 3)) / (PI * radius * radius);
function _getOctagon(acrossFlats) =
let(circumradius = acrossFlats / 2 / cos(22.5))
[for (index = [0 : 7]) circumradius * [cos(22.5 + index * 45), sin(22.5 + index * 45)]];
module gasTank(isOxygen = true) {
across = getQuest("gasTankShieldAcrossFlats");
length = getQuest("gasTankShieldLength");
radius = getQuest("gasTankDiameter") / 2;
straight = computeGasTankStraightLength();
color("Gainsboro") translate([-length / 2, 0, across / 2]) rotate([0, 90, 0])
linear_extrude(length) polygon(_getOctagon(across));
color(isOxygen ? "SteelBlue" : "DarkSeaGreen")
translate([-length / 2 - 1, 0, across / 2]) rotate([0, 90, 0])
linear_extrude(length + 2) difference() {
polygon(_getOctagon(across * 0.6));
polygon(_getOctagon(across * 0.5));
}
color("DimGray") translate([0, 0, across / 2]) {
for (side = [-1, 1]) translate([side * straight / 2, 0, 0]) sphere(r = radius);
rotate([0, 90, 0]) cylinder(h = straight, r = radius, center = true);
}
translate([0, 0, across]) grappleFixture();
}
gasTank();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126
// ============================================================================
// quest.scad — Quest Joint Airlock assembly
// Eric Moderbacher
// 2026-10-21
// G10 p56, G15 p34 and the STS-104 press kit: 5.5 m long, 4.0 m across, two
// chambers end to end (the equipment lock and the smaller crew lock with the
// EVA hatch), berthed to Unity's starboard port with a passive berthing ring
// (the press kit's seal covers ride on the passive half). Four high-pressure
// gas tanks (two oxygen, two nitrogen) each with a grapple fixture, four
// launch trunnions, one grapple fixture, two toolboxes, two racks and debris
// shields are sourced as present. Every position, and the crew lock's offset
// from the equipment lock's axis, is ASSUMED and echoed.
// Element frame: z = 0 on the berthing face, +Z outboard. Installed on Unity's
// starboard port through placeOnNodeRadialPortOutward, element +X points
// station aft, +Y nadir, +Z starboard (getQuestWallAngle).
// ============================================================================
use <../config.scad>
use <../common/placement.scad>
use <../common/moduleShell.scad>
use <../common/passiveCbm.scad>
use <../common/nodeHatch.scad>
use <../common/trunnion.scad>
use <../common/handrail.scad>
use <../common/grappleFixture.scad>
use <../common/standardRack.scad>
use <equipmentLock.scad>
use <crewLock.scad>
use <gasTank.scad>
use <toolbox.scad>
include <../detail.scad>
showDebrisShield = true;
showRacks = true;
evaThermalCoverOpenAngle = 0;
// Installed on Unity's starboard port, element +X points station aft and +Y nadir.
function getQuestWallAngle(name) =
name == "aft" ? 0 : name == "nadir" ? 90 : name == "forward" ? 180 : name == "zenith" ? 270 :
assert(false, str("unknown wall ", name));
// [angle, isOxygen] for each gas tank on the drum.
function getQuestGasTanks() = [
[getQuest("oxygenTankAngleA"), true], [getQuest("oxygenTankAngleB"), true],
[getQuest("nitrogenTankAngleA"), false], [getQuest("nitrogenTankAngleB"), false]];
function getQuestTrunnionPlacements() =
[for (angle = [getQuest("trunnionAngle"), getQuest("trunnionAngle") + 180],
z = [getQuestDrumStart() + 250, getQuestDrumEnd() - 250]) [angle, z]];
function getQuestToolboxAngles() = [getQuest("toolboxAngleA"), getQuest("toolboxAngleB")];
function getQuestRackAngles() = [0, 180];
// "evaHatch": origin on the crew lock's outboard face, +Z away from Quest.
// "berthing": origin on the berthing face, +Z toward Unity.
module placeOnQuestPort(name) {
if (name == "evaHatch")
translate(concat(getQuestCrewLockCenter(), [getQuest("overallLength")])) children();
else if (name == "berthing")
rotate([180, 0, 0]) children();
else
assert(false, str("unknown Quest port ", name));
}
module _towelBars() {
across = getQuest("gasTankShieldAcrossFlats");
standoff = getQuest("gasTankStandoff");
color("Silver")
for (side = [-1, 1])
translate([-getQuest("gasTankShieldLength") * 0.45, side * across / 3 - 25, -standoff])
cube([getQuest("gasTankShieldLength") * 0.9, 50, standoff]);
}
module _questGasTanks() {
outer = getQuest("equipmentLockOuterDiameter") / 2;
for (tank = getQuestGasTanks())
surfaceFrame(tank[0], getQuestDrumMiddle(), outer + getQuest("gasTankStandoff"), true) {
gasTank(tank[1]);
_towelBars();
}
}
module _questHandrails() {
outer = getQuest("equipmentLockOuterDiameter") / 2;
railLength = getCommon("handrailLength");
for (angle = [55, 345])
surfaceFrame(angle, getQuestDrumMiddle(), outer, false) translate([-railLength / 2, 0, 0]) handrail();
for (angle = [150, 200, 250])
translate([0, 0, getQuestBulkheadZ()]) rotate([0, 0, angle]) translate([900, 0, 0]) handrail();
}
module _questRacks() {
rackRadius = getQuest("equipmentLockShellDiameter") / 2 - getQuest("shellWallThickness")
- getQuest("rackRadialClearance") - getCommon("rackDepth");
for (angle = getQuestRackAngles())
let(radial = [cos(angle), sin(angle), 0], tangent = [-sin(angle), cos(angle), 0])
alignFrame(radial * rackRadius + [0, 0, getQuestDrumMiddle()], [0, 0, 1], tangent) standardRack();
}
module quest(debrisShieldShown = showDebrisShield, racksShown = showRacks,
thermalCoverOpenAngle = evaThermalCoverOpenAngle) {
for (row = getAssumedRows(getQuestDimensions()))
echo(str("ASSUMED: quest ", row[0], " = ", row[1], " (", row[3], ")"));
outer = getQuest("equipmentLockOuterDiameter") / 2;
passiveCbm();
translate([0, 0, getQuestConeStart() + 20]) nodeHatch();
equipmentLock(debrisShieldShown);
translate(concat(getQuestCrewLockCenter(), [getQuestBulkheadZ()]))
crewLock(debrisShieldShown, thermalCoverOpenAngle);
_questGasTanks();
for (placement = getQuestTrunnionPlacements())
surfaceFrame(placement[0], placement[1], outer, true) trunnion();
surfaceFrame(getQuest("grappleFixtureAngle"), getQuestDrumMiddle(), outer, true) grappleFixture();
for (angle = getQuestToolboxAngles())
translate([0, 0, getQuestBulkheadZ()]) rotate([0, 0, angle])
translate([getQuest("toolboxRadius"), 0, 0]) rotate([0, 0, 90]) toolbox();
_questHandrails();
if (racksShown) _questRacks();
}
quest();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
// ============================================================================
// toolbox.scad — EVA toolbox on Quest's outboard bulkhead
// Eric Moderbacher
// 2026-10-21
// G10 p56's end view labels Toolbox 1 and Toolbox 2 beside the crew lock.
// A lidded box with two latches; every size is ASSUMED.
// Frame: base on z = 0, centered, length along X.
// ============================================================================
use <../config.scad>
include <../detail.scad>
module toolbox() {
length = getQuest("toolboxLength");
width = getQuest("toolboxWidth");
height = getQuest("toolboxHeight");
color("WhiteSmoke") translate([-length / 2, -width / 2, 0]) cube([length, width, height * 0.8]);
color("Gainsboro") translate([-length / 2 - 10, -width / 2 - 10, height * 0.8]) cube([length + 20, width + 20, height * 0.2]);
color("DimGray")
for (side = [-1, 1])
translate([side * length / 4 - 20, width / 2, height * 0.6]) cube([40, 20, height * 0.3]);
}
toolbox();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
// ============================================================================
// dockingTarget.scad — docking target and TV camera near Rassvet's nadir port
// Eric Moderbacher
// 2026-10-21
// The STS-132 on-orbit photo (Commons "Rassvet Canadarm Crop") labels a docking
// target ("mishen' stykovki") beside the nadir port; RSW shows a TV camera fitted
// there on the 21 January 2011 spacewalk. Both are drawn as a disc with a cross
// on a standoff post and a small boxed camera; every size is ASSUMED.
// Base on z = 0, post along +Z; target and camera face +X.
// ============================================================================
use <../config.scad>
include <../detail.scad>
module dockingTarget() {
standoff = getRassvet("dockingTargetStandoff");
diameter = getRassvet("dockingTargetDiameter");
color("DimGray") cylinder(h = standoff, d = 40);
translate([0, 0, standoff]) rotate([0, 90, 0]) {
color("White") cylinder(h = 10, d = diameter);
color("Black") translate([0, 0, 10]) {
translate([-diameter * 0.4, -15, 0]) cube([diameter * 0.8, 30, 8]);
translate([-15, -diameter * 0.4, 0]) cube([30, diameter * 0.8, 8]);
translate([0, 0, 8]) cylinder(h = diameter * 0.3, d = 30);
}
}
}
module tvCamera() {
size = getRassvet("tvCameraSize");
color("Gainsboro") translate([-size / 2, -size / 3, 0]) cube([size, size * 2 / 3, size * 0.6]);
color("Black") translate([size / 2, 0, size * 0.3]) rotate([0, 90, 0]) cylinder(h = size * 0.3, d = size * 0.35);
}
dockingTarget();
translate([0, 600, 0]) tvCamera();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
// ============================================================================
// eraSpareElbow.scad — the European Robotic Arm's spare elbow, stowed on Rassvet
// Eric Moderbacher
// 2026-10-21
// G10 p62 and S132 p42: a spare ERA elbow rides on Rassvet's hull "until it is
// needed, if ever". Wikipedia (ERA, secondary) says the spare is the elbow joint
// with two limbs; ESA's brochure p5 gives the flight limbs as 5 m. The S132
// drawing shows two parallel blanketed tubes with the joint at one end, about
// two thirds of the hull long, so the stowed length, diameters and mounts are
// ASSUMED. No source found says it has left Rassvet (the RTOd, ShK and work
// post went to Nauka in 2023); that it remains is ASSUMED.
// Lies along +X from the origin, mounting feet on z = 0.
// ============================================================================
use <../config.scad>
use <../common/placement.scad>
include <../detail.scad>
module eraSpareElbow() {
length = getRassvet("elbowStowedLength");
limb = getRassvet("elbowLimbDiameter");
joint = getRassvet("elbowJointSize");
standoff = getRassvet("elbowStandoff");
axisHeight = standoff + limb / 2;
color("WhiteSmoke")
for (side = [-1, 1])
translate([joint, side * limb * 0.6, axisHeight]) rotate([0, 90, 0])
cylinder(h = length - joint - limb, d = limb);
color("Gainsboro") {
translate([0, -joint / 2, axisHeight - joint / 2]) cube([joint, joint, joint]);
for (side = [-1, 1])
translate([length - limb, side * limb * 0.6, axisHeight]) rotate([0, 90, 0])
cylinder(h = limb, d = limb * 1.3);
}
color("DimGray")
for (x = [joint / 2, length - limb / 2])
rodBetween([x, 0, 0], [x, 0, axisHeight - limb / 2], 60);
}
eraSpareElbow();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
// ============================================================================
// kursAntenna.scad — Kurs rendezvous antennas on Rassvet's hull
// Eric Moderbacher
// 2026-10-21
// GCTC: Kurs system antennas are mounted outside the hull, so Soyuz and Progress
// can dock automatically at the nadir port. The 2024 photo iss072e451610 shows a
// dish on a boom near that port. Count, sizes and shapes are ASSUMED: one dish
// on a boom and one short whip.
// Base on z = 0, boom or whip along +Z; the dish faces +X.
// ============================================================================
use <../config.scad>
include <../detail.scad>
module kursDishAntenna() {
boom = getRassvet("kursBoomLength");
dish = getRassvet("kursDishDiameter");
color("Gainsboro") {
cylinder(h = 20, d = 120);
cylinder(h = boom, d = 50);
}
color("WhiteSmoke") translate([0, 0, boom]) rotate([0, 90, 0]) difference() {
cylinder(h = dish * 0.25, d1 = dish * 0.3, d2 = dish);
translate([0, 0, 10]) cylinder(h = dish * 0.25, d1 = dish * 0.3, d2 = dish - 20);
}
}
module kursWhipAntenna() {
color("Gainsboro") {
cylinder(h = 20, d = 100);
cylinder(h = getRassvet("kursWhipLength"), d1 = 40, d2 = 15);
}
}
kursDishAntenna();
translate([500, 0, 0]) kursWhipAntenna();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117
// ============================================================================
// rassvet.scad — Rassvet (Mini-Research Module 1, MRM1) assembly, as of 2026
// Eric Moderbacher
// 2026-10-21
// S132 p40 and G10 p62: 6.0 m between docking planes, 2.35 m maximum hull
// diameter; berthed by Canadarm2 to Zarya's nadir port in May 2010. GCTC: an
// active docking unit to Zarya, a passive axial unit for Soyuz and Progress,
// Kurs antennas, EVA handrails, radiators and the shuttle-bay fittings outside.
// S132 p42: one PVGF for the station arm. Launched carrying the ShK airlock,
// RTOd radiator and ERA work post for Nauka; those moved to Nauka in 2023
// (Wikipedia Nauka, NASA blog 2023-04-19) and are not drawn. The ERA spare
// elbow is drawn as still stowed (no source says it moved; ASSUMED). Where each
// item sits around the hull, and which side faced the orbiter's bay doors, is
// ASSUMED and echoed.
// Element frame: z = 0 on the Zarya-side docking plane, +Z toward the nadir
// port. Installed on Zarya nadir, element +X points station forward, +Y
// starboard, +Z nadir (getRassvetWallAngle).
// ============================================================================
use <../config.scad>
use <../common/placement.scad>
use <../common/moduleShell.scad>
use <../common/trunnion.scad>
use <../common/handrail.scad>
use <../common/powerDataGrappleFixture.scad>
use <rassvetHull.scad>
use <ssvpDockingUnit.scad>
use <eraSpareElbow.scad>
use <kursAntenna.scad>
use <dockingTarget.scad>
include <../detail.scad>
showSpareElbow = true;
// Installed on Zarya's nadir port with station.scad's "-z" placement: element +X stays station
// forward, so element +Y is station starboard.
function getRassvetWallAngle(name) =
name == "forward" ? 0 : name == "starboard" ? 90 : name == "aft" ? 180 : name == "port" ? 270 :
assert(false, str("unknown wall ", name));
function getRassvetPortZ(portName) =
portName == "nadir" ? getRassvet("lengthBetweenDockingPlanes") :
portName == "zarya" ? 0 :
assert(false, str("unknown port ", portName));
// Origin on the port's docking plane, +Z pointing away from Rassvet.
module placeOnRassvetPort(portName) {
if (portName == "nadir") translate([0, 0, getRassvetPortZ("nadir")]) children();
else if (portName == "zarya") rotate([180, 0, 0]) children();
else assert(false, str("unknown port ", portName));
}
function _getRassvetAngle(offsetFromTop) = getRassvet("payloadBayTopAngle") + offsetFromTop;
function getRassvetHandrailAngles() =
let(count = getRassvet("handrailLinesAround"), half = floor(count / 2))
[for (index = [0 : count - 1])
_getRassvetAngle(index < half ? 100 + 40 * index : 260 + 40 * (index - half))];
module _trunnionOnStruts(angle, z) {
screen = getRassvetScreenRadius();
standoff = getRassvet("trunnionStrutStandoff");
radial = [cos(angle), sin(angle), 0];
fitting = radial * (screen + standoff) + [0, 0, z];
surfaceFrame(angle, z, screen + standoff, true) trunnion();
color("Gainsboro")
for (offset = [-350, 350])
rodBetween(radial * (screen - 20) + [0, 0, z + offset], fitting, 70);
}
module _rassvetTrunnions() {
for (side = [90, -90], z = [getRassvet("trunnionNearZ"), getRassvet("trunnionFarZ")])
_trunnionOnStruts(_getRassvetAngle(side), z);
for (index = [0 : getRassvet("keelTrunnionCount") - 1])
_trunnionOnStruts(_getRassvetAngle(180),
(getRassvet("trunnionNearZ") + getRassvet("trunnionFarZ")) / 2 + index * 400);
}
module _rassvetHandrails() {
start = getRassvetCylinderStart() + 250;
span = getRassvetCylinderEnd() - 600 - start;
count = getRassvet("handrailsPerLine");
for (angle = getRassvetHandrailAngles(), index = [0 : count - 1])
surfaceFrame(angle, start + index * span / max(count - 1, 1), getRassvetScreenRadius(), true)
handrail();
}
module _rassvetNadirEndFixtures() {
screen = getRassvetScreenRadius();
z = getRassvetCylinderEnd() - 250;
surfaceFrame(_getRassvetAngle(120), z, screen, true) kursDishAntenna();
surfaceFrame(_getRassvetAngle(300), z, screen, true) kursWhipAntenna();
surfaceFrame(_getRassvetAngle(150), z - 150, screen, true) dockingTarget();
surfaceFrame(_getRassvetAngle(210), z, screen, true) tvCamera();
}
module rassvet(spareElbowShown = showSpareElbow) {
for (row = getAssumedRows(getRassvetDimensions()))
echo(str("ASSUMED: rassvet ", row[0], " = ", row[1], " (", row[3], ")"));
rassvetHull([_getRassvetAngle(-25), _getRassvetAngle(25)]);
ssvpActiveUnit();
placeOnRassvetPort("nadir") ssvpPassiveUnit();
surfaceFrame(_getRassvetAngle(getRassvet("pvgfAngleFromTop")), getRassvet("pvgfZ"), getRassvetScreenRadius(), true)
powerDataGrappleFixture();
_rassvetTrunnions();
_rassvetHandrails();
_rassvetNadirEndFixtures();
if (spareElbowShown)
surfaceFrame(_getRassvetAngle(getRassvet("elbowAngleFromTop")), getRassvet("elbowStartZ"),
getRassvetScreenRadius(), true)
eraSpareElbow();
}
rassvet();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114
// ============================================================================
// rassvetHull.scad — Rassvet pressure hull, debris screens and radiators
// Eric Moderbacher
// 2026-10-21
// S132 p40: 6.0 m between the docking planes, 2.35 m maximum hull diameter,
// 17.4 m3 pressurized. GCTC: one sealed aluminium-magnesium compartment of
// 2.2 m maximum diameter, closed outside by micrometeoroid screens and
// blankets, with radiators on the hull; RSW: shaped like a diver's air tank.
// Drawn as a 2.2 m cylinder with half-ellipsoid end domes and a docking tunnel
// at each end, screens standing off to the sourced 2.35 m. Dome depth, collar
// length, screen layout and radiator size are ASSUMED; the test checks the
// drawn volume against the sourced 17.4 m3.
// Element frame: z = 0 on the Zarya-side docking plane, +Z to the nadir plane.
// ============================================================================
use <../config.scad>
include <../detail.scad>
function getRassvetHullRadius() = getRassvet("pressureHullDiameter") / 2;
function getRassvetScreenRadius() = getRassvet("maxDiameter") / 2;
function getRassvetCylinderStart() = getRassvet("dockingCollarLength") + getRassvet("endDomeDepth");
function getRassvetCylinderEnd() = getRassvet("lengthBetweenDockingPlanes") - getRassvetCylinderStart();
function _computeDomeSegmentVolume(radialAxis, axialAxis, throatRadius) =
let(axialExtent = axialAxis * sqrt(1 - pow(throatRadius / radialAxis, 2)))
PI * radialAxis * radialAxis * (axialExtent - pow(axialExtent, 3) / (3 * axialAxis * axialAxis));
function _computeDomeAxialExtent(radialAxis, axialAxis, throatRadius) =
axialAxis * sqrt(1 - pow(throatRadius / radialAxis, 2));
function computeRassvetInternalVolumeLitres() =
let(wall = getRassvet("shellWallThickness"),
innerRadius = getRassvetHullRadius() - wall,
innerDome = getRassvet("endDomeDepth") - wall,
passageRadius = getRassvet("dockingPassageDiameter") / 2,
cylinder = PI * innerRadius * innerRadius * (getRassvetCylinderEnd() - getRassvetCylinderStart()),
dome = _computeDomeSegmentVolume(innerRadius, innerDome, passageRadius),
tunnelLength = getRassvetCylinderStart() - _computeDomeAxialExtent(innerRadius, innerDome, passageRadius),
tunnel = PI * passageRadius * passageRadius * tunnelLength)
(cylinder + 2 * dome + 2 * tunnel) / 1e6;
function _getDomeSteps() = isFineDetail() ? 24 : 8;
// Half profile [r, z] of one end, from the tunnel at the docking plane to the cylinder.
function _getEndProfile(hullRadius, domeDepth, tunnelRadius) =
let(cylinderStart = getRassvetCylinderStart(),
startAngle = asin(tunnelRadius / hullRadius),
steps = _getDomeSteps())
concat([[tunnelRadius, 0]],
[for (step = [0 : steps])
let(angle = startAngle + (90 - startAngle) * step / steps)
[hullRadius * sin(angle), cylinderStart - domeDepth * cos(angle)]]);
function _getHullProfile(radiusOffset) =
let(length = getRassvet("lengthBetweenDockingPlanes"),
end = _getEndProfile(getRassvetHullRadius() + radiusOffset, getRassvet("endDomeDepth") + radiusOffset,
getRassvet("dockingTunnelDiameter") / 2 + radiusOffset))
concat([[0, 0]], end, [for (index = [len(end) - 1 : -1 : 0]) [end[index][0], length - end[index][1]]],
[[0, length]]);
module _hullSolid(radiusOffset) {
rotate_extrude() polygon(_getHullProfile(radiusOffset));
}
module rassvetPressureHull() {
length = getRassvet("lengthBetweenDockingPlanes");
color("Wheat") difference() {
_hullSolid(0);
_hullSolid(-getRassvet("shellWallThickness"));
translate([0, 0, -1]) cylinder(h = length + 2, d = getRassvet("dockingPassageDiameter"));
}
}
module rassvetDebrisScreens() {
outer = getRassvetScreenRadius();
thickness = 4;
gap = getRassvet("screenGap");
rows = getRassvet("screenRowCount");
around = getRassvet("screenPanelsAround");
start = getRassvetCylinderStart();
rowLength = (getRassvetCylinderEnd() - start) / rows;
sweep = 360 / around - gap / outer * 180 / PI;
color("AntiqueWhite")
for (row = [0 : rows - 1], column = [0 : around - 1])
rotate([0, 0, column * 360 / around]) rotate_extrude(angle = sweep)
translate([outer - thickness, start + row * rowLength + gap / 2]) square([thickness, rowLength - gap]);
color("Tan")
for (row = [0 : rows])
translate([0, 0, start + row * rowLength - 20]) difference() {
cylinder(h = 40, r = outer - 10);
translate([0, 0, -1]) cylinder(h = 42, r = getRassvetHullRadius() - 1);
}
}
module rassvetRadiators(centerAngles) {
radius = getRassvetScreenRadius() + getRassvet("radiatorStandoff");
length = getRassvet("radiatorLength");
arc = getRassvet("radiatorArcDegrees");
middle = (getRassvetCylinderStart() + getRassvetCylinderEnd()) / 2;
color("White")
for (angle = centerAngles)
rotate([0, 0, angle - arc / 2]) rotate_extrude(angle = arc)
translate([radius, middle - length / 2]) square([12, length]);
}
module rassvetHull(radiatorAngles = [0, 180]) {
rassvetPressureHull();
rassvetDebrisScreens();
rassvetRadiators(radiatorAngles);
}
rassvetHull();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
// ============================================================================
// ssvpDockingUnit.scad — SSVP probe-and-drogue docking units at Rassvet's ends
// Eric Moderbacher
// 2026-10-21
// GCTC: an active docking unit (ASA) docks Rassvet to Zarya's nadir port and a
// passive axial unit (ASP-O) receives Soyuz and Progress. WSSVP: the probe and
// drogue system with an 800 mm transfer tunnel and eight hard-capture hooks;
// Rassvet's nadir port is listed among the passive SSVP-G4000 ports. Ring size,
// drogue depth and hook shape are ASSUMED. The active unit's probe is not drawn:
// once docked it sits inside Zarya's drogue, and crews remove it to open the hatch.
// Both units: mating plane at z = 0, +Z pointing away from Rassvet, body in -Z.
// ============================================================================
use <../config.scad>
include <../detail.scad>
module _ssvpRing() {
ring = getRassvet("dockingRingDiameter");
height = getRassvet("dockingRingHeight");
color("Silver") translate([0, 0, -height]) difference() {
cylinder(h = height, d = ring);
translate([0, 0, -1]) cylinder(h = height + 2, d = getRassvet("dockingPassageDiameter"));
}
color("DimGray")
for (index = [0 : getRassvet("dockingHookCount") - 1])
rotate([0, 0, (index + 0.5) * 360 / getRassvet("dockingHookCount")])
translate([ring / 2 - 70, -30, -height - 60]) cube([60, 60, 60]);
}
module ssvpActiveUnit() {
_ssvpRing();
color("Gainsboro") translate([0, 0, -getRassvet("dockingRingHeight") - 150]) difference() {
cylinder(h = 150, d = getRassvet("dockingTunnelDiameter") + 60);
translate([0, 0, -1]) cylinder(h = 152, d = getRassvet("dockingPassageDiameter"));
}
}
module ssvpPassiveUnit() {
depth = getRassvet("drogueConeDepth");
mouth = getRassvet("drogueMouthDiameter");
_ssvpRing();
color("Peru") translate([0, 0, -depth]) difference() {
cylinder(h = depth - 1, d = getRassvet("dockingTunnelDiameter") - 2);
translate([0, 0, 40]) cylinder(h = depth, d1 = 120, d2 = mouth);
}
}
ssvpActiveUnit();
translate([0, 0, 800]) ssvpPassiveUnit();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
// ============================================================================
// betaGimbal.scad — Beta Gimbal Assembly and the mast canister it turns
// Eric Moderbacher
// 2026-10-21
// CBS/Boeing: the BGA measures 3 x 3 x 3 ft; it turns its wing for the beta
// angle (Wikipedia). The mast canister ("mast can", where the iROSA brackets
// mount) holds the stowed FAST mast; its diameter is ASSUMED and its length is
// whatever is left of the 35 m wing after the BGA and the mast. BGA base at
// the origin, axis +X.
// ============================================================================
use <../config.scad>
include <../detail.scad>
function getMastCanisterLength() =
getSolarArray("wingLength") - getSolarArray("betaGimbalSize") - getSolarArray("mastLength");
module betaGimbal() {
size = getSolarArray("betaGimbalSize");
color("LightGray") translate([0, -size / 2, -size / 2]) cube([size * 0.7, size, size]);
color("DimGray") translate([size * 0.7, 0, 0]) rotate([0, 90, 0]) cylinder(h = size * 0.3, d = size * 0.9);
}
module mastCanister() {
diameter = getSolarArray("mastCanisterDiameter");
color("WhiteSmoke") rotate([0, 90, 0]) cylinder(h = getMastCanisterLength(), d = diameter);
color("Silver") translate([getMastCanisterLength() - 40, 0, 0]) rotate([0, 90, 0])
cylinder(h = 40, d = diameter * 1.15);
}
betaGimbal();
translate([getSolarArray("betaGimbalSize"), 0, 0]) mastCanister();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
// ============================================================================
// blanketBox.scad — Solar Array Blanket Box (SABB)
// Eric Moderbacher
// 2026-10-21
// CBS/Boeing fact sheet and Wikipedia: 20 in high and 15 ft long; a folded
// blanket stows inside. Each wing has one at the root and one at the tip of
// each blanket. Depth and the lid detail are ASSUMED. The 20 in height runs
// along X (the folded stack), the 15 ft length along Y; centered in Y and Z.
// ============================================================================
use <../config.scad>
include <../detail.scad>
module blanketBox() {
height = getSolarArray("blanketBoxHeight");
length = getSolarArray("blanketWidth");
depth = getSolarArray("blanketBoxDepth");
color("WhiteSmoke") translate([0, -length / 2, -depth / 2]) cube([height, length, depth]);
color("Silver")
for (y = [-length / 2 + 200 : (length - 400) / 5 : length / 2 - 199])
translate([-20, y - 40, -depth / 2 - 10]) cube([height + 40, 80, depth + 20]);
}
blanketBox();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
// ============================================================================
// fastMast.scad — Folding Articulated Square Truss (FAST) mast
// Eric Moderbacher
// 2026-10-21
// Source: Elliott, Horta, Templeton, Knight, NASA LaRC (NTRS 20120008397):
// 1296 in deployed, 30.4 in square, 32 bays; each bay has eight longerons
// (upper and lower, folding at a mid-bay elbow), rigid battens at the bay ends,
// flexible battens at the elbows, and wire diagonals. Member shapes are
// simplified to square bars and thin rods. Diagonals and flexible battens are
// drawn only at fine detail. Root at the origin, extends +X, centered in Y and Z.
// ============================================================================
use <../config.scad>
use <../common/placement.scad>
include <../detail.scad>
module _squareFrame(width, bar) {
half = width / 2;
for (y = [-half, half - bar]) translate([0, y, -half]) cube([bar, bar, width]);
for (z = [-half, half - bar]) translate([0, -half, z]) cube([bar, width, bar]);
}
module fastMast(length = getSolarArray("mastLength")) {
width = getSolarArray("mastWidth");
bar = getSolarArray("mastLongeronSize");
bays = getSolarArray("mastBayCount");
bay = length / bays;
half = width / 2 - bar / 2;
color("Gainsboro") {
for (corner = [[-half, -half], [-half, half], [half, -half], [half, half]])
translate([0, corner[0] - bar / 2, corner[1] - bar / 2]) cube([length, bar, bar]);
for (index = [0 : bays]) translate([index * bay - bar / 2, 0, 0]) _squareFrame(width, bar * 1.6);
}
if (isFineDetail())
color("DarkGray") for (index = [0 : bays - 1]) {
translate([(index + 0.5) * bay, 0, 0]) _squareFrame(width * 0.96, bar * 0.6);
for (step = [0, 1], face = [0 : 3])
let(x0 = index * bay + step * bay / 2, x1 = x0 + bay / 2)
rotate([face * 90, 0, 0])
rodBetween([x0, -half, half], [x1, half, half], 4);
}
}
fastMast();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
// ============================================================================
// photovoltaicRadiator.scad — Photovoltaic Radiator (PVR)
// Eric Moderbacher
// 2026-10-21
// CBS/Boeing: deployed 44 x 12 x 7 ft, seven panels, two flow paths. Drawn as
// seven panels in a row along +X with hinge links between them; panel
// thickness, gaps and the deploy direction are ASSUMED. Root at the origin,
// panels in the XY plane, centered in Y.
// ============================================================================
use <../config.scad>
include <../detail.scad>
module photovoltaicRadiator() {
length = getSolarArray("radiatorLength");
width = getSolarArray("radiatorWidth");
count = getSolarArray("radiatorPanelCount");
thickness = getSolarArray("radiatorPanelThickness");
gap = getSolarArray("radiatorPanelGap");
pitch = length / count;
for (index = [0 : count - 1]) translate([index * pitch + gap / 2, -width / 2, -thickness / 2]) {
color("White") cube([pitch - gap, width, thickness]);
if (isFineDetail())
color("Gainsboro") for (y = [width / 8 : width / 8 : width - 1])
translate([0, y - 10, thickness]) cube([pitch - gap, 20, 6]);
}
color("DimGray") for (index = [0 : count - 1], y = [-1, 1])
translate([index * pitch - gap, y * (width / 2 - 100) - 40, -thickness]) cube([gap * 2, 80, thickness * 2]);
}
photovoltaicRadiator();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
// ============================================================================
// rigidPanelWing.scad — a Russian-style array wing of rigid hinged panels
// Eric Moderbacher
// 2026-10-21
// Used for Zvezda, whose 29.7 m wingspan is sourced (G10 p63) but whose panel
// layout is not: panel count, frame and gaps are ASSUMED. Root at the origin,
// along +X, cells facing +Z, centered in Y.
// ============================================================================
include <../detail.scad>
module rigidPanelWing(length, width, panelCount = 4) {
gap = 60;
pitch = length / panelCount;
color("Gray") translate([0, -40, -40]) cube([length, 80, 80]);
for (index = [0 : panelCount - 1]) translate([index * pitch + gap / 2, -width / 2, 0]) {
color("MidnightBlue") cube([pitch - gap, width, 25]);
color("Silver") difference() {
translate([-20, -20, -10]) cube([pitch - gap + 40, width + 40, 30]);
translate([0, 0, -20]) cube([pitch - gap, width, 60]);
}
}
}
rigidPanelWing(12000, 3000);
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
// ============================================================================
// rollOutArray.scad — ISS Roll-Out Solar Array (iROSA) with its mounting bracket
// Eric Moderbacher
// 2026-10-21
// NASA blog 2023-06-15: 60 x 20 ft. Spaceflight Now 2021: two slit-tube carbon
// booms 6.5 in across, canted 10 degrees to the old wing. Wikipedia: mounted on
// the mast can by a bracket, over the central part of the old wing for up to two
// thirds of its length. Bracket height, root yoke and tip mandrel are ASSUMED.
// Frame: the old wing's, root of the bracket at the origin, the array runs +X,
// rises toward +Z (the cell side of the old wing).
// ============================================================================
use <../config.scad>
use <solarBlanket.scad>
include <../detail.scad>
module rollOutArray() {
length = getSolarArray("rollOutLength");
width = getSolarArray("rollOutWidth");
boom = getSolarArray("rollOutBoomDiameter");
standoff = getSolarArray("rollOutStandoff");
mandrel = getSolarArray("rollOutMandrelDiameter");
color("Silver") for (y = [-1, 1], x = [0, 900])
hull() {
translate([x, y * 300, 0]) cube([80, 80, 10], center = true);
translate([600, y * width / 3, standoff]) cube([80, 80, 10], center = true);
}
translate([600, 0, standoff]) rotate([0, -getSolarArray("rollOutCantDegrees"), 0]) {
color("DimGray") translate([0, -width / 2 - boom, -boom / 2]) cube([boom * 1.5, width + 2 * boom, boom]);
color("DarkSlateGray") for (y = [-1, 1])
translate([0, y * (width / 2 + boom / 2), 0]) rotate([0, 90, 0]) cylinder(h = length, d = boom);
translate([boom * 1.5, 0, 0])
solarBlanket(length - boom * 1.5 - mandrel, width, isFineDetail() ? 40 : 10);
color("Gray") translate([length - mandrel / 2, 0, 0]) rotate([90, 0, 0])
cylinder(h = width + 2 * boom, d = mandrel, center = true);
}
}
rollOutArray();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
// ============================================================================
// solarArrayWing.scad — one main Solar Array Wing (SAW) on its beta gimbal
// Eric Moderbacher
// 2026-10-21
// Sources: Wikipedia, CBS/Boeing, Lockheed Martin, NASA LaRC (see config.scad):
// 35 m long, 38 ft across, two blankets either side of a FAST mast, blanket
// boxes at root and tip, a tip bar tying the tip boxes to the mast, turned by
// a beta gimbal. With rollOut, an iROSA rides over the wing on a bracket from
// the mast can. Frame: BGA base at the origin, wing along +X, blankets in the
// XY plane with cells facing +Z, betaAngle turns everything past the BGA about X.
// ============================================================================
use <../config.scad>
use <betaGimbal.scad>
use <fastMast.scad>
use <solarBlanket.scad>
use <blanketBox.scad>
use <rollOutArray.scad>
include <../detail.scad>
function getBlanketCenterOffset() = (getSolarArray("wingWidth") - getSolarArray("blanketWidth")) / 2;
module solarArrayWing(betaAngle = 0, withRollOut = false) {
gimbal = getSolarArray("betaGimbalSize");
mastStart = gimbal + getMastCanisterLength();
mastLength = getSolarArray("mastLength");
boxHeight = getSolarArray("blanketBoxHeight");
offset = getBlanketCenterOffset();
bar = getSolarArray("tipBarSize");
betaGimbal();
rotate([betaAngle, 0, 0]) {
translate([gimbal, 0, 0]) mastCanister();
translate([mastStart, 0, 0]) fastMast(mastLength);
for (side = [-1, 1]) translate([0, side * offset, 0]) {
translate([mastStart, 0, 0]) blanketBox();
translate([mastStart + mastLength - boxHeight, 0, 0]) blanketBox();
translate([mastStart + boxHeight, 0, 0]) solarBlanket(mastLength - 2 * boxHeight);
}
color("Silver") {
translate([mastStart + mastLength - bar, -offset - getSolarArray("blanketWidth") / 4, -bar / 2])
cube([bar, 2 * offset + getSolarArray("blanketWidth") / 2, bar]);
translate([mastStart, -offset, -bar / 2]) cube([bar, 2 * offset, bar]);
}
if (withRollOut) translate([gimbal + getMastCanisterLength() / 2, 0, 0]) rollOutArray();
}
}
solarArrayWing(0, true);
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
// ============================================================================
// solarBlanket.scad — one photovoltaic blanket of folded panels
// Eric Moderbacher
// 2026-10-21
// Sources: blanket 4.57 m wide (Wikipedia, blanket box length); panels fold
// accordion-style into the blanket box. The panel count is unconfirmed (82
// active per Wikipedia, 84 total per an untraced source). Cell side faces +Z
// (dark), Kapton back faces -Z (gold); the colors are ASSUMED from photos.
// Lies along +X from the origin, centered in Y, in the XY plane.
// ============================================================================
use <../config.scad>
include <../detail.scad>
module solarBlanket(length, width = getSolarArray("blanketWidth"), panelCount = getSolarArray("blanketPanelCount")) {
thickness = getSolarArray("blanketThickness");
gap = getSolarArray("blanketPanelGap");
pitch = length / panelCount;
for (index = [0 : panelCount - 1])
translate([index * pitch + gap / 2, -width / 2, 0]) {
color("MidnightBlue") cube([pitch - gap, width, thickness / 2]);
color("DarkGoldenrod") translate([0, 0, -thickness / 2]) cube([pitch - gap, width, thickness / 2]);
}
if (isFineDetail())
color("SlateGray") for (y = [-width / 2 + width / 8 : width / 4 : width / 2])
translate([0, y - 6, thickness / 2]) cube([length, 12, 2]);
}
solarBlanket(8000);
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
// ============================================================================
// heatExchanger.scad — external water-to-ammonia heat exchanger
// Eric Moderbacher
// 2026-10-21
// NASA "Node 3 Description" p3: two heat exchangers on the external side of
// the passive-end cone carry the node's heat to the station ammonia loops.
// Box, fluid lines and sizes are ASSUMED. Base on z = 0, length along X.
// ============================================================================
use <../config.scad>
include <../detail.scad>
module heatExchanger() {
length = getTranquility("heatExchangerLength");
width = getTranquility("heatExchangerWidth");
height = getTranquility("heatExchangerHeight");
color("Silver") translate([-length / 2, -width / 2, 0]) cube([length, width, height]);
color("WhiteSmoke") translate([-length / 2 + 30, -width / 2 + 30, height]) cube([length - 60, width - 60, 20]);
color("DimGray")
for (y = [-width / 4, width / 4])
translate([-length / 2, y, height / 2]) rotate([0, -90, 0]) cylinder(h = 120, d = 40);
}
heatExchanger();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71
// ============================================================================
// tranquility.scad — Tranquility (Node 3) assembly
// Eric Moderbacher
// 2026-10-21
// The shared Node 2/3 build (common/europeanNode.scad) with Tranquility's own
// parts: two heat exchangers on the passive-end cone (NASA Node 3 Description
// p3), one FRGF (p7), and the PDGF reserved for Dextre on the zenith port
// (Wikipedia). Their positions on the hull are ASSUMED and echoed.
// Once installed, element +X is station forward, +Y nadir, +Z station port.
// ============================================================================
use <../config.scad>
use <../common/placement.scad>
use <../common/moduleShell.scad>
use <../common/europeanNode.scad>
use <../common/grappleFixture.scad>
use <../common/powerDataGrappleFixture.scad>
use <heatExchanger.scad>
include <../detail.scad>
showDebrisShield = true;
showRacks = true;
radialCoverOpenAngle = 120;
function getTranquilitySpec() = makeEuropeanNodeSpec(
getTranquility("overallLength"), getTranquility("endConeLength"), getTranquility("portBayLength"),
getTranquility("pressureShellDiameter"), getTranquility("endConeSmallDiameter"),
getTranquility("outerDiameter"), getTranquility("radialPortFaceProud"),
getTranquility("radialPortBossDiameter"), getTranquility("shellWallThickness"),
getTranquility("reinforcementRingWidth"), getTranquility("reinforcementRingHeight"));
// Element angles of the radial ports once installed (element +Y is nadir).
function getTranquilityPortAngle(name) =
name == "forward" ? 0 : name == "nadir" ? 90 : name == "aft" ? 180 : name == "zenith" ? 270 :
assert(false, str("unknown port ", name));
module _heatExchangers(spec) {
small = getModuleValue(spec, "smallRadius");
outer = getModuleValue(spec, "outerRadius");
coneStart = getModuleValue(spec, "coneAStart");
coneEnd = getModuleValue(spec, "coneAEnd");
for (angle = [210, 330])
let(radial = [cos(angle), sin(angle), 0],
start = radial * small + [0, 0, coneStart],
end = radial * outer + [0, 0, coneEnd],
slope = end - start)
alignFrame((start + end) / 2, slope, radial * (coneEnd - coneStart) - [0, 0, outer - small])
heatExchanger();
}
module tranquility(debrisShieldShown = showDebrisShield, racksShown = showRacks,
coverOpenAngle = radialCoverOpenAngle) {
for (row = getAssumedRows(getTranquilityDimensions()))
echo(str("ASSUMED: tranquility ", row[0], " = ", row[1], " (", row[3], ")"));
spec = getTranquilitySpec();
europeanNode(spec, coverOpenAngle, debrisShieldShown, racksShown,
getTranquility("panelRowsPerBay"), getTranquility("panelsAround"),
getTranquility("panelThickness"), getTranquility("panelGap"),
getTranquility("rackRadialClearance"));
placeOnNodeRadialPortOutward(spec, getTranquilityPortAngle("zenith")) translate([0, 0, 140])
powerDataGrappleFixture();
_heatExchangers(spec);
surfaceFrame(135, (getModuleValue(spec, "coneAEnd") + getModuleValue(spec, "firstSectionEnd")) / 2 + 600,
getModuleValue(spec, "outerRadius"), true)
grappleFixture();
}
tranquility();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
// ============================================================================
// cetaCart.scad — Crew and Equipment Translation Aid cart
// Eric Moderbacher
// 2026-10-21
// STS-112 press kit p44: one cart each on S1 and P1; spacewalkers push it along
// the Mobile Transporter rails and use it as a work platform. Shape and size
// are ASSUMED. Frame matches mobileTransporter.scad: rails on x = 0, cart +X.
// ============================================================================
use <../config.scad>
use <../common/handrail.scad>
use <../common/placement.scad>
include <../detail.scad>
module cetaCart() {
size = getTruss("cetaCartSize");
gauge = getTruss("railGauge");
color("Silver") translate([getTruss("railSize"), -size / 2, -gauge / 2 - 100]) cube([150, size, gauge + 200]);
color("Gainsboro") translate([getTruss("railSize") + 150, -size / 2, -size / 2]) cube([60, size, size]);
color("DimGray") for (y = [-size / 2 + 100, size / 2 - 100], z = [-gauge / 2, gauge / 2])
translate([getTruss("railSize") * 0.5, y, z]) sphere(d = 120);
for (z = [-size / 2 + 150, size / 2 - 150])
alignFrame([getTruss("railSize") + 210, -getCommon("handrailLength") / 2, z], [0, 1, 0], [1, 0, 0])
handrail();
}
cetaCart();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
// ============================================================================
// heatRejectionRadiator.scad — one Heat Rejection System radiator ORU
// Eric Moderbacher
// 2026-10-21
// Boeing (2002): S1 and P1 each deploy a set of three. NASA archive caption:
// eight panels, 75 ft extended, each panel 11 ft wide. The stowage box, panel
// thickness, gaps and the scissor links between panels are ASSUMED. The box
// sits on z = 0 centered on the origin; the panels deploy along -X from the
// box's aft edge, in the XY plane, centered in Y.
// ============================================================================
use <../config.scad>
use <../common/placement.scad>
include <../detail.scad>
module radiatorStowageBox() {
color("WhiteSmoke") translate([-getTruss("radiatorBoxDepth") / 2, -getTruss("radiatorBoxLength") / 2, 0])
cube([getTruss("radiatorBoxDepth"), getTruss("radiatorBoxLength"), getTruss("radiatorBoxHeight")]);
}
module heatRejectionRadiator(deployed = true) {
count = getTruss("radiatorPanelsEach");
width = getTruss("radiatorPanelWidth");
thickness = getTruss("radiatorPanelThickness");
pitch = getTruss("radiatorDeployedLength") / count;
gap = 120;
start = -getTruss("radiatorBoxDepth") / 2;
level = getTruss("radiatorBoxHeight") / 2;
radiatorStowageBox();
if (deployed) {
for (index = [0 : count - 1])
translate([start - (index + 1) * pitch + gap / 2, -width / 2, level - thickness / 2]) {
color("White") cube([pitch - gap, width, thickness]);
if (isFineDetail())
color("Gainsboro") for (y = [width / 10 : width / 10 : width - 1])
translate([0, y - 8, thickness]) cube([pitch - gap, 16, 4]);
}
color("DimGray") for (side = [-1, 1], index = [0 : count - 1])
let(x0 = start - index * pitch, x1 = x0 - pitch)
rodBetween([x0, side * (width / 2 + 60), level], [x1, side * (width / 2 + 60), level], 50);
}
}
heatRejectionRadiator();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
// ============================================================================
// integratedEquipmentAssembly.scad — IEA on each photovoltaic module
// Eric Moderbacher
// 2026-10-21
// CBS/Boeing: 16 x 16 x 16 ft; carries the power equipment for its two wings
// and the photovoltaic radiator. Drawn as an open-frame cube with equipment
// boxes on its faces; the box layout is ASSUMED. Centered on the origin.
// ============================================================================
use <../config.scad>
include <../detail.scad>
module integratedEquipmentAssembly() {
size = getSolarArray("equipmentAssemblySize");
half = size / 2;
bar = 180;
color("LightGray") {
for (a = [-1, 1], b = [-1, 1]) {
translate([a * (half - bar / 2) - bar / 2, b * (half - bar / 2) - bar / 2, -half]) cube([bar, bar, size]);
translate([-half, a * (half - bar / 2) - bar / 2, b * (half - bar / 2) - bar / 2]) cube([size, bar, bar]);
translate([a * (half - bar / 2) - bar / 2, -half, b * (half - bar / 2) - bar / 2]) cube([bar, size, bar]);
}
}
color("WhiteSmoke")
for (face = [0, 180], slot = [-1, 0, 1])
rotate([0, 0, face]) translate([half - 700, slot * 1400 - 550, -half + 400]) cube([600, 1100, size - 800]);
color("Gainsboro") translate([-half + 400, -half + 400, -half + 300]) cube([size - 800, size - 800, 500]);
}
integratedEquipmentAssembly();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119
// ============================================================================
// integratedTruss.scad — the Integrated Truss Structure with everything on it
// Eric Moderbacher
// 2026-10-21
// S0 at the center; outward on each side S1/P1 (with three heat rejection
// radiators each on a thermal rotary joint, an ammonia tank and a CETA cart),
// S3/S4 and P3/P4 split by the SARJ, the S5/P5 spacers, and S6/P6. Everything
// outboard of a SARJ turns with the alpha angle: P4/S4 and P6/S6 each carry an
// IEA, its radiator and two wings. The Mobile Transporter rails run from SARJ
// to SARJ on the forward face. Segment sizes are sourced (config.scad); the
// face the rails use, the radiators' deploy direction, and every other
// placement are ASSUMED.
// Frame: truss along Y (port +Y), centered on the S0 midpoint, +X forward, +Z zenith.
// ============================================================================
use <../config.scad>
use <../common/placement.scad>
use <trussSegment.scad>
use <solarAlphaRotaryJoint.scad>
use <heatRejectionRadiator.scad>
use <mobileTransporter.scad>
use <cetaCart.scad>
use <integratedEquipmentAssembly.scad>
use <../solarArrays/solarArrayWing.scad>
use <../solarArrays/photovoltaicRadiator.scad>
include <../detail.scad>
function getPortSegments() = ["P1", "P3P4", "P5", "P6"];
function getStarboardSegments() = ["S1", "S3S4", "S5", "S6"];
function getSegmentStart(names, target, startY = getTrussLength("S0") / 2) =
names[0] == target ? startY
: getSegmentStart([for (i = [1 : 1 : len(names) - 1]) names[i]], target, startY + getTrussLength(names[0]));
function getSegmentEnd(names, target) = getSegmentStart(names, target) + getTrussLength(target);
function getSarjY(names) =
getSegmentStart(names, names[1]) + getTruss("sarjFractionAlongP3P4") * getTrussLength(names[1]);
function getRailX() = max([for (name = ["S0", "S1", "P1", "S3S4", "P3P4"]) getTrussWidth(name)]) / 2 + 100;
function getTrussSpan() = getSegmentEnd(getPortSegments(), "P6") + getSegmentEnd(getStarboardSegments(), "S6");
module _segmentBox(name, startY, length = -1, bottomFraction = 1) {
translate([0, startY, 0])
trussSegment(length > 0 ? length : getTrussLength(name), getTrussWidth(name), getTrussHeight(name), bottomFraction);
}
module _photovoltaicModule(centerY, segment, betaAngle, plannedRollOuts) {
half = getSolarArray("equipmentAssemblySize") / 2;
translate([0, centerY, 0]) {
integratedEquipmentAssembly();
translate([0, 0, -half]) rotate([0, 90, 0]) photovoltaicRadiator();
for (channel = getPowerChannels()) if (channel[1] == segment)
rotate([0, 0, channel[2] > 0 ? 0 : 180]) translate([half, 0, 0])
solarArrayWing(betaAngle, channel[3] || plannedRollOuts);
}
}
// One side, drawn as the port side; the starboard call mirrors it in Y.
module _trussSide(names, alphaAngle, betaAngle, thermalAngle, plannedRollOuts) {
inner = names[0];
rotary = names[1];
ieaSize = getSolarArray("equipmentAssemblySize");
innerStart = getSegmentStart(names, inner);
innerLength = getTrussLength(inner);
innerTop = getTrussHeight(inner) / 2;
sarjY = getSarjY(names);
sarjDepth = getTruss("sarjDepth");
boxLength = getTruss("radiatorBoxLength");
boxPitch = (innerLength - getTruss("ammoniaTankSize") - 400) / getTruss("radiatorsPerSide");
_segmentBox(inner, innerStart, -1, getTruss("s1BottomWidthFraction"));
color("WhiteSmoke") translate([-getTruss("ammoniaTankSize") / 2, innerStart + 200, innerTop])
cube(getTruss("ammoniaTankSize"));
for (index = [0 : getTruss("radiatorsPerSide") - 1])
let(y = innerStart + getTruss("ammoniaTankSize") + 400 + (index + 0.5) * boxPitch,
hingeX = -getTruss("radiatorBoxDepth") / 2)
translate([0, y, innerTop])
translate([hingeX, 0, 0]) rotate([0, -thermalAngle, 0]) translate([-hingeX, 0, 0])
heatRejectionRadiator();
_segmentBox(rotary, getSegmentStart(names, rotary), sarjY - getSegmentStart(names, rotary));
translate([0, sarjY, 0]) solarAlphaRotaryJoint();
rotate([0, alphaAngle, 0]) {
_segmentBox(rotary, sarjY + sarjDepth, getSegmentEnd(names, rotary) - sarjY - sarjDepth - ieaSize);
_photovoltaicModule(getSegmentEnd(names, rotary) - ieaSize / 2, rotary, betaAngle, plannedRollOuts);
_segmentBox(names[2], getSegmentStart(names, names[2]));
_segmentBox(names[3], getSegmentStart(names, names[3]), getTrussLength(names[3]) - ieaSize);
_photovoltaicModule(getSegmentEnd(names, names[3]) - ieaSize / 2, names[3], betaAngle, plannedRollOuts);
}
}
module integratedTruss(alphaAngle = 0, betaAngle = 0, thermalAngle = 0, transporterY = 0, plannedRollOuts = false,
armAngles = []) {
s0Half = getTrussLength("S0") / 2;
railX = getRailX();
railEnd = min(getSarjY(getPortSegments()), getSarjY(getStarboardSegments())) - 300;
echo(str("CHECK: truss segments span ", getTrussSpan(), " mm end to end; G15 p78 gives ",
getTrussSpanP6ToS6(), " mm P6 to S6"));
_segmentBox("S0", -s0Half);
_trussSide(getPortSegments(), alphaAngle, betaAngle, thermalAngle, plannedRollOuts);
mirror([0, 1, 0]) _trussSide(getStarboardSegments(), alphaAngle, betaAngle, thermalAngle, plannedRollOuts);
translate([railX, 0, 0]) {
transporterRails(-railEnd, railEnd);
translate([0, max(-railEnd + 3000, min(railEnd - 3000, transporterY)), 0]) mobileTransporterWithBase(armAngles);
for (inner = ["P1", "S1"])
let(sign = inner == "P1" ? 1 : -1)
translate([0, sign * (s0Half + getTrussLength(inner) / 2), 0]) cetaCart();
}
}
integratedTruss();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53
// ============================================================================
// mobileTransporter.scad — Mobile Transporter rails, the MT car, and the MBS
// Eric Moderbacher
// 2026-10-21
// G10 p91: the Mobile Base System (5.7 x 4.5 x 2.9 m) rides the Mobile
// Transporter along a track on the truss and carries Canadarm2; it has a PDGF at
// each of its four top corners (Wikipedia, Mobile Servicing System). STS-112 press
// kit: the rails run the length of the central truss; CETA carts share them.
// Rail gauge, the MT car, and the MBS's grapple fixtures and layout are
// ASSUMED. Frame: rails along +Y on the plane x = 0, the MBS standing off +X.
// ============================================================================
use <../config.scad>
use <../common/powerDataGrappleFixture.scad>
use <../canadarm2/canadarm2.scad>
include <../detail.scad>
module transporterRails(startY, endY) {
gauge = getTruss("railGauge");
rail = getTruss("railSize");
color("Silver") for (z = [-gauge / 2, gauge / 2])
translate([0, startY, z - rail / 2]) cube([rail, endY - startY, rail]);
color("DimGray") for (y = [startY : 1500 : endY], z = [-gauge / 2, gauge / 2])
translate([-300, y - 40, z - 40]) cube([300, 80, 80]);
}
module mobileTransporterWithBase(armAngles = [], armCorner = 0) {
length = getTruss("mobileBaseLength");
width = getTruss("mobileBaseWidth");
height = getTruss("mobileBaseHeight");
rail = getTruss("railSize");
color("Gray") translate([rail, -1500, -getTruss("railGauge") / 2 - 200])
cube([400, 3000, getTruss("railGauge") + 400]);
color("LightGray") translate([rail + 400, -length / 2, -width / 2]) cube([height * 0.6, length, width]);
color("WhiteSmoke") for (y = [-length / 3, length / 3])
translate([rail + 400 + height * 0.6, y - 600, -width / 2 + 300]) cube([height * 0.25, 1200, width - 600]);
// Four PDGFs on the top corners (Wikipedia); Canadarm2 grips the one at armCorner.
top = rail + 400 + height * 0.6;
corners = [[-length / 2 + 500, -width / 2 + 500], [length / 2 - 500, -width / 2 + 500],
[length / 2 - 500, width / 2 - 500], [-length / 2 + 500, width / 2 - 500]];
for (index = [0 : getCanadarm("mobileBaseGrappleCount") - 1])
translate([top, corners[index][0], corners[index][1]]) rotate([0, 90, 0]) {
powerDataGrappleFixture();
if (index == armCorner && len(armAngles) > 0)
translate([0, 0, getCommon("pdgfBaseHeight") + getCommon("grappleBaseThickness")])
canadarm2(armAngles);
}
}
transporterRails(-5000, 5000);
mobileTransporterWithBase([0, 0, -50, 100, 40, 0, 0]);
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
// ============================================================================
// solarAlphaRotaryJoint.scad — SARJ between P3 and P4 (and S3 and S4)
// Eric Moderbacher
// 2026-10-21
// CBS/Boeing and Wikipedia: a 10 ft rotary joint that turns everything outboard
// of it to track the sun. Drawn as a bearing race between two bulkhead rings
// with drive-lock housings; depth and housings are ASSUMED. Axis +Y, inboard
// face at y = 0.
// ============================================================================
use <../config.scad>
include <../detail.scad>
module solarAlphaRotaryJoint() {
diameter = getTruss("sarjDiameter");
depth = getTruss("sarjDepth");
color("Silver") rotate([-90, 0, 0]) difference() {
cylinder(h = depth, d = diameter);
translate([0, 0, -1]) cylinder(h = depth + 2, d = diameter * 0.7);
}
color("DimGray") rotate([-90, 0, 0]) translate([0, 0, depth * 0.4]) difference() {
cylinder(h = depth * 0.2, d = diameter * 1.04);
translate([0, 0, -1]) cylinder(h = depth, d = diameter * 0.95);
}
color("Gray") for (angle = [45, 225])
rotate([0, angle, 0]) translate([diameter / 2 - 100, depth * 0.1, -200]) cube([350, depth * 0.8, 400]);
}
solarAlphaRotaryJoint();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
// ============================================================================
// trussSegment.scad — one Integrated Truss Structure segment as a braced frame
// Eric Moderbacher
// 2026-10-21
// Length, width and height come from getTrussSegments() (press kits, CBS,
// Wikipedia). The frame itself (four corner longerons, a frame at every bay,
// one diagonal per bay per face) is ASSUMED: the real segments mix bulkheads,
// longerons and equipment trays. bottomWidthFraction < 1 narrows the nadir
// face, as on S1 and P1 (STS-112 press kit drawing). Runs +Y from the origin,
// centered in X (fore-aft width) and Z (height).
// ============================================================================
use <../config.scad>
use <../common/placement.scad>
include <../detail.scad>
module trussSegment(length, width, height, bottomWidthFraction = 1) {
member = getTruss("longeronSize");
brace = getTruss("bracingSize");
bayCount = max(1, round(length / getTruss("trussBayLength")));
bay = length / bayCount;
topHalf = width / 2 - member / 2;
bottomHalf = topHalf * bottomWidthFraction;
halfHeight = height / 2 - member / 2;
corners = [[-topHalf, halfHeight], [topHalf, halfHeight], [bottomHalf, -halfHeight], [-bottomHalf, -halfHeight]];
color("Gainsboro") {
for (corner = corners)
translate([corner[0] - member / 2, 0, corner[1] - member / 2]) cube([member, length, member]);
for (index = [0 : bayCount], side = [0 : 3])
let(a = corners[side], b = corners[(side + 1) % 4], y = min(index * bay, length - member / 2))
rodBetween([a[0], y, a[1]], [b[0], y, b[1]], member * 0.8);
}
color("LightGray")
for (index = [0 : bayCount - 1], side = [0 : 3])
let(a = corners[side], b = corners[(side + 1) % 4], flip = (index + side) % 2)
rodBetween([a[0], index * bay + (flip ? 0 : bay), a[1]],
[b[0], index * bay + (flip ? bay : 0), b[1]], brace);
}
trussSegment(getTrussLength("S1"), getTrussWidth("S1"), getTrussHeight("S1"), getTruss("s1BottomWidthFraction"));
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83
// ============================================================================
// unity.scad — Unity (Node 1) assembly
// Eric Moderbacher
// 2026-10-21
// Boeing-built, aluminum, six berthing ports, four racks (STS-88 press kit;
// NASA 2010 guide p53). Same layout as Tranquility less one rack bay (2010 guide
// p52), so the shell and debris shield come from common/moduleShell.scad with
// Unity's numbers. Active berthing rings on all six ports, hatches, raised-plate
// debris panels, trunnions with covers, handrails, one grapple fixture, and one
// ring of four racks. Which end the rack bay sits at is ASSUMED.
// Element frame: +Z from the aft face (toward PMA-1) to the forward face (toward
// Destiny). Once installed, element +X is nadir, +Y port, +Z station forward.
// ============================================================================
use <../config.scad>
use <../common/moduleShell.scad>
use <../common/activeCbm.scad>
use <../common/trunnion.scad>
use <../common/handrail.scad>
use <../common/grappleFixture.scad>
include <../detail.scad>
showDebrisShield = true;
showRacks = true;
radialCoverOpenAngle = 120;
function getUnitySpec() = makeModuleSpec(
getBerthing("activeCbmDepth"), getBerthing("activeCbmDepth"),
getUnity("overallLength"), getUnity("endConeLength"), [getUnity("portBayLength")],
getUnity("pressureShellDiameter"), getUnity("endConeSmallDiameter"),
getUnity("outerDiameter"), getUnity("radialPortFaceProud"),
getUnity("radialPortBossDiameter"), getUnity("shellWallThickness"),
getUnity("reinforcementRingWidth"), getUnity("reinforcementRingHeight"), true);
// Element angles of the radial ports once installed (element +X is nadir, +Y port).
function getUnityPortAngle(name) =
name == "nadir" ? 0 : name == "port" ? 90 : name == "zenith" ? 180 : name == "starboard" ? 270 :
assert(false, str("unknown port ", name));
module _unityHandrails(spec) {
outer = getModuleValue(spec, "outerRadius") + 12;
railLength = getCommon("handrailLength");
for (angle = [15 : 30 : 345])
surfaceFrame(angle, getModuleValue(spec, "cylinderEnd") - 150, outer, false)
translate([-railLength / 2, 0, 0]) handrail();
for (angle = [45 : 90 : 315])
surfaceFrame(angle, getModuleValue(spec, "portZ") - railLength / 2, outer, true) handrail();
}
module _unityTrunnions(spec) {
outer = getModuleValue(spec, "outerRadius");
for (angle = [135, 315], z = [getModuleValue(spec, "coneAEnd") + 250, getModuleValue(spec, "cylinderEnd") - 250])
surfaceFrame(angle, z, outer, true) trunnion();
surfaceFrame(225, getModuleValue(spec, "portZ"), outer, true) trunnion();
}
module unity(debrisShieldShown = showDebrisShield, racksShown = showRacks,
coverOpenAngle = radialCoverOpenAngle) {
for (row = getAssumedRows(getUnityDimensions()))
echo(str("ASSUMED: unity ", row[0], " = ", row[1], " (", row[3], ")"));
spec = getUnitySpec();
moduleShell(spec);
if (debrisShieldShown)
moduleDebrisShield(spec, getUnity("panelRowsPerBay"), getUnity("panelsAround"),
getUnity("panelThickness"), getUnity("panelGap"), getUnity("panelRaisedInset"));
activeCbm(coverOpenAngle);
translate([0, 0, getModuleValue(spec, "overallLength")]) rotate([180, 0, 0]) activeCbm(coverOpenAngle);
for (angle = [0 : 90 : 270]) placeOnNodeRadialPortFace(spec, angle) activeCbm(coverOpenAngle);
moduleHatches(spec);
_unityHandrails(spec);
_unityTrunnions(spec);
surfaceFrame(45, getModuleValue(spec, "cylinderEnd") - 500, getModuleValue(spec, "outerRadius") + 12, true)
grappleFixture();
if (racksShown)
moduleRacks(spec, getUnity("rackRadialClearance"), getWallRackPlacements(
[(getModuleValue(spec, "firstSectionEnd") + getModuleValue(spec, "cylinderEnd")) / 2]));
}
unity();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
// ============================================================================
// antennas.scad — Zarya's Kurs and Komparus antennas, and Earth sensors
// Eric Moderbacher
// 2026-10-21
// GCTC drawing: Kurs rendezvous antennas at items 2, 8, 13 and 21 (one on a
// tall mast amidships), Komparus command-radio antennas at items 9 and 18,
// Earth-orientation sensors at item 17. G10 p59 labels the Kurs antenna on the
// forward end. Shapes and sizes are ASSUMED from the drawing's proportions.
// Local frame: foot on z = 0 (the hull), antenna along +Z.
// ============================================================================
use <../config.scad>
include <../detail.scad>
module kursAntenna(length) {
dish = getZarya("antennaDishDiameter");
color("Silver") {
cylinder(h = length, d = 40);
translate([0, 0, length]) cylinder(h = dish * 0.3, d1 = dish * 0.25, d2 = dish);
}
}
module komparusAntenna(length) {
color("Silver") {
cylinder(h = length, d = 50);
for (z = [length * 0.55, length]) translate([0, 0, z]) rotate([0, 90, 0])
cylinder(h = 420, d = 25, center = true);
}
}
module earthSensor() {
diameter = getZarya("earthSensorDiameter");
color("DimGray") cylinder(h = diameter * 0.4, d = diameter);
color("Black") translate([0, 0, diameter * 0.4]) sphere(d = diameter * 0.7);
}
kursAntenna(getZarya("kursMastLength"));
translate([600, 0, 0]) komparusAntenna(getZarya("komparusMastLength"));
translate([1200, 0, 0]) earthSensor();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81
// ============================================================================
// dockingPorts.scad — Zarya's three docking units
// Eric Moderbacher
// 2026-10-21
// GCTC (gctc.ru Zarya page): a passive APAS on the GA's axis (forward, to
// PMA-1), a passive SSVP on the GA's underside (nadir, Rassvet since 2010), and
// an active hybrid SSVP-M on the PGO's axis at the conical bottom (aft, to
// Zvezda). Wikipedia APAS: APAS-89 is 1550 mm across, three guide petals.
// Wikipedia Russian Docking System: an 800 mm tunnel; the hybrid pairs the SSVP
// probe with an APAS-95-style collar of twelve latches. Collar sizes, petal
// shape, probe and drogue sizes are ASSUMED.
// Each unit: mating face on z = 0, +Z pointing away from Zarya, body in -Z.
// ============================================================================
use <../config.scad>
include <../detail.scad>
module _annulus(height, outerDiameter, innerDiameter) {
difference() {
cylinder(h = height, d = outerDiameter);
translate([0, 0, -1]) cylinder(h = height + 2, d = innerDiameter);
}
}
// petalPhase turns the petals so they interleave with the PMA's (which sit at 0/120/240).
module apasPassivePort(neckLength, petalPhase = 60) {
outer = getZarya("apasOuterDiameter");
height = getZarya("apasRingHeight");
petal = getZarya("apasPetalHeight");
passage = getZarya("internalHatchDiameter");
count = getZarya("apasPetalCount");
color("Silver") translate([0, 0, -height]) _annulus(height, outer, passage + 300);
color("LightGray") translate([0, 0, -neckLength]) _annulus(neckLength - height + 1, getZarya("apasNeckDiameter"), passage);
color("LightGray") for (index = [0 : count - 1]) rotate([0, 0, petalPhase + index * 360 / count])
hull() {
translate([outer / 2 - 110, -outer * 0.2, 0]) cube([60, outer * 0.4, 10]);
translate([outer / 2 - 170, -40, petal - 10]) cube([40, 80, 10]);
}
color("DimGray") for (index = [0 : 11]) rotate([0, 0, index * 30 + 15])
translate([outer / 2 - 60, 0, 0]) cylinder(h = 30, d = 50);
}
module ssvpHybridActivePort(collarDepth, probeLength) {
collar = getZarya("ssvpCollarDiameter");
passage = getZarya("internalHatchDiameter");
probe = getZarya("ssvpProbeDiameter");
head = getZarya("ssvpProbeHeadDiameter");
color("Silver") translate([0, 0, -collarDepth]) _annulus(collarDepth, collar, passage);
color("DimGray") for (index = [0 : getZarya("hybridLatchCount") - 1])
rotate([0, 0, index * 360 / getZarya("hybridLatchCount")])
translate([collar / 2 - 70, -30, 0]) cube([50, 60, 45]);
color("Gray") {
for (index = [0 : 2]) rotate([0, 0, index * 120])
hull() {
translate([passage / 2 - 20, 0, -collarDepth / 2]) sphere(d = 50);
translate([0, 0, probeLength * 0.35]) sphere(d = probe);
}
cylinder(h = probeLength - head / 2, d = probe);
translate([0, 0, probeLength - head / 2]) cylinder(h = head / 2, d1 = head, d2 = probe * 0.6);
}
}
module ssvpPassiveDrogue(collarDepth) {
collar = getZarya("ssvpCollarDiameter");
depth = getZarya("ssvpDrogueDepth");
color("Silver") difference() {
translate([0, 0, -collarDepth]) cylinder(h = collarDepth, d = collar);
translate([0, 0, -depth]) cylinder(h = depth + 1, d1 = getZarya("ssvpProbeHeadDiameter"), d2 = collar - 160);
translate([0, 0, -collarDepth - 1]) cylinder(h = collarDepth + 2, d = getZarya("ssvpProbeHeadDiameter") * 0.8);
}
color("DimGray") for (index = [0 : 7]) rotate([0, 0, index * 45])
translate([collar / 2 - 60, -25, 0]) cube([40, 50, 30]);
}
translate([-2000, 0, 0]) apasPassivePort(1000);
ssvpHybridActivePort(460, 430);
translate([2000, 0, 0]) ssvpPassiveDrogue(300);
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
// ============================================================================
// engineBlocks.scad — Zarya's engine blocks
// Eric Moderbacher
// 2026-10-21
// GCTC: two DKS blocks (rendezvous and correction, the two large engines that
// Wikipedia says were disabled once Zvezda docked) and two DPS/DTS blocks
// (approach, stabilization and fine stabilization jets) at the junction of the
// PGO's conical and cylindrical shells, and two more DPS blocks on the GA.
// Wikipedia, Gunter's: 24 large and 12 small jets. Block sizes, nozzle layout
// and how the jets divide among the blocks are ASSUMED.
// Local frame: base on z = 0 (the hull), +Z outward, +X station aft (the
// direction the main engine fires), centered on the origin.
// ============================================================================
use <../config.scad>
include <../detail.scad>
module _nozzle(diameter) {
cylinder(h = diameter * 1.4, d1 = diameter * 0.35, d2 = diameter);
}
module dksEngineBlock() {
length = getZarya("dksBlockLength");
width = getZarya("dksBlockWidth");
height = getZarya("dksBlockHeight");
nozzle = getZarya("mainEngineNozzleDiameter");
color("Gainsboro") translate([-length / 2, -width / 2, 0]) cube([length, width, height]);
color("DimGray") translate([length / 2, 0, height / 2]) rotate([0, 90, 0]) _nozzle(nozzle);
}
// jetsPerSide jets on each of the block's three exposed sides.
module thrusterBlock(jetsPerSide = 2) {
size = getZarya("dpsBlockSize");
jet = getZarya("thrusterNozzleDiameter");
color("Gainsboro") translate([-size / 2, -size / 2, 0]) cube([size, size, size]);
color("DimGray") {
for (index = [0 : jetsPerSide - 1])
let(offset = (index - (jetsPerSide - 1) / 2) * size / jetsPerSide) {
translate([offset, 0, size]) _nozzle(jet);
for (side = [-1, 1])
translate([offset, side * size / 2, size / 2]) rotate([-side * 90, 0, 0]) _nozzle(jet);
}
}
}
dksEngineBlock();
translate([0, 1200, 0]) thrusterBlock();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74
// ============================================================================
// hull.scad — Zarya's pressure hull: hermetic adapter (GA) and PGO
// Eric Moderbacher
// 2026-10-21
// GCTC (gctc.ru Zarya page): the GA is a sphere with a cone whose large end
// joins PGO-3; PGO-3 and PGO-1 are 2.9 m cylinders; PGO-2 is a conical shell
// and a conical bottom joined by a 4.1 m frame; an 800 mm hatch separates PGO
// and GA. G10 p59: micrometeoroid protection over the hull. Every station along
// the axis and the sphere's size are ASSUMED, scaled from GCTC's drawing; the
// test suite checks the volumes they enclose against GCTC's 64.5 and 7.0 m3.
// Element frame: z = 0 on the forward APAS mating face, +Z station aft, the
// hull a solid of revolution about Z.
// ============================================================================
use <../config.scad>
include <../detail.scad>
function _getZaryaGaConeStartRadius() =
let(radius = getZarya("gaSphereDiameter") / 2,
offset = getZarya("gaConeStartZ") - getZarya("gaSphereCenterZ"))
sqrt(radius * radius - offset * offset);
module _zaryaHullProfile() {
sphereRadius = getZarya("gaSphereDiameter") / 2;
pgoRadius = getZarya("pgoCylinderDiameter") / 2;
intersection() {
union() {
translate([0, getZarya("gaSphereCenterZ")]) circle(r = sphereRadius);
polygon([[0, getZarya("gaConeStartZ")], [_getZaryaGaConeStartRadius(), getZarya("gaConeStartZ")],
[pgoRadius, getZarya("pgoStartZ")], [0, getZarya("pgoStartZ")]]);
polygon([[0, getZarya("pgoStartZ")], [pgoRadius, getZarya("pgoStartZ")],
[pgoRadius, getZarya("conicalShellStartZ")],
[getZarya("frameDiameter") / 2, getZarya("frameZ")],
[getZarya("conicalBottomSmallDiameter") / 2, getZarya("conicalBottomEndZ")],
[0, getZarya("conicalBottomEndZ")]]);
}
translate([0, -1]) square([getZarya("maxDiameter"), getZarya("overallLength") + 2]);
}
}
module _zaryaShellProfile() {
difference() {
_zaryaHullProfile();
offset(delta = -getZarya("shellWallThickness")) _zaryaHullProfile();
translate([-1, -1]) square([getZarya("internalHatchDiameter") / 2 + 1, getZarya("overallLength") + 2]);
}
}
module _zaryaRing(z, radius, width, height) {
translate([0, 0, z - width / 2]) difference() {
cylinder(h = width, r = radius + height);
translate([0, 0, -1]) cylinder(h = width + 2, r = radius - 1);
}
}
// nadirAngle: element angle of the nadir port, cut through the sphere.
module zaryaHull(nadirAngle) {
pgoRadius = getZarya("pgoCylinderDiameter") / 2;
color("WhiteSmoke") difference() {
rotate_extrude() _zaryaShellProfile();
rotate([0, 0, nadirAngle]) translate([0, 0, getZarya("gaSphereCenterZ")]) rotate([0, 90, 0])
cylinder(h = getZarya("gaSphereDiameter"), d = getZarya("internalHatchDiameter"));
}
color("Silver") {
_zaryaRing(getZarya("frameZ"), getZarya("frameDiameter") / 2,
getZarya("frameRingWidth"), getZarya("frameRingHeight"));
for (z = [getZarya("pgoStartZ"), getZarya("conicalShellStartZ")])
_zaryaRing(z, pgoRadius, getZarya("frameRingWidth"), getZarya("frameRingHeight"));
}
}
zaryaHull(180);
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
// ============================================================================
// propellantTanks.scad — one fairing over a pair of Zarya's propellant tanks
// Eric Moderbacher
// 2026-10-21
// GCTC, Wikipedia, Gunter's: sixteen propellant tanks outside the PGO, up to
// 6.1 t. The STS-88 zenith photo (sts088-719-059) shows long covered fairings,
// two per side fore and aft; the model groups the tanks two to a fairing.
// Tank size, the fairing's shape and the grouping are ASSUMED.
// Local frame: the fairing runs along +Z from z = 0, its two tanks side by
// side across Y, centered on the origin in X (the radial direction).
// ============================================================================
use <../config.scad>
include <../detail.scad>
module propellantTankFairing(length) {
tank = getZarya("tankDiameter");
cover = getZarya("tankFairingCover");
endTaper = tank * 0.4;
color("Gainsboro") hull()
for (y = [-tank / 2, tank / 2]) translate([0, y, 0]) {
translate([0, 0, endTaper]) cylinder(h = length - 2 * endTaper, d = tank + 2 * cover);
cylinder(h = length, d = tank * 0.55);
}
if (isFineDetail())
color("Silver") for (z = [length * 0.25, length * 0.5, length * 0.75])
translate([0, 0, z]) hull()
for (y = [-tank / 2, tank / 2]) translate([0, y, 0]) cylinder(h = 20, d = tank + 2 * cover + 8);
}
propellantTankFairing(2700);
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
// ============================================================================
// radiator.scad — a curved thermal-control radiator panel on Zarya's PGO
// Eric Moderbacher
// 2026-10-21
// G10 p59 labels a "Thermal Control Radiator"; GCTC lists radiative heat
// exchanger panels outside the PGO; the STS-88 zenith photo shows ribbed bands
// beside the aft tank fairings. Where they sit, their sweep and ribs are ASSUMED.
// Local frame: a curved panel about the Z axis at the given radius, centered on
// angle 0, from z = 0 to z = length.
// ============================================================================
use <../config.scad>
include <../detail.scad>
module zaryaRadiatorPanel(radius, length) {
sweep = getZarya("radiatorSweepDegrees");
ribs = getZarya("radiatorRibCount");
color("White") rotate([0, 0, -sweep / 2]) rotate_extrude(angle = sweep)
translate([radius, 0]) square([20, length]);
color("Gainsboro") for (index = [0 : ribs - 1])
translate([0, 0, (index + 0.5) * length / ribs]) rotate([0, 0, -sweep / 2])
rotate_extrude(angle = sweep) translate([radius + 20, -30]) square([25, 60]);
}
zaryaRadiatorPanel(1490, 2700);
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
// ============================================================================
// solarArrayWing.scad — one of Zarya's two fan-folded solar array wings
// Eric Moderbacher
// 2026-10-21
// Wikipedia Zarya, Gunter's, JSC Roundup 1998-12-04: each wing 10.67 x 3.35 m
// (35 x 11 ft). GCTC: the drives sit in two niches on PGO-3. NTRS 20150003832
// (Liddle): both wings were retracted in 2007-08 to clear the P1/S1 radiators
// and rebounded outboard, the port wing up to 41 in past the design stow. The
// wing is drawn as a zig-zag of hinged panels: foldAngle 0 is fully deployed,
// near 90 fully stowed. Panel count and the fold today are ASSUMED.
// Local frame: root at the origin, span along +X, chord along Y (centered),
// cell side +Z when deployed.
// ============================================================================
use <../config.scad>
include <../detail.scad>
module _zaryaSolarPanel(length, width, thickness) {
color("MidnightBlue") translate([0, -width / 2, 0]) cube([length, width, thickness]);
color("Goldenrod") difference() {
translate([0, -width / 2 - 15, -5]) cube([length, width + 30, thickness]);
translate([25, -width / 2 + 25, -10]) cube([length - 50, width - 50, thickness + 20]);
}
if (isFineDetail())
color("DarkSlateGray") for (y = [-width / 2 + width / 4 : width / 4 : width / 2 - 1])
translate([0, y - 6, thickness]) cube([length, 12, 2]);
}
module zaryaSolarArrayWing(foldAngle) {
count = getZarya("solarPanelsPerWing");
pitch = getZarya("solarWingLength") / count;
width = getZarya("solarWingWidth");
thickness = getZarya("solarPanelThickness");
for (index = [0 : count - 1])
let(up = index % 2 == 0)
translate([index * pitch * cos(foldAngle), 0, up ? 0 : pitch * sin(foldAngle)])
rotate([0, up ? -foldAngle : foldAngle, 0]) _zaryaSolarPanel(pitch, width, thickness);
color("Silver") translate([0, -getZarya("solarMastWidth") / 2, -getZarya("solarMastWidth")])
cube([max(pitch * cos(foldAngle), getZarya("solarMastWidth")), getZarya("solarMastWidth"), getZarya("solarMastWidth")]);
}
zaryaSolarArrayWing(getZarya("solarFoldAngleToday"));
translate([0, 5000, 0]) zaryaSolarArrayWing(0);
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265
// ============================================================================
// zarya.scad — Zarya, the Functional Cargo Block (FGB), assembly
// Eric Moderbacher
// 2026-10-21
// G10 p59: 12.99 m (printed "12,990 m"), 4.1 m maximum, 24.4 m array span.
// GCTC (gctc.ru Zarya page and drawing): a spherical hermetic adapter (GA)
// carrying a passive APAS forward, a passive SSVP nadir (Rassvet since 2010)
// and a welded cover with a PDGF where a zenith port was planned; a PGO of
// 2.9 m cylinders, a conical shell and a conical bottom on a 4.1 m frame, with
// an active hybrid SSVP-M aft (to Zvezda); 16 propellant tanks, DKS and DPS
// engine blocks, Kurs and Komparus antennas, radiator panels, two array wings.
// NTRS 20150003832: the wings were retracted in 2007-08 and sprang partly back
// out; they are drawn that way today (solarFoldAngleToday, ASSUMED). Every
// position along the hull is ASSUMED from GCTC's drawing; the tests check it
// against GCTC's volumes and the G10 span.
// Element frame: z = 0 on the forward APAS mating face (toward PMA-1), +Z
// station aft. Once installed, element +X is zenith, +Y port, so
// rotate([0, -90, 0]) takes the element frame into the station frame.
// ============================================================================
use <../config.scad>
use <../common/placement.scad>
use <../common/moduleShell.scad>
use <../common/handrail.scad>
use <../common/grappleFixture.scad>
use <../common/powerDataGrappleFixture.scad>
use <hull.scad>
use <dockingPorts.scad>
use <propellantTanks.scad>
use <solarArrayWing.scad>
use <engineBlocks.scad>
use <antennas.scad>
use <radiator.scad>
include <../detail.scad>
// Array fold, degrees: 0 deployed (1998-2007), about 90 fully stowed.
zaryaSolarFoldAngle = getZarya("solarFoldAngleToday");
// Array rotation about each wing's own span, degrees; 0 = cells toward zenith.
zaryaSolarArrayRotation = 0;
// Element angles once installed (element +X is zenith, +Y port).
function getZaryaWallAngle(name) =
name == "zenith" ? 0 : name == "port" ? 90 : name == "nadir" ? 180 : name == "starboard" ? 270 :
assert(false, str("unknown wall ", name));
function _lerp(z, zA, valueA, zB, valueB) = valueA + (valueB - valueA) * (z - zA) / (zB - zA);
// Outer radius of the pressure hull at axial station z (0 outside the hull).
function getZaryaHullRadius(z) =
let(sphereRadius = getZarya("gaSphereDiameter") / 2, center = getZarya("gaSphereCenterZ"),
pgoRadius = getZarya("pgoCylinderDiameter") / 2)
z < center - sphereRadius || z > getZarya("conicalBottomEndZ") ? 0
: z < getZarya("gaConeStartZ") ? sqrt(sphereRadius * sphereRadius - (z - center) * (z - center))
: z < getZarya("pgoStartZ")
? _lerp(z, getZarya("gaConeStartZ"), getZaryaHullRadius(getZarya("gaConeStartZ") - 0.001),
getZarya("pgoStartZ"), pgoRadius)
: z < getZarya("conicalShellStartZ") ? pgoRadius
: z < getZarya("frameZ")
? _lerp(z, getZarya("conicalShellStartZ"), pgoRadius, getZarya("frameZ"), getZarya("frameDiameter") / 2)
: _lerp(z, getZarya("frameZ"), getZarya("frameDiameter") / 2,
getZarya("conicalBottomEndZ"), getZarya("conicalBottomSmallDiameter") / 2);
// [origin, outward unit normal] of each port's mating face, element frame.
function getZaryaPortFace(name) =
name == "forward" ? [[0, 0, 0], [0, 0, -1]]
: name == "aft" ? [[0, 0, getZarya("aftMatingFaceZ")], [0, 0, 1]]
: name == "nadir"
? let(angle = getZaryaWallAngle("nadir"), radial = [cos(angle), sin(angle), 0])
[radial * getZarya("nadirPortFaceRadius") + [0, 0, getZarya("gaSphereCenterZ")], radial]
: assert(false, str("unknown Zarya port ", name));
// Children get their origin on the port's mating face, +Z pointing away from Zarya.
// forward: child +X zenith. aft: child +X zenith, +Y port. nadir: child +X station forward.
module placeOnZaryaPort(name) {
if (name == "forward") rotate([180, 0, 0]) children();
else if (name == "aft") translate(getZaryaPortFace("aft")[0]) children();
else if (name == "nadir")
rotate([0, 0, getZaryaWallAngle("nadir")])
translate([getZarya("nadirPortFaceRadius"), 0, getZarya("gaSphereCenterZ")]) rotate([0, 90, 0]) children();
else assert(false, str("unknown Zarya port ", name));
}
function _getFrustumVolume(height, radiusA, radiusB) =
PI * height / 3 * (radiusA * radiusA + radiusA * radiusB + radiusB * radiusB);
// Enclosed volumes inside the shell wall, litres (mm3 / 1e6).
function getZaryaPgoVolumeLitres() =
let(wall = getZarya("shellWallThickness"),
pgoRadius = getZarya("pgoCylinderDiameter") / 2 - wall,
frameRadius = getZarya("frameDiameter") / 2 - wall,
bottomRadius = getZarya("conicalBottomSmallDiameter") / 2 - wall)
(PI * pgoRadius * pgoRadius * (getZarya("conicalShellStartZ") - getZarya("pgoStartZ"))
+ _getFrustumVolume(getZarya("frameZ") - getZarya("conicalShellStartZ"), pgoRadius, frameRadius)
+ _getFrustumVolume(getZarya("conicalBottomEndZ") - getZarya("frameZ"), frameRadius, bottomRadius)) / 1e6;
function getZaryaGaVolumeLitres() =
let(wall = getZarya("shellWallThickness"),
sphereRadius = getZarya("gaSphereDiameter") / 2 - wall,
capHeight = sphereRadius - (getZarya("gaConeStartZ") - getZarya("gaSphereCenterZ")),
coneStartRadius = getZaryaHullRadius(getZarya("gaConeStartZ") - 0.001) - wall)
(4 / 3 * PI * pow(sphereRadius, 3)
- PI * capHeight * capHeight * (3 * sphereRadius - capHeight) / 3
+ _getFrustumVolume(getZarya("pgoStartZ") - getZarya("gaConeStartZ"), coneStartRadius,
getZarya("pgoCylinderDiameter") / 2 - wall)) / 1e6;
function getZaryaTankCenterRadius() =
getZarya("pgoCylinderDiameter") / 2 + getZarya("tankStandoff") + getZarya("tankDiameter") / 2
+ getZarya("tankFairingCover");
function getZaryaFairingMaxRadius() =
let(center = getZaryaTankCenterRadius(), half = getZarya("tankDiameter") / 2)
sqrt(center * center + half * half) + half + getZarya("tankFairingCover");
function getZaryaFairingHalfAngle() =
atan((getZarya("tankDiameter") + getZarya("tankFairingCover")) / getZaryaTankCenterRadius());
function getZaryaFairingAngles() =
let(perGroup = getZarya("tankFairingCount") / 2)
[for (index = [0 : perGroup - 1]) getZarya("tankFairingFirstAngle") + index * 360 / perGroup];
// [startZ, endZ] of the forward and aft fairing groups.
function getZaryaFairingGroups() =
[[getZarya("forwardTankStartZ"), getZarya("forwardTankEndZ")],
[getZarya("aftTankStartZ"), getZarya("aftTankEndZ")]];
function getZaryaTankVolumeLitres() =
let(radius = getZarya("tankDiameter") / 2, perGroup = getZarya("tankFairingCount") / 2)
[for (group = getZaryaFairingGroups()) perGroup * 2 * PI * radius * radius * (group[1] - group[0]) / 1e6]
* [1, 1];
// The span G10 quotes less two wings leaves this much for each root from the axis.
function getZaryaWingRootRadius() = (getZarya("solarArraySpan") - 2 * getZarya("solarWingLength")) / 2;
function getZaryaWingReach(foldAngle) = getZarya("solarWingLength") * cos(foldAngle);
module _coneSurfaceFrame(angle, z, zA, radiusA, zB, radiusB) {
radial = [cos(angle), sin(angle), 0];
along = normalize(radial * (radiusB - radiusA) + [0, 0, zB - zA]);
outward = normalize(radial * (zB - zA) - [0, 0, radiusB - radiusA]);
alignFrame(radial * _lerp(z, zA, radiusA, zB, radiusB) + [0, 0, z], along, outward) children();
}
module _onConicalShell(angle, z) {
_coneSurfaceFrame(angle, z, getZarya("conicalShellStartZ"), getZarya("pgoCylinderDiameter") / 2,
getZarya("frameZ"), getZarya("frameDiameter") / 2) children();
}
module _onConicalBottom(angle, z) {
_coneSurfaceFrame(angle, z, getZarya("frameZ"), getZarya("frameDiameter") / 2,
getZarya("conicalBottomEndZ"), getZarya("conicalBottomSmallDiameter") / 2) children();
}
module _onGaCone(angle, z) {
_coneSurfaceFrame(angle, z, getZarya("gaConeStartZ"), getZaryaHullRadius(getZarya("gaConeStartZ") - 0.001),
getZarya("pgoStartZ"), getZarya("pgoCylinderDiameter") / 2) children();
}
module _zaryaDockingPorts() {
sphereRadius = getZarya("gaSphereDiameter") / 2;
collar = getZarya("ssvpCollarDiameter");
placeOnZaryaPort("forward") apasPassivePort(getZarya("gaSphereCenterZ"));
placeOnZaryaPort("aft")
ssvpHybridActivePort(getZarya("aftMatingFaceZ") - getZarya("conicalBottomEndZ") + 20,
getZarya("overallLength") - getZarya("aftMatingFaceZ"));
placeOnZaryaPort("nadir")
ssvpPassiveDrogue(getZarya("nadirPortFaceRadius") - sqrt(sphereRadius * sphereRadius - collar * collar / 4) + 20);
}
module _zaryaZenithFittings() {
sphereRadius = getZarya("gaSphereDiameter") / 2;
cover = getZarya("zenithCoverDiameter");
base = sqrt(sphereRadius * sphereRadius - cover * cover / 4);
height = sphereRadius - base + getZarya("zenithCoverProud");
surfaceFrame(getZaryaWallAngle("zenith"), getZarya("gaSphereCenterZ"), base, true) {
color("Silver") cylinder(h = height, d = cover);
translate([0, 0, height]) powerDataGrappleFixture();
}
_onGaCone(getZaryaWallAngle("zenith") + 35, (getZarya("gaConeStartZ") + getZarya("pgoStartZ")) / 2)
grappleFixture();
}
module _zaryaTankFairings() {
for (group = getZaryaFairingGroups(), angle = getZaryaFairingAngles())
rotate([0, 0, angle]) translate([getZaryaTankCenterRadius(), 0, group[0]])
propellantTankFairing(group[1] - group[0]);
}
module _zaryaSolarArrays(foldAngle, rotation) {
pgoRadius = getZarya("pgoCylinderDiameter") / 2;
root = getZaryaWingRootRadius();
for (side = ["port", "starboard"])
let(angle = getZaryaWallAngle(side), radial = [cos(angle), sin(angle), 0]) {
color("Silver") rotate([0, 0, angle]) translate([pgoRadius - 100, 0, getZarya("solarArrayRootZ")])
rotate([0, 90, 0]) cylinder(h = root - pgoRadius + 100, d = getZarya("solarDriveDiameter"));
alignFrame(radial * root + [0, 0, getZarya("solarArrayRootZ")], radial, [1, 0, 0])
rotate([rotation, 0, 0]) zaryaSolarArrayWing(foldAngle);
}
}
module _zaryaEngines() {
sphereRadius = getZarya("gaSphereDiameter") / 2;
for (side = ["zenith", "nadir"])
_onConicalShell(getZaryaWallAngle(side), getZarya("conicalShellStartZ") + getZarya("dksBlockLength") / 2 + 50)
dksEngineBlock();
for (side = ["port", "starboard"]) {
_onConicalShell(getZaryaWallAngle(side), getZarya("conicalShellStartZ") + getZarya("dpsBlockSize"))
thrusterBlock();
surfaceFrame(getZaryaWallAngle(side), getZarya("gaSphereCenterZ"), sphereRadius - 30, true)
thrusterBlock(1);
}
}
module _zaryaRadiators() {
for (side = ["port", "starboard"])
rotate([0, 0, getZaryaWallAngle(side)]) translate([0, 0, getZarya("radiatorStartZ")])
zaryaRadiatorPanel(getZarya("pgoCylinderDiameter") / 2 + getZarya("radiatorStandoff"),
getZarya("radiatorEndZ") - getZarya("radiatorStartZ"));
}
module _zaryaAntennas() {
pgoRadius = getZarya("pgoCylinderDiameter") / 2;
zenith = getZaryaWallAngle("zenith");
nadir = getZaryaWallAngle("nadir");
forwardZ = getZarya("gaSphereCenterZ") - getZarya("gaSphereDiameter") * 0.3;
for (angle = [zenith + 40, nadir + 40])
surfaceFrame(angle, forwardZ, getZaryaHullRadius(forwardZ) - 20, true) kursAntenna(getZarya("kursBoomLength"));
surfaceFrame(zenith, getZarya("kursMastZ"), pgoRadius, true) kursAntenna(getZarya("kursMastLength"));
surfaceFrame(zenith, getZarya("komparusZenithZ"), pgoRadius, true) komparusAntenna(getZarya("komparusMastLength"));
surfaceFrame(nadir, getZarya("komparusNadirZ"), pgoRadius, true) komparusAntenna(getZarya("komparusMastLength"));
surfaceFrame(nadir, getZarya("pgoStartZ") + 250, pgoRadius, true) kursAntenna(getZarya("kursBoomLength"));
for (angle = [nadir - 30, nadir + 30])
_onConicalBottom(angle, (getZarya("frameZ") + getZarya("conicalBottomEndZ")) / 2)
kursAntenna(getZarya("kursBoomLength"));
for (index = [0 : getZarya("earthSensorCount") - 1])
surfaceFrame(nadir + (index - 0.5) * 20, getZarya("earthSensorZ"), pgoRadius, true) earthSensor();
}
module _zaryaHandrails() {
pgoRadius = getZarya("pgoCylinderDiameter") / 2;
railLength = getCommon("handrailLength");
for (angle = [getZaryaWallAngle("zenith") + 10, getZaryaWallAngle("nadir") + 10],
z = [getZarya("pgoStartZ") + 400 : getZarya("handrailPitch") : getZarya("conicalShellStartZ") - 400])
surfaceFrame(angle, z, pgoRadius, true) translate([-railLength / 2, 0, 0]) handrail();
_onConicalBottom(getZaryaWallAngle("zenith"), (getZarya("frameZ") + getZarya("conicalBottomEndZ")) / 2)
translate([-railLength / 2, 0, 0]) handrail();
}
module zarya(solarFoldAngle = zaryaSolarFoldAngle, solarArrayRotation = zaryaSolarArrayRotation) {
for (row = getAssumedRows(getZaryaDimensions()))
echo(str("ASSUMED: zarya ", row[0], " = ", row[1], " (", row[3], ")"));
zaryaHull(getZaryaWallAngle("nadir"));
_zaryaDockingPorts();
_zaryaZenithFittings();
_zaryaTankFairings();
_zaryaSolarArrays(solarFoldAngle, solarArrayRotation);
_zaryaEngines();
_zaryaRadiators();
_zaryaAntennas();
_zaryaHandrails();
}
zarya();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
// ============================================================================
// antennas.scad — Zvezda's Kurs rendezvous and Lira (Luch) relay antennas
// Eric Moderbacher
// 2026-10-21
// G10 p63 labels two Kurs rendezvous antennas and a Luch satellite antenna;
// the NASA press kit p9 and RussianSpaceWeb confirm Kurs-P and the Lira
// system. Dish sizes, boom lengths and where each one stands are ASSUMED.
// Foot on z = 0, boom up +Z, dish at the top facing +X.
// ============================================================================
use <../config.scad>
include <../detail.scad>
module _dishOnBoom(boomLength, dishDiameter) {
color("Silver") cylinder(h = boomLength, d = 60);
color("White") translate([0, 0, boomLength]) rotate([0, 90, 0]) difference() {
cylinder(h = dishDiameter * 0.2, d1 = dishDiameter * 0.2, d2 = dishDiameter);
translate([0, 0, 15]) cylinder(h = dishDiameter * 0.2, d1 = dishDiameter * 0.2 - 30, d2 = dishDiameter - 30);
}
}
module kursAntenna() {
_dishOnBoom(getZvezda("kursBoomLength"), getZvezda("kursDishDiameter"));
}
module liraAntenna() {
_dishOnBoom(getZvezda("liraBoomLength"), getZvezda("liraDishDiameter"));
}
kursAntenna();
translate([2000, 0, 0]) liraAntenna();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79
// ============================================================================
// assemblyCompartment.scad — Zvezda's unpressurized aft (assembly) compartment
// Eric Moderbacher
// 2026-10-21
// NASA Zvezda press kit p8: an unpressurized compartment wrapped around the
// transfer chamber holds propellant tanks, thrusters and antennas. G10 p63:
// 2 maneuvering engines and 32 attitude engines in 6 clusters. RussianSpaceWeb
// and Wikipedia: four propellant tanks; the main engines are S5.79s. The skirt
// length, the aft disk that carries four thruster pods (Zvezda rear photo,
// Wikimedia Commons), where each engine and tank sits, every size, and the split
// of the 32 thrusters among the 6 clusters are ASSUMED.
// Element frame as in zvezdaLayout.scad.
// ============================================================================
use <../config.scad>
use <../common/placement.scad>
use <zvezdaLayout.scad>
include <../detail.scad>
// Pod body on z = 0 rising +Z, nozzles on its top face pointing +Z.
module thrusterPod(thrusterCount) {
size = getZvezda("thrusterPodSize");
nozzle = getZvezda("thrusterNozzleDiameter");
columns = ceil(sqrt(thrusterCount));
rows = ceil(thrusterCount / columns);
pitch = nozzle * 1.4;
color("Silver") translate([-size / 2, -size / 2, 0]) cube([size, size, size / 2]);
color("DimGray")
for (index = [0 : thrusterCount - 1])
let(column = index % columns, row = floor(index / columns))
translate([(column - (columns - 1) / 2) * pitch, (row - (rows - 1) / 2) * pitch, size / 2])
cylinder(h = nozzle, d1 = nozzle * 0.5, d2 = nozzle);
}
module _skirt(startZ, length, outerRadius) {
wall = getZvezda("shellWallThickness");
color("WhiteSmoke") translate([0, 0, startZ]) difference() {
cylinder(h = length, r = outerRadius);
translate([0, 0, -1]) cylinder(h = length + 2, r = outerRadius - wall);
}
}
module assemblyCompartment() {
largeEnd = getZvezdaLargeEndZ();
chamberEnd = getZvezdaChamberEndZ();
skirtLength = getZvezda("assemblySkirtLength");
outerRadius = getZvezda("maxDiameter") / 2;
annulusRadius = (getZvezda("transferChamberDiameter") / 2 + outerRadius) / 2;
clusters = getZvezdaThrusterClusters();
diskRadius = getZvezda("aftDiskDiameter") / 2;
pod = getZvezda("thrusterPodSize");
_skirt(largeEnd, skirtLength, outerRadius);
color("Gainsboro")
for (angle = [45 : 90 : 315])
rotate([0, 0, angle]) translate([annulusRadius, 0, largeEnd + getZvezda("propellantTankDiameter") / 2 + 10])
sphere(d = getZvezda("propellantTankDiameter"));
color("DimGray")
for (angle = [90, 270])
rotate([0, 0, angle]) translate([annulusRadius, 0, largeEnd + 100])
cylinder(h = getZvezda("mainEngineNozzleLength"), d1 = getZvezda("mainEngineNozzleDiameter") / 3,
d2 = getZvezda("mainEngineNozzleDiameter"));
color("LightSteelBlue") translate([0, 0, chamberEnd - getZvezda("aftDiskThickness")]) difference() {
cylinder(h = getZvezda("aftDiskThickness"), r = diskRadius);
translate([0, 0, -1]) cylinder(h = getZvezda("aftDiskThickness") + 2, d = getZvezda("passageDiameter"));
}
for (index = [0 : 3])
rotate([0, 0, 45 + index * 90]) translate([diskRadius - pod * 0.75, 0, chamberEnd]) thrusterPod(clusters[index]);
for (index = [0 : 1])
rotate([0, 0, index * 180]) translate([outerRadius, 0, largeEnd + skirtLength / 2]) rotate([0, 90, 0])
thrusterPod(clusters[4 + index]);
}
assemblyCompartment();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
// ============================================================================
// debrisPanels.scad — Service Module Debris Protection (SMDP) panels
// Eric Moderbacher
// 2026-10-21
// space.com (2007-05-30): six SMDPs went on in August 2002, five on the conical
// section between Zvezda's large and small diameters in May 2007, 23 in all
// when complete, each about 2 by 3 ft. Where every panel but those five sits is
// ASSUMED: one ring on the work cone and one on the forward part of the small
// cylinder, clear of the array roots. Element frame as in zvezdaLayout.scad.
// ============================================================================
use <../config.scad>
use <../common/placement.scad>
use <zvezdaLayout.scad>
include <../detail.scad>
module _debrisPanel() {
color("LightGray")
cube([getZvezda("debrisPanelLength"), getZvezda("debrisPanelWidth"), 20], center = true);
}
module debrisPanels() {
split = getZvezdaDebrisPanelSplit();
smallRadius = getZvezda("workSmallDiameter") / 2;
largeRadius = getZvezda("workLargeDiameter") / 2;
coneStart = getZvezdaSmallEndZ();
coneEnd = getZvezdaLargeStartZ();
slant = [largeRadius - smallRadius, 0, coneEnd - coneStart];
normal = [coneEnd - coneStart, 0, -(largeRadius - smallRadius)];
standoff = 60;
for (index = [0 : split[0] - 1])
rotate([0, 0, index * 360 / split[0]])
alignFrame([(smallRadius + largeRadius) / 2, 0, (coneStart + coneEnd) / 2] + normalize(normal) * standoff,
slant, normal)
_debrisPanel();
ringRadius = getZvezdaRadiatorOuterRadius(getZvezda("workSmallDiameter")) + 40;
ringZ = getZvezdaWorkStartZ() + 150 + getZvezda("debrisPanelLength") / 2;
for (index = [0 : split[1] - 1])
rotate([0, 0, index * 360 / split[1] + 180 / split[1]])
alignFrame([ringRadius, 0, ringZ], [0, 0, 1], [1, 0, 0]) _debrisPanel();
}
debrisPanels();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
// ============================================================================
// hybridDockingPort.scad — passive SSVP-M8000 "hybrid" docking unit
// Eric Moderbacher
// 2026-10-21
// Wikipedia (Russian Docking System): the hybrid keeps SSVP's probe-and-drogue
// capture but hard-docks on an APAS-95 collar with 12 latches; the passage is
// 800 mm. russpace.ucoz.ru: Zvezda's forward, zenith and nadir ports carry
// passive hybrid units. Collar size, latch shape and drogue depth are ASSUMED.
// Mating face at z = 0 facing +Z; collar and drogue extend -Z into the host.
// ============================================================================
use <../config.scad>
include <../detail.scad>
module hybridDockingPort() {
collar = getZvezda("hybridCollarDiameter");
height = getZvezda("hybridCollarHeight");
mouth = getZvezda("drogueMouthDiameter");
depth = getZvezda("drogueDepth");
latches = getZvezda("hybridLatchCount");
color("Silver") translate([0, 0, -height]) difference() {
cylinder(h = height, d = collar);
translate([0, 0, -1]) cylinder(h = height + 2, d = mouth + 40);
}
color("DimGray") translate([0, 0, -depth]) difference() {
cylinder(h = depth - 1, d1 = mouth * 0.25, d2 = mouth);
translate([0, 0, 15]) cylinder(h = depth, d1 = mouth * 0.25 - 30, d2 = mouth - 30);
}
color("DarkGray") for (index = [0 : latches - 1]) rotate([0, 0, index * 360 / latches])
translate([collar / 2 - 70, -30, -20]) cube([50, 60, 40]);
}
hybridDockingPort();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
// ============================================================================
// porthole.scad — a round Zvezda window: frame ring and dark pane
// Eric Moderbacher
// 2026-10-21
// NASA Zvezda press kit p8 gives the clear diameters (9 in forward, 16 in in
// the work compartment); frame width and height are ASSUMED cosmetic offsets.
// Pane on z = 0, frame rising +Z, centered on the origin.
// ============================================================================
use <../config.scad>
include <../detail.scad>
module porthole(diameter) {
frame = diameter * 0.25;
color("Silver") difference() {
cylinder(h = 40, d = diameter + 2 * frame);
translate([0, 0, -1]) cylinder(h = 42, d = diameter);
}
color("Black") cylinder(h = 10, d = diameter);
}
porthole(getZvezda("forwardWindowDiameter"));
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65
// ============================================================================
// pressureHull.scad — Zvezda's three pressurized compartments
// Eric Moderbacher
// 2026-10-21
// Nauka i Zhizn No. 10 (2000): a 2.2 m spherical transfer compartment joined to
// a truncated cone; a work compartment of two cylinders, 2.9 m by 3.5 m and
// 4.1 m by 2.9 m, joined by a conical adapter; a 2 m by 1.34 m transfer chamber
// aft. NASA press kit p8: three ports on the sphere (forward, up, down) and one
// aft. The transfer cone's narrow end, the port necks and the wall are ASSUMED.
// Welded aluminium (NIZ); drawn as a hollow shell with an 800 mm passage
// (Wikipedia, Russian Docking System) through each port neck.
// Element frame: z = 0 on the forward mating face, +Z aft; radial ports at
// element angles 0 (zenith) and 180 (nadir).
// ============================================================================
use <../config.scad>
use <zvezdaLayout.scad>
include <../detail.scad>
function getZvezdaRadialPortAngles() = [0, 180];
module _zvezdaPortNecks(diameter, extra) {
sphereZ = getZvezdaSphereCenterZ();
radius = getZvezdaSphereRadius();
translate([0, 0, -extra]) cylinder(h = sphereZ + extra, d = diameter);
for (angle = getZvezdaRadialPortAngles())
rotate([0, 0, angle]) translate([0, 0, sphereZ]) rotate([0, 90, 0])
cylinder(h = radius + getZvezda("radialPortHeight") + extra, d = diameter);
translate([0, 0, getZvezdaChamberEndZ() - 10]) cylinder(h = getZvezda("aftPortHeight") + 10 + extra, d = diameter);
}
module _zvezdaHullSolid(inset) {
smallRadius = getZvezda("workSmallDiameter") / 2 - inset;
largeRadius = getZvezda("workLargeDiameter") / 2 - inset;
chamberRadius = getZvezda("transferChamberDiameter") / 2 - inset;
coneStart = getZvezdaTransferConeStartZ();
workStart = getZvezdaWorkStartZ();
smallEnd = getZvezdaSmallEndZ();
largeStart = getZvezdaLargeStartZ();
largeEnd = getZvezdaLargeEndZ();
translate([0, 0, getZvezdaSphereCenterZ()]) sphere(r = getZvezdaSphereRadius() - inset);
translate([0, 0, coneStart])
cylinder(h = workStart - coneStart + 0.01, r1 = getZvezda("transferConeNarrowDiameter") / 2 - inset, r2 = smallRadius);
translate([0, 0, workStart]) cylinder(h = smallEnd - workStart, r = smallRadius);
translate([0, 0, smallEnd - 0.01]) cylinder(h = largeStart - smallEnd + 0.02, r1 = smallRadius, r2 = largeRadius);
translate([0, 0, largeStart]) cylinder(h = largeEnd - largeStart - inset, r = largeRadius);
translate([0, 0, largeEnd - inset - 0.01]) cylinder(h = getZvezdaChamberEndZ() - largeEnd + 0.01, r = chamberRadius);
}
module pressureHull() {
wall = getZvezda("shellWallThickness");
color("WhiteSmoke") difference() {
union() {
_zvezdaHullSolid(0);
_zvezdaPortNecks(getZvezda("portNeckDiameter"), 0);
}
_zvezdaHullSolid(wall);
_zvezdaPortNecks(getZvezda("passageDiameter"), 10);
}
}
pressureHull();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
// ============================================================================
// radiatorPanels.scad — radiator panels on Zvezda's two work cylinders
// Eric Moderbacher
// 2026-10-21
// Energia (Zvezda page) and Nauka i Zhizn No. 10 (2000): radiators on the
// cylindrical parts of the work compartment, doubling as micrometeoroid
// shields. Their outer face on the large cylinder is taken to be the 4.35 m
// maximum diameter Energia gives; the panel count, gaps and thickness are
// ASSUMED. Gaps fall on multiples of 360 / panelsAround, so the array roots at
// element angles 90 and 270 pass between panels. holes: [angle, z, diameter].
// Element frame as in zvezdaLayout.scad.
// ============================================================================
use <../config.scad>
use <zvezdaLayout.scad>
include <../detail.scad>
module _radiatorBand(hullDiameter, startZ, endZ) {
around = getZvezda("radiatorPanelsAround");
gap = getZvezda("radiatorGapDegrees");
inner = hullDiameter / 2 + getZvezdaRadiatorStandoff();
outer = getZvezdaRadiatorOuterRadius(hullDiameter);
for (index = [0 : around - 1])
rotate([0, 0, index * 360 / around + gap / 2]) rotate_extrude(angle = 360 / around - gap)
translate([inner, startZ]) square([outer - inner, endZ - startZ]);
}
module radiatorPanels(holes = []) {
margin = 150;
color("White") difference() {
union() {
_radiatorBand(getZvezda("workSmallDiameter"), getZvezdaWorkStartZ() + margin, getZvezdaSmallEndZ() - margin);
_radiatorBand(getZvezda("workLargeDiameter"), getZvezdaLargeStartZ() + margin, getZvezdaLargeEndZ() - margin);
}
for (hole = holes)
rotate([0, 0, hole[0]]) translate([0, 0, hole[1]]) rotate([0, 90, 0])
cylinder(h = getZvezda("maxDiameter"), d = hole[2]);
}
}
radiatorPanels();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
// ============================================================================
// ssvpDockingPort.scad — passive SSVP-G4000 drogue docking unit
// Eric Moderbacher
// 2026-10-21
// NASA Zvezda press kit (July 2000) p8: the aft port has a probe-and-cone
// mechanism for Progress and Soyuz. Wikipedia (Russian Docking System): the
// standard SSVP-G4000 is passive on Zvezda's aft port, 8 hard-capture locks,
// 800 mm passage. russpace.ucoz.ru: only the aft port keeps the old SSVP.
// Collar size, lock shape and drogue depth are ASSUMED.
// Mating face at z = 0 facing +Z; collar and drogue extend -Z into the host.
// ============================================================================
use <../config.scad>
include <../detail.scad>
module ssvpDockingPort() {
collar = getZvezda("ssvpCollarDiameter");
height = getZvezda("ssvpCollarHeight");
mouth = getZvezda("drogueMouthDiameter");
depth = getZvezda("drogueDepth");
latches = getZvezda("ssvpLatchCount");
color("Silver") translate([0, 0, -height]) difference() {
cylinder(h = height, d = collar);
translate([0, 0, -1]) cylinder(h = height + 2, d = mouth + 40);
}
color("DimGray") translate([0, 0, -depth]) difference() {
cylinder(h = depth - 1, d1 = mouth * 0.25, d2 = mouth);
translate([0, 0, 15]) cylinder(h = depth, d1 = mouth * 0.25 - 30, d2 = mouth - 30);
}
color("DarkGray") for (index = [0 : latches - 1]) rotate([0, 0, index * 360 / latches + 180 / latches])
translate([collar / 2 - 60, -40, 0]) cube([40, 80, 30]);
}
ssvpDockingPort();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125
// ============================================================================
// zvezda.scad — Zvezda (Service Module) assembly
// Eric Moderbacher
// 2026-10-21
// Energia: 13.11 m body, 4.35 m at most, wings 29.73 m tip to tip; Nauka i
// Zhizn No. 10 (2000): a 2.2 m transfer sphere with three docking units, a
// work compartment of 2.9 m and 4.1 m cylinders, a 2 m transfer chamber;
// NASA press kit (July 2000): an unpressurized aft compartment around the
// chamber, 13 windows; G10 p63: 2 main and 32 attitude engines in 6 clusters,
// two Kurs antennas and a Luch antenna; space.com 2007: 23 SMDP debris panels.
// Russian docking units: passive hybrids forward, zenith and nadir; a passive
// SSVP drogue aft. Since 2021 the nadir port holds Nauka (Pirs before it), the
// zenith port Poisk, the forward port Zarya's aft port. Port neck heights, the
// transfer cone, every placement on the outside (windows, handrails, antennas,
// docking target) and every size not listed above are ASSUMED and echoed.
// Element frame: z = 0 on the forward mating face (to Zarya), +Z toward station
// aft; once installed, element +X is station zenith and +Y station port.
// ============================================================================
use <../config.scad>
use <../common/placement.scad>
use <../common/moduleShell.scad>
use <../common/handrail.scad>
use <../common/grappleTarget.scad>
use <zvezdaLayout.scad>
use <pressureHull.scad>
use <radiatorPanels.scad>
use <debrisPanels.scad>
use <assemblyCompartment.scad>
use <hybridDockingPort.scad>
use <ssvpDockingPort.scad>
use <zvezdaSolarWing.scad>
use <antennas.scad>
use <porthole.scad>
include <../detail.scad>
showDebrisShield = true;
// Sun-tracking rotation of both wings about their own axis, degrees; 0 = cells face zenith.
solarArrayTrackAngle = 0;
// Element angles once installed (element +X is zenith, +Y port).
function getZvezdaWallAngle(name) =
name == "zenith" ? 0 : name == "port" ? 90 : name == "nadir" ? 180 : name == "starboard" ? 270 :
assert(false, str("unknown wall ", name));
function getZvezdaPortNames() = ["forward", "zenith", "nadir", "aft"];
// Origin on the named port's mating face, +Z pointing away from Zvezda.
module placeOnZvezdaPort(portName) {
if (portName == "forward")
rotate([180, 0, 0]) children();
else if (portName == "zenith" || portName == "nadir")
rotate([0, 0, getZvezdaWallAngle(portName)])
translate([getZvezdaSphereRadius() + getZvezda("radialPortHeight"), 0, getZvezdaSphereCenterZ()])
rotate([0, 90, 0]) children();
else if (portName == "aft")
translate([0, 0, getZvezdaAftFaceZ()]) children();
else
assert(false, str("unknown Zvezda port ", portName));
}
function getZvezdaLargeWindowZ() = (getZvezdaLargeStartZ() + getZvezdaLargeEndZ()) / 2;
module _forwardWindows() {
count = getZvezda("forwardWindowCount");
radius = getZvezdaSphereRadius();
for (index = [0 : count - 1])
let(azimuth = 30 + index * 360 / count,
direction = [sin(45) * cos(azimuth), sin(45) * sin(azimuth), -cos(45)])
alignFrame([0, 0, getZvezdaSphereCenterZ()] + direction * (radius - 15),
[-sin(azimuth), cos(azimuth), 0], direction)
porthole(getZvezda("forwardWindowDiameter"));
}
module _zvezdaHandrails() {
railLength = getCommon("handrailLength");
count = getZvezda("handrailsPerRow");
for (band = [[getZvezda("workSmallDiameter"), getZvezdaWorkStartZ(), getZvezdaSmallEndZ()],
[getZvezda("workLargeDiameter"), getZvezdaLargeStartZ(), getZvezdaLargeEndZ()]],
angle = [22.5 : 90 : 337.5], index = [0 : count - 1])
let(pitch = (band[2] - band[1]) / count, z = band[1] + (index + 0.5) * pitch)
surfaceFrame(angle, z - railLength / 2, getZvezdaRadiatorOuterRadius(band[0]), true) handrail();
}
module _zvezdaAntennas() {
coneZ = (getZvezdaTransferConeStartZ() + getZvezdaWorkStartZ()) / 2;
coneRadius = (getZvezda("transferConeNarrowDiameter") + getZvezda("workSmallDiameter")) / 4;
surfaceFrame(225, coneZ, coneRadius, true) kursAntenna();
translate([getZvezda("aftDiskDiameter") / 2 - getZvezda("thrusterPodSize"), 0, getZvezdaChamberEndZ()]) kursAntenna();
surfaceFrame(315, getZvezdaLargeEndZ() + getZvezda("assemblySkirtLength") / 2, getZvezda("maxDiameter") / 2, true)
liraAntenna();
}
module zvezda(arrayTrackAngle = solarArrayTrackAngle, debrisShieldShown = showDebrisShield) {
for (row = getAssumedRows(getZvezdaDimensions()))
echo(str("ASSUMED: zvezda ", row[0], " = ", row[1], " (", row[3], ")"));
nadir = getZvezdaWallAngle("nadir");
largeWindow = getZvezda("largeWindowDiameter");
wingRootRadius = getZvezda("workSmallDiameter") / 2;
pressureHull();
radiatorPanels([[nadir, getZvezdaLargeWindowZ(), largeWindow * 2]]);
if (debrisShieldShown) debrisPanels();
assemblyCompartment();
for (port = ["forward", "zenith", "nadir"]) placeOnZvezdaPort(port) hybridDockingPort();
placeOnZvezdaPort("aft") ssvpDockingPort();
for (side = [getZvezdaWallAngle("port"), getZvezdaWallAngle("starboard")])
rotate([0, 0, side]) translate([wingRootRadius, 0, getZvezdaArrayAxisZ()])
rotate([arrayTrackAngle, 0, 0]) rotate([side == getZvezdaWallAngle("port") ? 90 : -90, 0, 0])
zvezdaSolarWing();
_forwardWindows();
surfaceFrame(nadir, getZvezdaLargeWindowZ(), getZvezda("workLargeDiameter") / 2, true) porthole(largeWindow);
_zvezdaHandrails();
_zvezdaAntennas();
surfaceFrame(getZvezda("dockingTargetAngle"), getZvezdaSphereCenterZ(), getZvezdaSphereRadius() - 10, true)
grappleTarget();
}
zvezda();
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60
// ============================================================================
// zvezdaLayout.scad — where each Zvezda section starts and ends along the axis
// Eric Moderbacher
// 2026-10-21
// Functions only, shared by every Zvezda part. Section sizes come from Nauka i
// Zhizn No. 10 (2000) and the Energia Zvezda page (see getZvezdaDimensions()).
// The sources give no length for the transfer compartment's cone, so it takes
// whatever the 13.11 m body leaves after the sourced sections and the ASSUMED
// port heights. The work compartment's cone is the Energia 7.7 m less NIZ's two
// cylinders. The radiator standoff is what puts their outer face at the
// sourced 4.35 m maximum diameter.
// Element frame: z = 0 on the forward (Zarya) mating face, +Z toward station
// aft; element +X is station zenith, +Y station port once installed.
// ============================================================================
use <../config.scad>
function getZvezdaSphereRadius() = getZvezda("transferSphereDiameter") / 2;
function getZvezdaSphereCenterZ() = getZvezda("forwardPortHeight") + getZvezdaSphereRadius();
function getZvezdaTransferConeLength() =
getZvezda("overallLength") - getZvezda("forwardPortHeight") - getZvezda("transferSphereDiameter")
- getZvezda("workCompartmentLength") - getZvezda("transferChamberLength") - getZvezda("aftPortHeight");
function getZvezdaWorkConeLength() =
getZvezda("workCompartmentLength") - getZvezda("workSmallLength") - getZvezda("workLargeLength");
function getZvezdaWorkStartZ() =
getZvezda("forwardPortHeight") + getZvezda("transferSphereDiameter") + getZvezdaTransferConeLength();
function getZvezdaSmallEndZ() = getZvezdaWorkStartZ() + getZvezda("workSmallLength");
function getZvezdaLargeStartZ() = getZvezdaSmallEndZ() + getZvezdaWorkConeLength();
function getZvezdaLargeEndZ() = getZvezdaLargeStartZ() + getZvezda("workLargeLength");
function getZvezdaChamberEndZ() = getZvezdaLargeEndZ() + getZvezda("transferChamberLength");
function getZvezdaAftFaceZ() = getZvezdaChamberEndZ() + getZvezda("aftPortHeight");
// The transfer cone meets the sphere where the sphere is as wide as the cone's narrow end.
function getZvezdaTransferConeStartZ() =
let(radius = getZvezdaSphereRadius(), narrow = getZvezda("transferConeNarrowDiameter") / 2)
getZvezdaSphereCenterZ() + sqrt(radius * radius - narrow * narrow);
function getZvezdaRadiatorStandoff() =
(getZvezda("maxDiameter") - getZvezda("workLargeDiameter")) / 2 - getZvezda("radiatorThickness");
function getZvezdaRadiatorOuterRadius(hullDiameter) =
hullDiameter / 2 + getZvezdaRadiatorStandoff() + getZvezda("radiatorThickness");
function getZvezdaArrayAxisZ() = getZvezdaWorkStartZ() + getZvezda("arrayAxisFromWorkStart");
function getZvezdaWingLength() = getZvezda("wingSpan") / 2 - getZvezda("workSmallDiameter") / 2;
function getZvezdaWingPanelPitch() =
(getZvezdaWingLength() - getZvezda("wingRootStrutLength")) / getZvezda("wingPanelsAlong");
function getZvezdaWingPanelLength() = getZvezdaWingPanelPitch() - getZvezda("wingPanelGap");
// Two rows of panels, one each side of the boom, carry the sourced cell area between them.
function getZvezdaWingRowWidth() =
getZvezda("wingCellAreaSquareMeters") * 1e6 / (2 * getZvezda("wingPanelsAlong") * getZvezdaWingPanelLength());
function getZvezdaWingWidth() = 2 * getZvezdaWingRowWidth() + getZvezda("wingBoomGap");
function getZvezdaDebrisPanelSplit() = [getZvezda("debrisPanelsOnCone"), getZvezda("debrisPanelsOnSmall")];
function getZvezdaThrusterClusters() =
concat([for (index = [0 : 3]) getZvezda("aftClusterThrusters")],
[for (index = [0 : 1]) getZvezda("sideClusterThrusters")]);
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
// ============================================================================
// zvezdaSolarWing.scad — one of Zvezda's two rigid-panel solar array wings
// Eric Moderbacher
// 2026-10-21
// Energia: the wings span 29.73 m tip to tip, 76 m2 of cells; Nauka i Zhizn
// No. 10 (2000): two arrays of 38 m2 each that track the Sun. The wing runs
// from the 2.9 m small cylinder's hull (russpace.ucoz.ru: the arrays fold
// against the small diameter at launch) out to half the span. The panel count
// (four hinged pairs each side of a central boom), the root strut and every gap
// are ASSUMED from the STS-106 photo of Zarya and Zvezda; the row width is
// what makes the panels carry the sourced 38 m2. solarArrays/rigidPanelWing.scad
// has neither the boom nor the sourced area, hence this file.
// Root at the origin, wing along +X, cells facing +Z, rows either side of Y = 0.
// ============================================================================
use <../config.scad>
use <zvezdaLayout.scad>
include <../detail.scad>
module _wingPanel(length, width) {
color("MidnightBlue") cube([length, width, getZvezda("wingPanelThickness")]);
if (isFineDetail())
color("Silver")
for (index = [1 : 3])
translate([0, index * width / 4 - 5, getZvezda("wingPanelThickness")]) cube([length, 10, 4]);
}
module zvezdaSolarWing() {
length = getZvezdaWingLength();
strut = getZvezda("wingRootStrutLength");
pitch = getZvezdaWingPanelPitch();
panelLength = getZvezdaWingPanelLength();
rowWidth = getZvezdaWingRowWidth();
boomGap = getZvezda("wingBoomGap");
boom = getZvezda("wingBoomDiameter");
color("Silver") {
translate([0, 0, -boom / 2]) rotate([0, 90, 0]) cylinder(h = length, d = boom);
translate([0, -boomGap / 2, -boom / 2]) cube([strut, boomGap, boom]);
}
for (index = [0 : getZvezda("wingPanelsAlong") - 1], side = [-1, 1])
let(x = strut + index * pitch + getZvezda("wingPanelGap") / 2)
translate([x, side > 0 ? boomGap / 2 : -boomGap / 2 - rowWidth, -getZvezda("wingPanelThickness") / 2])
_wingPanel(panelLength, rowWidth);
}
zvezdaSolarWing();