All files / web/src/components/create/abacus abacus-kit-plate.ts

99.49% Statements 1181/1187
89.57% Branches 146/163
100% Functions 25/25
99.49% Lines 1181/1187

Press n or j to go to the next uncovered block, b, p or k for the previous block.

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 11881x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 108x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 310x 310x 310x 310x 310x 310x 310x 310x 310x 310x 310x 310x 310x 310x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 34x 34x 34x 34x 34x 125x 413x 345x 345x 345x 345x 345x 345x 345x 345x 413x 68x 68x 68x 68x 68x 68x 68x 68x 413x 125x 34x 34x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 75x 75x 75x 75x 75x 75x 75x 10x 10x 75x 75x 75x 85x 85x 85x 85x 85x 85x 85x 75x     75x 75x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 371x 371x 371x 371x 371x 371x 224x 224x 224x 4104x 4104x 4104x 4104x 4104x 4104x 224x 371x 371x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 21x 21x 21x 21x 21x 21x 21x 21x 21x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 162x 162x 162x 162x 162x 162x 162x 162x 2097x 2097x 2097x 2097x 162x 162x 162x 162x 162x 162x 162x 162x 162x 162x 152x 152x 152x 152x 162x 162x 770x 770x 770x 200x 200x 200x 770x 162x 162x 162x 162x 162x 162x 152x 152x 152x 152x 152x 152x 152x 162x 162x 770x 48x 770x 48x 48x 770x 162x 162x 1x 1x 1x 66x 66x 66x 66x 66x 66x 66x 66x 66x 66x 66x 66x 66x 66x 66x 1x 1x 1x 1x 1x 1x 1x 1x 1x 107x 107x 107x 107x 107x 107x 107x 107x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 107x 107x 107x 107x 107x 66x 66x 66x 66x 66x 66x 66x 66x 41x 107x 107x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 107x 107x 107x 107x 107x 107x 107x 107x 107x 107x 107x 54x 3x 3x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 56x 56x 56x 56x 53x 53x 53x 53x 3x 56x 56x 56x 3x 1x 1x 1x 1x 1x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 1x 1x 1x 1x 1x 1x 3x 3x 3x 3x 3x 1x 1x 1x 1x 1x 1x 1x 107x 107x 107x 107x 107x 107x 107x 107x 107x 107x 107x 107x 107x 107x 107x 107x 107x 107x 107x 107x 107x 24x 24x 24x 24x 24x 24x 24x 24x 24x 24x 24x 83x 83x 83x 83x 83x 83x 107x 331x 331x 331x 283x 283x 283x 331x 35x 35x 107x 107x 1x 1x 1x 1x 331x 331x 331x 331x 331x 331x 331x 331x 331x 331x 331x 331x 331x 331x 4224x 4224x 4224x 331x 331x 331x 331x 544x 331x 331x 331x 331x 19x 19x 19x 19x 19x 19x 19x 19x 19x 19x 331x 150x 150x 150x 150x 150x 150x 150x 150x 150x 150x 162x 162x 2097x 2097x         2097x 2097x 2097x 2097x 2097x 2097x 2097x 2097x 2097x 162x 162x 162x 162x 162x 162x 162x 162x 162x 162x 162x 162x 331x 114x 114x 114x 114x 114x 114x 114x 114x 114x 48x 331x 10x 38x 485x 485x 485x 38x 331x 331x 10x 38x 38x 38x 38x 38x 38x 38x 331x 331x 331x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 22x 22x 22x 22x 22x 22x 22x 22x 22x 22x 22x 1x 1x 21x 21x 21x 21x 21x 21x 21x 21x 21x 21x 21x 21x 21x 21x 21x 21x 21x 21x 21x 21x 21x 21x 21x 21x 741x 741x 741x 61x 61x 61x 61x 61x 61x 61x 61x 61x 61x 61x 741x 21x 21x 22x 261x 261x 20x 20x 20x 20x 20x 20x 20x 20x 20x 20x 22x 260x 260x 260x 260x 20x 20x 20x 20x 20x 20x 20x 20x 20x 22x 22x 22x 22x 22x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 17x 17x 17x 17x 17x 17x 17x 17x 17x 17x 17x 17x 17x 17x 17x 17x 17x 17x 17x 17x 17x 182x 182x 182x 182x 182x 182x 182x 182x 182x 182x 182x 182x 182x 182x 182x 182x 28x 28x 182x 14x 14x 14x 14x 14x 14x 17x 182x 182x 182x 182x 182x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x  
/**
 * The modular column kit on ONE build plate (Gitea #32, Phase A).
 *
 * A print ticket is one plate, one sliced job — there is no quantity field
 * anywhere in the contract and no batch concept. So for the kit to become "hit
 * print", somebody has to lay 13 modules out on a bed. Today that somebody is
 * the user, in their slicer, from the kit zip. This module is that somebody.
 *
 * WHAT IS BORROWED. Frame Studio already solved plate packing and the packer is
 * shared: MaxRects under a 12-way tournament (3 fit heuristics × 4 biggest-first
 * orderings, fewest plates then tightest envelope), gap applied by inflating
 * footprints, free 90° rotation. The purge tower is RESERVED BEFORE packing, as
 * a keep-out the parts pack around — a tower that lands on a part is a slice the
 * printer refuses outright, which is our own exit-154/155 history in someone
 * else's words. Bed truth comes from the printer, not a constant.
 *
 * WHAT IS OURS. Frame Studio packs single-body parts. A module is a multi-body
 * assembly — frame + its bead column + printed TPU feet + inset text inlays —
 * whose bodies must stay registered to each other or the beads come off their
 * rods and the inlays miss their pockets. So the new piece is {@link
 * placeModuleBodies}: one placement (the free 90° turn AND the translate)
 * applied to EVERY body of that module through ONE shared basis. Per-body
 * re-origin is the bug this module exists to not have — it is what "rigid group"
 * means, and it is why the basis ({@link ModuleBasis}) is computed once per
 * module kind and used by BOTH the packer's footprint and the transform. If
 * those two ever came from different boxes, every module would print shifted
 * from where the packer thought it was.
 *
 * WHAT IS SHARED WITH THE ZIP PATH. Everything upstream of placement:
 * `moduleSoups` runs the same hard-error gate (shell topology, footless module,
 * empty text pockets) each module's standalone 3MF runs, and the plate emits
 * through the same `emitThreeMfBodies` tail, so container law and role→filament
 * law each still live in exactly one place. The per-module zip path is
 * unchanged — a plate is a different container, not a different abacus.
 *
 * REFUSALS. This ticket either fits one plate or falls back to the zip. An
 * overfull plate, a module too big for the bed, or a tower with nowhere to go
 * all raise {@link KitPlateFitError} naming what didn't fit and which knob moves
 * it. Never silently drop a module: a kit missing a column is not a kit.
 *
 * IMPORT NOTE. The packer and the tower reserve come from `@eink/plate-packing`
 * (eink PR #569 lifted them out of `@eink/frames-engine`). `meshBounds`
 * deliberately stays behind: it reads triangles, and the point of the
 * extraction is that the packing package is geometry over numbers — no STL, no
 * 3MF, no DOM. `BedSize`/`BedRect` are declared identically in both packages
 * and structurally interchangeable, which is why the four other abacus files
 * that only name the TYPE can keep importing it beside the function they feed
 * it to. The swap was NOT a no-op — see `packKitPlate` on tower candidates.
 */
import { meshBounds } from '@eink/frames-engine/print-bundle'
import {
  type BedRect,
  type BedSize,
  type PackItem,
  type PlacedTower,
  packPlates,
  type TowerReserve,
  towerMargin,
  towerPlacementCandidates,
} from '@eink/plate-packing'
import {
  type AbacusThreeMf,
  emitThreeMfBodies,
  type PartSoup,
  type SpoolBodySummary,
} from './abacus-3mf'
import {
  BAMBU_256_BED,
  DEFAULT_WIPE_TOWER_PROFILE,
  envelopeForFilaments,
  SUPPORT_SKIRT,
  TOWER_GAP,
  type WipeTowerProfileGeometry,
} from './abacus-3mf-assembly'
import { resolveInfill } from './abacus-infill'
import { type FilamentMap, isModular, type Params } from './abacus-model'
import {
  type ModuleExportParts,
  type ModuleKind,
  type ModuleSoups,
  moduleKitPlan,
  moduleSoups,
} from './abacus-module-kit'
 
const EPS = 1e-6
 
/**
 * How far ONE module's first layer reaches past its declared outline, per side.
 *
 * Both numbers are THH's published `clearance.outlineGrowthMm` (things-haunt-house
 * #434), folded out of the very presets it slices us with rather than measured
 * here:
 *   - no supports: `brim_object_gap` 0.1 + `brim_width` 5 = 5.1
 *   - supports:    + `support_object_xy_distance` 0.35 + `support_expansion` 0 = 5.45
 *
 * Restated as constants only because the live block rides on an unmerged service
 * change; read them off the printer row once it lands, and delete these.
 *
 * NOT included, deliberately: the skirt. `skirt_loops` is 0 on every intent — no
 * skirt is drawn at all — and a skirt would ring the plate's convex hull anyway,
 * never the space between two modules. `SUPPORT_SKIRT` (8 mm) is still right for
 * the tower ring, which is a different measurement of a different thing; using it
 * here spent ~5 mm of bed per gap on a loop this printer never prints.
 */
const MODULE_GROWTH_MM = 5.1
const MODULE_GROWTH_SUPPORTED_MM = 5.45
 
/** One module's first-layer reach past its declared outline, per side. */
const moduleGrowthMm = (supports: boolean): number =>
  supports ? MODULE_GROWTH_SUPPORTED_MM : MODULE_GROWTH_MM
 
/**
 * Gap between neighbouring modules — BOTH grow, so it's twice one module's
 * reach, never once. The same reach must also be LEFT AT THE BED EDGE, where
 * there is no neighbour to share it with — that single-growth margin is
 * `firstLayerBed`'s job, not this gap's.
 *
 * The 4 mm this started as is the shared bundler's default, and it is wrong for a
 * kit in both states: it doesn't hold two brims. What it is NOT is the cause of
 * the exit 192 this file used to blame it for — see `clearOfKeepOuts`. Two modules
 * whose brims overlap slice without complaint; they just print fused.
 */
const moduleGapMm = (supports: boolean): number => 2 * moduleGrowthMm(supports)
 
// ---- refusals ---------------------------------------------------------------
 
/** Why a kit can't ship as one plate. Each wants a different move from the
 *  person who composed the design, so they stay distinguishable. */
export type KitPlateRefusal =
  /** Everything fits the bed alone, but not together. */
  | 'overflow'
  /** One module exceeds the usable bed even by itself. */
  | 'too-big'
  /** No clear rectangle left for the purge tower. */
  | 'no-tower-room'
  /** The plate can't be slid off the printer's keep-out zone (see `clearOfKeepOuts`). */
  | 'keep-out'
  /** Fits the bed, but not with the back `rearBandMm` clear — everything printed
   *  (tower and first-layer growth included) has to stay in front of the
   *  two-stage hand-off band (see `packKitPlate`). */
  | 'rear-band'
 
/**
 * A kit that cannot be laid out on one plate. `modules` names the labels that
 * didn't fit (empty for a tower refusal — nothing is at fault but the bed), so a
 * caller can point at them instead of re-deriving the diagnosis from prose.
 *
 * Typed rather than a bare Error because the fallback differs: the zip download
 * still prints every one of these module by module, and the UI should say so.
 */
export class KitPlateFitError extends Error {
  readonly reason: KitPlateRefusal
  readonly modules: readonly string[]
  /** What happened, on its own — the UI headline. */
  readonly headline: string
  /** The knob to turn, on its own. Every refusal has one, so this is never null:
   *  a plate we won't ship must always leave the user somewhere to go. */
  readonly remediation: string
 
  constructor(
    reason: KitPlateRefusal,
    modules: readonly string[],
    headline: string,
    remediation: string
  ) {
    // `message` stays the whole story so a log line or a bare `.message` reader
    // loses nothing; the split above is for a UI that renders the two apart.
    super(`${headline} ${remediation}`)
    this.name = 'KitPlateFitError'
    this.reason = reason
    this.modules = modules
    this.headline = headline
    this.remediation = remediation
  }
}
 
const bedLabel = (bed: BedSize): string => `${bed.wMm} × ${bed.dMm} mm bed`
 
// ---- instances --------------------------------------------------------------
 
/** One physically printed module on the plate: which geometry, which global
 *  column's bead roles ink it, and the two names that follow it downstream. */
export interface KitPlateInstance {
  /** Stable and unique within the plate — what the packer keys on. */
  readonly id: string
  /** Unique human name — what a job binding, a bed preview or an error names. */
  readonly label: string
  readonly kind: ModuleKind
  /** The GLOBAL column whose bead roles color this instance's beads. Bead color
   *  varies per column, so an instance carries its own — a placed module that
   *  borrowed its neighbour's column would print the wrong beads. */
  readonly column: number
  /** The kit-zip member this instance is a copy of. The label deliberately does
   *  NOT encode the mid variant (`mid 3 of 11` reads better than
   *  `mid-10s 1 of 3`), so the machine-readable variant identity lives here. */
  readonly file: string
}
 
/**
 * Expand the kit plan's counts into individually placeable instances: `mid ×11`
 * becomes eleven modules, each with its own id and label.
 *
 * Mid instances are numbered ACROSS variants, in plan order — the five bead-slot
 * variants of a 13-column kit are eleven mid modules to the person at the
 * printer, and eleven pieces is what they'll count off the plate. A kit with a
 * single mid (a 3-column design) drops the ordinal rather than reading `mid 1
 * of 1`. Ends are never numbered: there is exactly one of each.
 */
export function kitPlateInstances(p: Params, fm: FilamentMap): KitPlateInstance[] {
  const entries = moduleKitPlan(p, fm)
  const mids = entries.reduce((n, e) => n + (e.kind === 'mid' ? e.count : 0), 0)
  const out: KitPlateInstance[] = []
  let nth = 0
  for (const e of entries) {
    for (let k = 0; k < e.count; k++) {
      if (e.kind === 'mid') {
        nth++
        out.push({
          id: `mid-${nth}`,
          label: mids === 1 ? 'mid' : `mid ${nth} of ${mids}`,
          kind: 'mid',
          column: e.column,
          file: e.file,
        })
      } else {
        out.push({
          id: e.kind,
          label: `${e.kind} end`,
          kind: e.kind,
          column: e.column,
          file: e.file,
        })
      }
    }
  }
  return out
}
 
// ---- footprints -------------------------------------------------------------
 
/**
 * A module kind's XY footprint and the origin its bodies are measured from —
 * the ONE box that both the packer and {@link placeModuleBodies} read.
 *
 * Measured off the GATED soups rather than the raw renders, so what's measured
 * is exactly what ships: a feet render present on a design whose feet aren't
 * printed never enters the geometry and must never enter the footprint either.
 * In practice the frame body decides the box (feet sit in its pockets, inlays in
 * its carved text), but the union costs one pass and cannot be wrong if some
 * future part pass pokes proud.
 */
export interface ModuleBasis {
  readonly kind: ModuleKind
  /** XY origin of the module's own render frame — subtracted on placement. There
   *  is deliberately no Z here: placement is an XY decision, and the plate's drop
   *  to the bed stays with the container (see {@link placeModuleBodies}). */
  readonly minX: number
  readonly minY: number
  /** Footprint fed to the packer (un-rotated). */
  readonly wMm: number
  readonly hMm: number
}
 
/** Measure one kind's basis from its gated soups. Every instance of a kind
 *  renders from the same passes — one render per geometry is the kit's whole
 *  dedupe premise — so this is computed once per kind and shared. */
export function moduleBasis(kind: ModuleKind, soups: ModuleSoups): ModuleBasis {
  let minX = Infinity
  let minY = Infinity
  let maxX = -Infinity
  let maxY = -Infinity
  const all = [
    soups.mesh,
    ...soups.partSoups.map((s) => ({
      positions: s.positions,
      triangleCount: s.positions.length / 9,
    })),
  ]
  for (const s of all) {
    if (s.triangleCount === 0) continue
    const b = meshBounds(s)
    if (b.minX < minX) minX = b.minX
    if (b.minY < minY) minY = b.minY
    if (b.maxX > maxX) maxX = b.maxX
    if (b.maxY > maxY) maxY = b.maxY
  }
  if (!Number.isFinite(minX)) {
    throw new Error(`the module_${kind} render has no triangles — nothing to place`)
  }
  return { kind, minX, minY, wMm: maxX - minX, hMm: maxY - minY }
}
 
// ---- rigid-group placement --------------------------------------------------
 
/** The classified body soup's slots live per SHELL, not per soup, so its slot
 *  field is unread on the way through the group transform. */
const SLOT_PER_SHELL = -1
 
/** A triangle soup bound to a filament slot. */
export interface SlotSoup {
  readonly slot: number
  readonly positions: Float32Array
  /** The feet soup's tag, carried through placement — see `PartSoup`. */
  readonly role?: 'feet'
}
 
/** Where the packer put one module: min corner on the bed, and whether it was
 *  turned 90° about Z (free for printing — the print face stays down). */
export interface ModulePlacement {
  readonly xMm: number
  readonly yMm: number
  readonly rotated: boolean
}
 
/**
 * Apply ONE placement to EVERY body of one module — the rigid-group transform.
 *
 * The upstream single-body path rotates a part's soup by (x, y) → (−y, x) and
 * then re-origins it by re-measuring its own bounds. Re-measuring per body is
 * exactly what must not happen here: a module's feet and its frame have
 * different bounds, so each would land on its own origin and the module would
 * come apart — feet under the wrong column, inlays beside their pockets.
 *
 * So the basis is shared and applied analytically. Under (x, y) → (−y, x) the
 * group's box maps to x ∈ [−maxY, −minY], y ∈ [minX, maxX], so re-origining the
 * ROTATED group means subtracting −maxY from x and minX from y. Both are
 * constants of the group, never of the body:
 *
 *   un-rotated   x' = x − minX + X        y' = y − minY + Y
 *   rotated      x' = maxY − y + X        y' = x − minX + Y
 *
 * Z is left exactly as rendered. Packing is an XY decision, and dropping the
 * print onto the bed is the container's job — `assembleAbacus3mf` already
 * translates the whole assembly by −minZ, and doing it here instead would move
 * the frame's z=0 plane out from under the support-transition modifier, which is
 * pinned to that plane (printed feet dip BELOW zero and the modifier follows the
 * frame/support boundary, not the feet).
 *
 * The result is the module standing at its packed slot with every internal
 * relationship — bead-to-channel clearance, foot-to-pocket registration —
 * untouched, because a rotation and a translation are the same rigid motion for
 * all of its bodies.
 */
export function placeModuleBodies(
  bodies: readonly SlotSoup[],
  basis: ModuleBasis,
  at: ModulePlacement
): SlotSoup[] {
  const maxY = basis.minY + basis.hMm
  return bodies.map((body) => {
    const src = body.positions
    const out = new Float32Array(src.length)
    for (let i = 0; i < src.length; i += 3) {
      const x = src[i]
      const y = src[i + 1]
      out[i] = (at.rotated ? maxY - y : x - basis.minX) + at.xMm
      out[i + 1] = (at.rotated ? x - basis.minX : y - basis.minY) + at.yMm
      out[i + 2] = src[i + 2]
    }
    return { ...body, positions: out }
  })
}
 
// ---- packing ----------------------------------------------------------------
 
/** One module as laid out, footprint resolved post-rotation — enough to draw the
 *  bed, and enough for a test to prove nothing overlaps. */
export interface KitPlatePlacement extends KitPlateInstance, ModulePlacement {
  readonly wMm: number
  readonly hMm: number
}
 
export interface KitPlateLayout {
  readonly placements: readonly KitPlatePlacement[]
  /** The reserved purge-tower rect and the pin inside it. */
  readonly tower: PlacedTower
  /** The bed everything above is in the coordinates of. */
  readonly bed: BedSize
}
 
/**
 * A compact string identity for a packed arrangement — same plate in, same
 * string out; move any module and it changes.
 *
 * Feeds the submit's idempotency signature (see `print-idempotency`), where it
 * covers the one way two submits can differ that none of the user's own inputs
 * would show: the PACKER changing its mind. Every field the panel already
 * hashes (params, bed, tower profile) derives this layout today, so a packer
 * heuristic tweak — or the pending swap onto `@eink/plate-packing` — is exactly
 * the case where the design looks unchanged and the plate isn't. Reusing the
 * key there would make THH replay the previously-arranged job and quietly
 * discard the new arrangement.
 *
 * Coordinates are rounded to a micron before hashing: they come out of
 * floating-point packing arithmetic, and a 1e-12 wobble is not a new plate.
 */
export function kitPlateSignature(layout: KitPlateLayout): string {
  const µm = (mm: number): number => Math.round(mm * 1000)
  const parts = layout.placements
    .map((pl) => `${pl.id}@${µm(pl.xMm)},${µm(pl.yMm)}${pl.rotated ? 'r' : ''}`)
    .sort()
    .join('|')
  return `${µm(layout.bed.wMm)}x${µm(layout.bed.dMm)};t${µm(layout.tower.xMm)},${µm(
    layout.tower.yMm
  )};${parts}`
}
 
/**
 * The tower footprint to keep clear, from the printer's own bounded profile —
 * sized to the envelope row for the filament count this plate will actually load,
 * not the profile's worst case (see `envelopeForFilaments`).
 *
 * Mirrors `towerReserveFromCapability`'s envelope → rect translation (it can't be
 * called directly: the service capability carries `version`/`maxFilaments` that
 * the bundled geometry twin doesn't) and adds the clearance ring the mono
 * placement keeps — TOWER_GAP, plus SUPPORT_SKIRT when supports will be on,
 * which is the exit-155 lesson: supports push the first layer past the model
 * outline while the tower's brim reaches back, and the two must not meet.
 *
 * The ring has to be part of the RESERVE, not left to the packer's gap: the
 * packer inflates each footprint on its high sides only, so a module may sit
 * flush against the low edges of any keep-out.
 */
function plateTowerReserve(
  profile: WipeTowerProfileGeometry,
  supports: boolean,
  filaments?: number
): Extract<TowerReserve, { kind: 'reserve' }> {
  const clear = TOWER_GAP + (supports ? SUPPORT_SKIRT : 0)
  const e = envelopeForFilaments(profile, filaments)
  return {
    kind: 'reserve',
    profile: profile.profile,
    wMm: e.maxX - e.minX + 2 * clear,
    dMm: e.maxY - e.minY + 2 * clear,
    // Pin such that the envelope sits centred in the reserved rect: the tower
    // spills below-left of its pin, so the pin is NOT the rect's min corner.
    pinOffsetXMm: clear - e.minX,
    pinOffsetYMm: clear - e.minY,
  }
}
 
/**
 * Slide the whole layout until it clears the printer's keep-out zones — the real
 * cause of exit 192, "Object conflicts were detected".
 *
 * Orca's `layered_print_cleareance_valid` (libslic3r/Print.cpp) has exactly three
 * hard refusals, and all three are keep-out intersections: a model volume's
 * CONVEX HULL against `bed_exclude_area`, the same against the clumping-detection
 * area, and the prime tower's rect against `bed_exclude_area`. Everything else it
 * finds — parts overlapping each other, the tower overlapping a part — it records
 * as a warning and slices anyway. No amount of inter-module spacing has ever been
 * able to cause a 192, and the gap this file widened to chase one never could.
 *
 * The hull is the whole trap. `emitThreeMfBodies` merges every module riding a
 * filament slot into ONE volume, so a body's hull is the hull of modules scattered
 * across the plate — and the hull of an L-shaped arrangement covers the notch the
 * packer carefully carved out. The 13-module plate that took a 192 on 2026-08-05
 * (staged as `29037c8a….raw.3mf`) had ZERO vertices inside the X1C's 18 × 28 mm
 * cutter corner and was refused anyway, because two bodies spanned from one side
 * of that corner to the other. Nudging the identical plate 28.5 mm in +y sliced it
 * clean.
 *
 * So the invariant a packed plate has to keep is not about its parts, it is about
 * its BOUNDING BOX: no keep-out may touch it. Sliding the layout bodily preserves
 * every internal clearance exactly — the tower moves with the modules — which is
 * why this runs after packing rather than shrinking the region packed into. A pure
 * inset would spend a 256-wide strip of bed to avoid an 18 × 28 corner.
 *
 * And the box that has to stay clear is the FIRST-LAYER box, not the outline
 * box — the exit-154 half of the story. Brim and supports grow each module by
 * `growthMm` per side past its declared outline, so the box tested here is built
 * from the GROWN module rects (the tower rect stays as-is: its reserve already
 * carries `TOWER_GAP` + `SUPPORT_SKIRT` of ring, see `plateTowerReserve`). A
 * plate whose outline sat exactly at y = 0 was legal by the old outline test and
 * still sliced its support brim into unprintable area.
 */
function clearOfKeepOuts(
  bed: BedSize,
  modules: readonly BedRect[],
  tower: BedRect,
  growthMm: number
): { dxMm: number; dyMm: number } | null {
  const rects: BedRect[] = [
    ...modules.map((r) => ({
      xMm: r.xMm - growthMm,
      yMm: r.yMm - growthMm,
      wMm: r.wMm + 2 * growthMm,
      dMm: r.dMm + 2 * growthMm,
    })),
    tower,
  ]
  const box = {
    x0: Math.min(...rects.map((r) => r.xMm)),
    y0: Math.min(...rects.map((r) => r.yMm)),
    x1: Math.max(...rects.map((r) => r.xMm + r.wMm)),
    y1: Math.max(...rects.map((r) => r.yMm + r.dMm)),
  }
  const excludes = (bed.exclude ?? []).map((e) => ({
    x0: e.xMm,
    y0: e.yMm,
    x1: e.xMm + e.wMm,
    y1: e.yMm + e.dMm,
  }))
  const clears = (dx: number, dy: number): boolean => {
    const b = { x0: box.x0 + dx, y0: box.y0 + dy, x1: box.x1 + dx, y1: box.y1 + dy }
    // Bounds = no first-layer extrusion off the bed — the exit-154 belt.
    if (b.x0 < -EPS || b.y0 < -EPS || b.x1 > bed.wMm + EPS || b.y1 > bed.dMm + EPS) return false
    return !excludes.some(
      (e) => b.x0 < e.x1 - EPS && b.x1 > e.x0 + EPS && b.y0 < e.y1 - EPS && b.y1 > e.y0 + EPS
    )
  }
 
  // Staying put is always the first candidate; the rest are the minimal single-axis
  // pushes that clear one zone. With several zones a push can land on another, so
  // every candidate is re-checked against all of them and the shortest survivor wins.
  const candidates: { dx: number; dy: number }[] = [{ dx: 0, dy: 0 }]
  for (const e of excludes) {
    candidates.push(
      { dx: e.x1 - box.x0, dy: 0 },
      { dx: e.x0 - box.x1, dy: 0 },
      { dx: 0, dy: e.y1 - box.y0 },
      { dx: 0, dy: e.y0 - box.y1 }
    )
  }
  let best: { dxMm: number; dyMm: number } | null = null
  for (const c of candidates) {
    if (!clears(c.dx, c.dy)) continue
    const move = Math.hypot(c.dx, c.dy)
    if (best === null || move < Math.hypot(best.dxMm, best.dyMm) - EPS) {
      best = { dxMm: c.dx, dyMm: c.dy }
    }
  }
  return best
}
 
/** Maximal free rectangles of `f` once `ex` is carved out (the two overlap-free
 *  cases return `f` untouched). */
function splitRect(f: BedRect, ex: BedRect): BedRect[] {
  const fx1 = f.xMm + f.wMm
  const fy1 = f.yMm + f.dMm
  const ex1 = ex.xMm + ex.wMm
  const ey1 = ex.yMm + ex.dMm
  const disjoint =
    ex.xMm >= fx1 - EPS || ex1 <= f.xMm + EPS || ex.yMm >= fy1 - EPS || ey1 <= f.yMm + EPS
  if (disjoint) return [f]
  const out: BedRect[] = []
  if (ex.xMm > f.xMm + EPS) out.push({ xMm: f.xMm, yMm: f.yMm, wMm: ex.xMm - f.xMm, dMm: f.dMm })
  if (ex1 < fx1 - EPS) out.push({ xMm: ex1, yMm: f.yMm, wMm: fx1 - ex1, dMm: f.dMm })
  if (ex.yMm > f.yMm + EPS) out.push({ xMm: f.xMm, yMm: f.yMm, wMm: f.wMm, dMm: ex.yMm - f.yMm })
  if (ey1 < fy1 - EPS) out.push({ xMm: f.xMm, yMm: ey1, wMm: f.wMm, dMm: fy1 - ey1 })
  return out
}
 
/** The bed's genuinely usable rectangles at `margin`, printer keep-outs carved
 *  out — what the tower reservation searches. Mirrors the packer's own free-space
 *  seed, which frames-engine keeps private; it comes across with the rest of the
 *  import block when `@eink/plate-packing` publishes. Because it is that mirror,
 *  it must be handed the SAME bed object `packPlates` gets — the `firstLayerBed`,
 *  not the printer's — or the tower search and the pack disagree about where the
 *  free space is. That identity is load-bearing, not hygiene. Overlapping
 *  candidates are left in: they only widen the search, never move its answer. */
function bedFreeRects(bed: BedSize, margin: number): BedRect[] {
  const wMm = bed.wMm - 2 * margin
  const dMm = bed.dMm - 2 * margin
  if (wMm <= EPS || dMm <= EPS) return []
  let free: BedRect[] = [{ xMm: margin, yMm: margin, wMm, dMm }]
  for (const ex of bed.exclude ?? []) free = free.flatMap((f) => splitRect(f, ex))
  return free
}
 
/**
 * The bed as the PACKER and the tower search must see it: usable margin raised
 * to one module's first-layer growth, and every printer keep-out inflated by the
 * same growth on all sides.
 *
 * This is the exit-154 lesson (prod, 2026-08-18). `packPlates` seeds its free
 * space at `bed.marginMm ?? 0`, and this bed never set a margin — so modules
 * packed FLUSH against the bed edge: outline legal, first layer (brim +
 * supports, `moduleGrowthMm` past the outline) extruding off the bed. Orca's
 * multi-extruder post-slice check refuses that plate with exit 154, "Found
 * G-code in unprintable area". The keep-outs are unprintable area too, so they
 * grow by the same reach.
 *
 * PACKING VIEW ONLY: `KitPlateLayout.bed` always reports the printer's real
 * bed. The preview draws `bed.exclude` verbatim, and an inflated zone presented
 * as the printer's own would be a lie. Spreading `...bed` keeps any future
 * `BedSize` field. An inflated zone may reach negative coordinates; that is
 * fine — both `splitRect` and the packer's own carve clip against the free rect.
 */
function firstLayerBed(bed: BedSize, growthMm: number): BedSize {
  return {
    ...bed,
    marginMm: Math.max(bed.marginMm ?? 0, growthMm),
    ...(bed.exclude
      ? {
          exclude: bed.exclude.map((e) => ({
            xMm: e.xMm - growthMm,
            yMm: e.yMm - growthMm,
            wMm: e.wMm + 2 * growthMm,
            dMm: e.dMm + 2 * growthMm,
          })),
        }
      : {}),
  }
}
 
/**
 * Reserve the purge tower, then pack every instance around it onto ONE plate.
 *
 * The tower goes first and the parts pack around the hole it leaves — reversing
 * that order is how a tower ends up on top of a module, which the slicer refuses
 * outright. The reservation is unconditional: like the mono export's pin, it
 * stays available because ticket routing can add a support-interface filament and
 * turn a one-filament plate into a real two-filament slice. Bed area is the
 * cheaper thing to spend.
 *
 * WHICH CORNER THE TOWER TAKES IS PART OF THE SEARCH, NOT AN INPUT TO IT. The
 * tower is placed by hugging a corner of the bed's free space, and for a full
 * plate the corner it picks decides whether the kit fits at all: the reserve is
 * a ~98 × 91 mm hole, and 13 modules pack differently around a hole in the back
 * left than one in the front right. That is not a preference, it is the
 * difference between a plate and a refusal — and worse, between a plate that can
 * be slid clear of the printer's keep-out and one that cannot (see
 * `clearOfKeepOuts`; the slide needs slack in a bed dimension, and the losing
 * arrangement leaves 16.7 mm where it needs 18).
 *
 * So every viable spot is tried, best corner-hugger first, and the first that
 * yields a complete single-plate layout WITH a clear slide wins. Unconstrained
 * plates therefore cost exactly one pack, as before — the loop only spins for
 * kits that are actually tight. `towerPlacementCandidates` returns the list for
 * precisely this reason; taking `[0]` (which is what `placeTowerReserve` is)
 * made the flagship 13-column printed-feet kit's fit depend on how the upstream
 * packer happened to break a scoring tie, and a tie-break moving in
 * `@eink/plate-packing` 1.0.0 is what surfaced it.
 *
 * @throws {KitPlateFitError} when the kit needs more than one plate, when a
 * module exceeds the bed alone, when no rectangle is left for the tower, or when
 * no tower spot leaves a layout that clears the keep-outs. Phase A ships one
 * plate or nothing.
 */
export function packKitPlate(args: {
  instances: readonly KitPlateInstance[]
  bases: Record<ModuleKind, ModuleBasis>
  /** THH's reported geometry for the selected printer; download-only callers get
   *  the fallback plate. */
  bed?: BedSize
  /** Override the derived inter-module gap. Leave unset — the default follows
   *  `supportsAtSlice`, and that coupling is the whole point (see moduleGapMm).
   *  Moves the INTER-MODULE gap only: the bed-edge margin follows
   *  `supportsAtSlice` regardless, because clearing the bed edge is physics,
   *  not a preference. */
  gapMm?: number
  wipeTower?: WipeTowerProfileGeometry
  /** Supports will be on when this plate is sliced (printed feet, or the
   *  operator's ticket style). Widens BOTH the tower's clearance ring and the
   *  gap between modules — supports grow past every outline on the plate, not
   *  just the one the tower happens to sit next to. */
  supportsAtSlice?: boolean
  /** How many filaments this plate will load once the ticket resolves. Picks the
   *  tower's envelope row — a three-filament kit purges into a far shallower band
   *  than the profile's six-filament bound, and on a full bed that is the
   *  difference between one plate and a refusal. */
  filaments?: number
  /** Keep the back `rearBandMm` of the bed clear of EVERYTHING printed — module
   *  outlines plus their first-layer growth, and the purge tower. The two-stage
   *  hand-off homes Z and lays its purge lane in a band along the back edge,
   *  measured on the union of the whole plate (`TWO_STAGE_REAR_BAND_MM`), so a
   *  plate that reaches into it splits fine and then refuses at the printer.
   *  Unset: the full bed is fair game. */
  rearBandMm?: number
}): KitPlateLayout {
  const {
    instances,
    bases,
    bed = BAMBU_256_BED,
    wipeTower = DEFAULT_WIPE_TOWER_PROFILE,
    supportsAtSlice = false,
    filaments,
    rearBandMm,
  } = args
  const gapMm = args.gapMm ?? moduleGapMm(supportsAtSlice)
  const growthMm = moduleGrowthMm(supportsAtSlice)
  const reserve = plateTowerReserve(wipeTower, supportsAtSlice, filaments)
  // `into` is the bed this pack is confined to — the printer's, or the printer's
  // capped for the rear band. It has to reach `packAroundTower` as the bed
  // itself, not just as the first-layer view: the keep-out slide bounds on the
  // bed it is handed, and a slide bounded by the real bed would walk a capped
  // pack straight back into the band. The layout reports the real bed regardless.
  const packInto = (into: BedSize): KitPlateLayout => ({
    ...packAroundTower({
      instances,
      bases,
      bed: into,
      packBed: firstLayerBed(into, growthMm),
      growthMm,
      gapMm,
      reserve,
    }),
    bed,
  })
  if (rearBandMm === undefined) return packInto(bed)
  return packClearOfRearBand({ bed, rearBandMm, growthMm, packInto })
}
 
/** Step of the capped-bed sweep in {@link packClearOfRearBand}. */
const REAR_BAND_SWEEP_MM = 1
 
/**
 * Pack with the back `rearBandMm` of the bed clear of everything printed.
 *
 * Capping the bed at the band line is the obvious move, and it is where the
 * search starts — but the packer is a MaxRects tournament, and its outcome is
 * not monotonic in the bed it is handed: a 13-module kit that refuses a 231 mm
 * deep bed packs on a 234 one with everything under 223 (measured 2026-09-08 on
 * the kit-plate fixtures). So the caps are swept from the band line up to the
 * full bed, a millimetre at a time, and the first layout whose printed extent
 * ({@link printedYMax}) stays in front of the band ships. The sweep is
 * deterministic, so Stage A and Stage B — each packing from the same inputs —
 * land on the same arrangement, which the identity gate between them needs.
 *
 * Refusals: a kit that fits some bed but never clears the band is the
 * `rear-band` diagnosis, naming what crosses the line on the nearest miss; a kit
 * that fits no bed at all gets the full bed's own refusal (overflow, too-big, …).
 */
function packClearOfRearBand(args: {
  bed: BedSize
  rearBandMm: number
  growthMm: number
  packInto: (into: BedSize) => KitPlateLayout
}): KitPlateLayout {
  const { bed, rearBandMm, growthMm, packInto } = args
  const limit = bed.dMm - rearBandMm
  const caps: number[] = []
  for (let cap = limit; cap < bed.dMm - EPS; cap += REAR_BAND_SWEEP_MM) caps.push(cap)
  caps.push(bed.dMm)
 
  let nearest: { layout: KitPlateLayout; over: number } | null = null
  let refusal: KitPlateFitError | null = null
  for (const cap of caps) {
    let layout: KitPlateLayout
    try {
      layout = packInto({ ...bed, dMm: cap })
    } catch (err) {
      if (!(err instanceof KitPlateFitError)) throw err
      refusal = err
      continue
    }
    const over = printedYMax(layout, growthMm) - limit
    if (over <= EPS) return layout
    if (!nearest || over < nearest.over) nearest = { layout, over }
  }
  if (!nearest) throw refusal ?? new Error('packClearOfRearBand: no cap tried')
 
  const crossing = nearest.layout.placements
    .filter((pl) => pl.yMm + pl.hMm + growthMm > limit + EPS)
    .map((pl) => pl.label)
  const { tower } = nearest.layout
  const what = [...crossing, ...(tower.yMm + tower.dMm > limit + EPS ? ['the purge tower'] : [])]
  throw new KitPlateFitError(
    'rear-band',
    crossing,
    `${what.join(', ')} reach${what.length === 1 ? 'es' : ''} into the back ${rearBandMm} mm of this ${bedLabel(
      bed
    )}, which a two-stage print keeps clear for its hand-off (Z home and purge lane) — the kit fits the bed, but not with that band free.`,
    'Print the kit in one job instead, take a column or two out, or select a printer with a deeper bed.'
  )
}
 
/**
 * The back edge of everything the plate will print: module outlines plus their
 * first-layer growth (brim, supports), and the tower's reserve — which already
 * holds the tower's own clearance ring, so it is the outer bound there.
 */
function printedYMax(layout: KitPlateLayout, growthMm: number): number {
  let y = layout.tower.yMm + layout.tower.dMm
  for (const pl of layout.placements) y = Math.max(y, pl.yMm + pl.hMm + growthMm)
  return y
}
 
/**
 * Reserve the tower on `packBed`, then pack the modules around it. `bed` is the
 * bed this pack is confined to — what refusals name and what bounds the keep-out
 * slide; `packBed` is its first-layer view, what the packer and the tower search
 * actually pack into.
 */
function packAroundTower(args: {
  instances: readonly KitPlateInstance[]
  bases: Record<ModuleKind, ModuleBasis>
  bed: BedSize
  packBed: BedSize
  growthMm: number
  gapMm: number
  reserve: ReturnType<typeof plateTowerReserve>
}): KitPlateLayout {
  const { instances, bases, bed, packBed, growthMm, gapMm, reserve } = args
  // The tower searches the SAME first-layer bed the packer packs. Its edge law
  // is untouched by that: `towerMargin` is max(marginMm, TOWER_EDGE 16) and the
  // growth is ≤ 5.45, so 16 still dominates — the tower already keeps a stricter
  // edge than a module needs. Candidate ORDER is unchanged too: `cornerPull`
  // scores off `bed.wMm`/`bed.dMm`, which `firstLayerBed` never touches.
  const towerSpots = towerPlacementCandidates(
    packBed,
    reserve,
    bedFreeRects(packBed, towerMargin(packBed))
  )
  if (towerSpots.length === 0) {
    throw new KitPlateFitError(
      'no-tower-room',
      [],
      `no clear ${Math.round(reserve.wMm)} × ${Math.round(
        reserve.dMm
      )} mm rectangle for the purge tower on this ${bedLabel(
        bed
      )} — refusing to ship a multi-filament plate the slicer would reject.`,
      'Select a printer with a bigger bed, or print the kit module by module from the zip.'
    )
  }
 
  // Report the FIRST spot's refusal when none works. It is the corner the tower
  // would have taken on its own, so the diagnosis a user reads is the one that
  // describes the plate they asked for, not whichever of a dozen fallbacks
  // happened to fail last.
  let firstRefusal: KitPlateFitError | null = null
  for (const tower of towerSpots) {
    try {
      return layoutAroundTower({ instances, bases, bed, packBed, growthMm, gapMm, tower })
    } catch (err) {
      if (!(err instanceof KitPlateFitError)) throw err
      firstRefusal ??= err
    }
  }
  // Unreachable-by-construction guard: the loop either returns or records a
  // refusal, and towerSpots is non-empty above.
  throw firstRefusal ?? new Error('packKitPlate: no tower spot tried')
}
 
/** One tower spot, fully evaluated: pack the modules around it and slide the
 *  result clear of the printer's keep-outs, or refuse. Split out of {@link
 *  packKitPlate} so the spot search reads as a search. */
function layoutAroundTower(args: {
  instances: readonly KitPlateInstance[]
  bases: Record<ModuleKind, ModuleBasis>
  /** The printer's real bed — what refusals name and what the layout reports. */
  bed: BedSize
  /** The `firstLayerBed` — what the packer packs into. */
  packBed: BedSize
  growthMm: number
  gapMm: number
  tower: PlacedTower
}): KitPlateLayout {
  const { instances, bases, bed, packBed, growthMm, gapMm, tower } = args
 
  const items: PackItem[] = instances.map((inst) => ({
    id: inst.id,
    wMm: bases[inst.kind].wMm,
    hMm: bases[inst.kind].hMm,
  }))
  const packed = packPlates(items, packBed, gapMm, tower)
  const byId = new Map(packed.placements.map((pl) => [pl.id, pl]))
  const labelOf = (id: string): string =>
    instances.find((inst) => inst.id === id)?.label ?? `module ${id}`
 
  // Too-big first: it is the more specific diagnosis, and it also shows up as an
  // overflow (each too-big part is parked on a plate of its own).
  if (packed.tooBig.length > 0) {
    const names = packed.tooBig.map(labelOf)
    throw new KitPlateFitError(
      'too-big',
      names,
      `${names.join(', ')} ${
        names.length === 1 ? 'is' : 'are'
      } too big for this ${bedLabel(bed)} even alone — refusing to ship a plate the printer can't print.`,
      'Scale the design down, or select a printer with a bigger bed.'
    )
  }
  if (packed.plateCount > 1) {
    const spilled = packed.placements.filter((pl) => pl.plate > 0).map((pl) => labelOf(pl.id))
    throw new KitPlateFitError(
      'overflow',
      spilled,
      `this kit needs ${packed.plateCount} build plates — ${spilled.join(
        ', '
      )} won't fit beside the rest on this ${bedLabel(bed)}.`,
      'Refusing to ship a plate with modules missing: print fewer columns, scale the design down, or select a printer with a bigger bed. The kit zip still prints module by module.'
    )
  }
 
  const packedPlacements = instances.map((inst): KitPlatePlacement => {
    const pl = byId.get(inst.id)
    if (!pl) {
      // packPlates returns one placement per item id; a hole means the ids the
      // packer saw and the ids we expanded disagree (programming error).
      throw new Error(`the packer returned no placement for ${inst.label}`)
    }
    const basis = bases[inst.kind]
    return {
      ...inst,
      xMm: pl.xMm,
      yMm: pl.yMm,
      rotated: pl.rotated,
      wMm: pl.rotated ? basis.hMm : basis.wMm,
      hMm: pl.rotated ? basis.wMm : basis.hMm,
    }
  })
 
  // The plate's FIRST-LAYER box has to clear the printer's keep-outs and the
  // bed edges, not just its parts' outlines — Orca hulls each volume, our
  // volumes span the plate, and brim/supports grow every module by `growthMm`
  // per side. See clearOfKeepOuts.
  const shift = clearOfKeepOuts(
    bed,
    packedPlacements.map((pl) => ({ xMm: pl.xMm, yMm: pl.yMm, wMm: pl.wMm, dMm: pl.hMm })),
    { xMm: tower.xMm, yMm: tower.yMm, wMm: tower.wMm, dMm: tower.dMm },
    growthMm
  )
  if (!shift) {
    throw new KitPlateFitError(
      'keep-out',
      [],
      `this kit fills the ${bedLabel(
        bed
      )} so completely that it can't be slid clear of the printer's keep-out zone — the slicer would refuse it as an object conflict.`,
      'Print fewer columns, scale the design down, or select a printer with a bigger bed. The kit zip still prints module by module.'
    )
  }
  const placements =
    shift.dxMm === 0 && shift.dyMm === 0
      ? packedPlacements
      : packedPlacements.map((pl) => ({
          ...pl,
          xMm: pl.xMm + shift.dxMm,
          yMm: pl.yMm + shift.dyMm,
        }))
  const placedTower =
    shift.dxMm === 0 && shift.dyMm === 0
      ? tower
      : {
          ...tower,
          xMm: tower.xMm + shift.dxMm,
          yMm: tower.yMm + shift.dyMm,
          pinXMm: tower.pinXMm + shift.dxMm,
          pinYMm: tower.pinYMm + shift.dyMm,
        }
 
  return { placements, tower: placedTower, bed }
}
 
// ---- the plate --------------------------------------------------------------
 
export interface KitPlate extends KitPlateLayout {
  /** The finished single-plate multi-material `.3mf` (zip bytes). */
  readonly bytes: Uint8Array
  /** One entry per emitted body, ascending slot order. */
  readonly bodies: readonly SpoolBodySummary[]
  /** The tower pin written into the plate's project settings. */
  readonly wipeTower: AbacusThreeMf['wipeTower']
}
 
/** Everything decided about a kit plate BEFORE any triangle moves — the shared
 *  head of {@link planKitPlate} and {@link buildKitPlateThreeMf}. */
export interface KitPlatePlan {
  /** Where every module and the tower land. What a bed preview draws. */
  readonly layout: KitPlateLayout
  readonly instances: readonly KitPlateInstance[]
  readonly bases: Record<ModuleKind, ModuleBasis>
  /** This kit's gated soups, memoized per (kind, column). */
  readonly soupsFor: (inst: KitPlateInstance) => ModuleSoups
  /** Some module prints its own feet, so this plate slices with supports on
   *  whatever the ticket style says — it widens the gaps and the tower ring. */
  readonly feetPrinted: boolean
  /** Filaments this plate resolves to, `extraFilaments` included. Picks the
   *  tower's envelope row. */
  readonly filaments: number
}
 
/** The inputs that decide a plate's ARRANGEMENT. `buildKitPlateThreeMf` takes
 *  these plus the emit-only ones (`slotLabels`), so a preview and the submit it
 *  previews are the same call with the same answer. */
export interface KitPlatePlanArgs {
  /** The snapshot-once module renders — the same bundle the kit zip builds from. */
  parts: ModuleExportParts
  filamentMap: FilamentMap
  supportsAtSlice?: boolean
  bed?: BedSize
  wipeTower?: WipeTowerProfileGeometry
  gapMm?: number
  /** Filaments the ticket will add beyond the plate's own bodies — the
   *  support-interface spool. A download adds none. */
  extraFilaments?: number
  /** Keep the back of the bed clear for the two-stage hand-off — see `packKitPlate`. */
  rearBandMm?: number
}
 
/**
 * Gate every module, measure its basis, and pack the plate — everything up to
 * but not including the emit.
 *
 * Split out of {@link buildKitPlateThreeMf} so the bed preview and the submit
 * can't disagree about where things land. The preview is only honest if it is
 * the SAME arrangement that ships, including the refusals: a preview computed
 * from a cheaper model of the geometry would eventually draw a plate that fits
 * while the submit refuses, or worse, draw one that fits while a different one
 * prints. So this returns the real layout off the real renders, and the caller
 * that wants bytes pays only the emit on top.
 *
 * @throws {KitPlateFitError} when the kit doesn't fit one plate — which a
 * preview renders as the refusal it is, rather than hiding until submit.
 */
export function planKitPlate(args: KitPlatePlanArgs): KitPlatePlan {
  const {
    parts,
    filamentMap,
    supportsAtSlice,
    bed = BAMBU_256_BED,
    wipeTower = DEFAULT_WIPE_TOWER_PROFILE,
    gapMm,
    extraFilaments = 0,
  } = args
  const p = parts.params
  if (!isModular(p)) {
    throw new Error('a kit plate is a modular-mode export — this design is seam_mode "mono"')
  }
 
  const bodyOf: Record<ModuleKind, ArrayBuffer> = {
    left: parts.left,
    mid: parts.mid,
    right: parts.right,
  }
  const feetOf: Record<ModuleKind, ArrayBuffer | null> = {
    left: parts.leftFeet,
    mid: parts.midFeet,
    right: parts.rightFeet,
  }
  const textOf: Record<ModuleKind, ModuleExportParts['leftText']> = {
    left: parts.leftText,
    mid: [], // mid modules never carry text — the mid-variant dedupe depends on it
    right: parts.rightText,
  }
 
  const instances = kitPlateInstances(p, filamentMap)
 
  // One gate pass per (kind, column) — every instance of a plan entry shares
  // both, and the geometry is shared across a kind's variants outright (one
  // render per geometry). Placement is what differs, and that comes later.
  const soupCache = new Map<string, ModuleSoups>()
  const soupsFor = (inst: KitPlateInstance): ModuleSoups => {
    const key = `${inst.kind}:${inst.column}`
    const hit = soupCache.get(key)
    if (hit) return hit
    const made = moduleSoups({
      body: bodyOf[inst.kind],
      feet: feetOf[inst.kind],
      text: textOf[inst.kind],
      kind: inst.kind,
      column: inst.column,
      params: p,
      filamentMap,
    })
    soupCache.set(key, made)
    return made
  }
 
  const bases = {} as Record<ModuleKind, ModuleBasis>
  for (const inst of instances) {
    if (!bases[inst.kind]) bases[inst.kind] = moduleBasis(inst.kind, soupsFor(inst))
  }
 
  const feetPrinted = instances.some((inst) => soupsFor(inst).feetPrinted)
 
  // The tower's reservation has to be sized BEFORE the pack, so the filament count
  // has to be known before the bodies are emitted. It is: a body is emitted per slot
  // that carries geometry, and the slots are fixed once every instance is gated —
  // placement moves triangles, it never changes which filament they print on. The
  // emit tail recomputes the same set from the same soups, so the reserved hole and
  // the count reported to the service are the same number by construction.
  const plateSlots = new Set<number>()
  for (const inst of instances) {
    const soups = soupsFor(inst)
    for (const slot of soups.slotOfShell) plateSlots.add(slot)
    for (const soup of soups.partSoups) plateSlots.add(soup.slot)
  }
 
  const filaments = plateSlots.size + extraFilaments
  const layout = packKitPlate({
    instances,
    bases,
    bed,
    gapMm,
    wipeTower,
    supportsAtSlice: supportsAtSlice || feetPrinted,
    filaments,
    rearBandMm: args.rearBandMm,
  })
  return { layout, instances, bases, soupsFor, feetPrinted, filaments }
}
 
/**
 * Build the whole kit as ONE plate: plan it ({@link planKitPlate}), then apply
 * each placement rigidly to every body of its module and emit the lot through
 * the shared bucket/emit tail.
 *
 * Classification happens BEFORE placement, and that ordering is load-bearing:
 * `analyzeModuleShells` reads bead centers at the module's known local x and
 * hard-errors on anything else, so a rotated or translated module would fail its
 * own gate. Placement is the last thing that touches geometry.
 *
 * @throws {KitPlateFitError} when the kit doesn't fit one plate — the caller
 * falls back to the kit zip.
 */
export function buildKitPlateThreeMf(
  args: KitPlatePlanArgs & { slotLabels?: readonly string[] }
): KitPlate {
  const {
    filamentMap,
    slotLabels,
    supportsAtSlice,
    bed = BAMBU_256_BED,
    wipeTower = DEFAULT_WIPE_TOWER_PROFILE,
    extraFilaments = 0,
  } = args
  const { layout, instances, bases, soupsFor, feetPrinted } = planKitPlate(args)
  const placementOf = new Map(layout.placements.map((pl) => [pl.id, pl]))
 
  // Each instance contributes its classified body soup (placed, its shells
  // renumbered into the plate's shell table) plus its already-slotted part
  // soups. The plate's geometry is therefore the same triangles the zip would
  // have shipped, moved as rigid groups — nothing is re-classified after a move.
  const chunks: { positions: Float32Array; triShell: Int32Array; shellBase: number }[] = []
  const slotOfShell: number[] = []
  const partSoups: PartSoup[] = []
  for (const inst of instances) {
    const soups = soupsFor(inst)
    const basis = bases[inst.kind]
    const at = placementOf.get(inst.id)
    if (!at) throw new Error(`no placement for ${inst.label}`)
    const [main] = placeModuleBodies(
      [{ slot: SLOT_PER_SHELL, positions: soups.mesh.positions }],
      basis,
      at
    )
    chunks.push({
      positions: main.positions,
      triShell: soups.triShell,
      shellBase: slotOfShell.length,
    })
    slotOfShell.push(...soups.slotOfShell)
    for (const soup of placeModuleBodies(soups.partSoups, basis, at)) {
      partSoups.push(soup)
    }
  }
 
  const triangleCount = chunks.reduce((n, c) => n + c.triShell.length, 0)
  const positions = new Float32Array(triangleCount * 9)
  const triShell = new Int32Array(triangleCount)
  let triAt = 0
  let posAt = 0
  for (const c of chunks) {
    positions.set(c.positions, posAt)
    posAt += c.positions.length
    for (let t = 0; t < c.triShell.length; t++) triShell[triAt + t] = c.triShell[t] + c.shellBase
    triAt += c.triShell.length
  }
 
  const built = emitThreeMfBodies({
    mesh: { positions, triangleCount },
    triShell,
    slotOfShell,
    partSoups,
    filamentMap,
    slotLabels,
    feetPrinted,
    supportsAtSlice,
    bed,
    wipeTower,
    // The modules are already where they print, around a tower whose rect the
    // packer kept clear — so the container must not re-center them, and must pin
    // the tower where the reservation is rather than beside the plate's box.
    placedOnBed: { towerPinMm: { x: layout.tower.pinXMm, y: layout.tower.pinYMm } },
    extraFilaments,
    // Every module on the plate is the same design, so one resolve covers the
    // lot — the per-role split happens per emitted body, downstream.
    infill: resolveInfill(args.parts.params),
  })
 
  return { ...layout, bytes: built.bytes, bodies: built.bodies, wipeTower: built.wipeTower }
}