All files / web/src/components/create/abacus abacus-3mf-assembly.ts

96.53% Statements 837/867
96.39% Branches 107/111
100% Functions 17/17
96.53% Lines 837/867

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 8681x 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 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 176x 176x 176x 176x 176x 132x 132x 132x 131x 130x 130x 176x 176x 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 27x 27x 27x 27x 27x 27x 1x 1x 1x 10x 10x 10x 10x 10x 10x 10x 10x 10x 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 19408x 19408x 19408x 19408x 19408x 1x 963x 963x 963x 963x 963x 963x 963x 1x 1x 333x 333x 333x 333x 7x 7x 7x 333x 333x 333x 1x 1x 333x 333x 333x 333x 7x 7x 7x 333x 333x 1x 1x 1x 1x 333x 333x 333x 333x 333x 1x 333x 333x 333x 333x 333x 333x 1957x 5871x 5871x 5871x 5871x 5871x 5871x 5871x 5361x 5361x 5361x 5361x 5871x 5871x 1957x 333x 333x 5361x 5361x 333x 333x 1957x 1957x 333x 333x 1x 1x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 26x 1x 1x 1x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 16x 512x 512x 512x 16x 8x 8x 8x 8x 8x 8x 8x 256x 256x 256x 256x 256x 256x 256x 256x 256x 256x 8x 8x 8x 8x 8x 1x 1x 1x 1x 1x 1x 1x 1x 202x 1x 1x 1x 29x 29x 29x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x 101x                                                             1x 1x 1x 1x 1x 1x 117x 117x 117x 117x 117x 1x 1x 117x 1x 1x 1x 1x 115x 115x 115x 115x 115x 115x 115x 117x 333x 333x 5871x 5871x 5871x 5871x 5871x 5871x 333x 115x 115x 115x 115x 115x 115x 115x 117x 117x 117x 117x 117x 117x 117x 117x 117x 117x 117x 14x 101x 117x 117x 117x 117x 117x 117x 117x 117x 117x 117x 333x 333x 333x 333x 117x 117x 117x 117x 117x 117x 117x 26x 89x 117x 26x 89x 117x 117x 117x 117x 8x 8x 8x 8x 8x 8x 8x 8x 117x 117x 117x 117x 8x 117x 117x 117x 117x 117x 117x 26x 89x 117x 117x 117x 117x 117x 117x 117x 117x 117x 117x 117x 117x 117x 117x 117x 333x 117x 117x 117x 26x 26x 26x 26x 26x 26x 89x 117x 117x 117x 8x 117x 117x 117x 117x 117x 117x 117x 117x 117x 117x 117x 117x 117x 117x 117x 117x 117x 117x 117x 117x 117x 117x 117x 117x 117x 117x 117x 117x 117x 117x 117x 117x 117x 117x 117x 117x 26x 26x 26x 26x 26x 26x 26x 89x 117x 117x 117x 117x 117x 117x 117x 117x 117x 117x 117x 117x 117x 117x 117x 117x 117x 117x 117x 117x 117x 117x 117x 117x 117x  
/**
 * Multicolor abacus → ONE print-ready `.3mf` object (Gitea #5).
 *
 * WHY THIS EXISTS. The shared `meshesToThreeMf` emits one *separate* top-level
 * `<object>` per filament — perfect for the receipt path (QR pads nested in cover
 * wells), fatal for the abacus. The abacus prints IN PLACE: the beads are free
 * shells trapped in the frame's channels at 0.25 mm clearance. As four separate
 * objects, OrcaSlicer's auto-arrange pulls them apart (beads scatter out of the
 * frame) and its auto-placed prime tower lands in an invalid spot → the
 * multi-extruder gcode check rejects the plate (`exit 154`, "unprintable area").
 * Reproduced and root-caused on THH's sidecar 2026-07-23.
 *
 * WHAT THIS DOES INSTEAD. It welds the co-registered per-filament bodies into ONE
 * printable object — a `<components>` assembly that references each colored mesh
 * as a `<part>` carrying its `extruder` index — so the whole abacus translates as
 * a unit and the beads stay threaded. Then it OWNS the layout the way THH's slicer
 * won't do safely on its own:
 *   1. the object is centered on the target bed (build-item transform), and
 *   2. the prime tower is pinned (`wipe_tower_x/y` in `project_settings.config`)
 *      into a gap that clears the model.
 * Both survive THH's slice for the same reason: a 3MF that carries
 * `Metadata/project_settings.config` is a *project* file, and Orca skips arrange
 * entirely for one (`need_arrange=0` in the log). So our transform and our pin are
 * honoured as written — verified exit 0 with all filaments used, no THH-side change
 * required. Centering is for bed margin, not for surviving arrange; it's the
 * pre-fix files, which shipped no `Metadata/`, that got arranged and scattered.
 *
 * That last sentence holds only while the gap is wide enough. With supports on the
 * first layer reaches past the model outline while the tower's own brim reaches back
 * toward it; in a 6 mm gap the two extrusions meet and Orca's post-slice conflict
 * check fails the plate (exit 155). That is why `placeWipeTower` takes the options
 * — see SUPPORT_SKIRT.
 *
 * This is the multicolor path only. The single-filament export still rides
 * `meshesToThreeMf` unchanged (one object already slices clean).
 */
 
import type { BedSize } from '@eink/frames-engine/print-bundle'
import { strToU8, zipSync } from 'fflate'
 
/**
 * The zip timestamp every 3MF we emit carries. fflate stamps `Date.now()` into
 * each entry's DOS date when `mtime` is unset, so two builds of the same design
 * differed by wall-clock bytes — and THH admits a chained Stage B print
 * (things-haunt-house#456, abaci #38) only when its model bytes equal Stage A's.
 * abaci rebuilds the 3MF on every submit, so the bytes must be a pure function of
 * the design. 1980-01-01 is the DOS-date epoch (fflate rejects anything earlier —
 * `mtime: 0` throws "invalid date"), built with the local-time constructor because
 * fflate reads it back through local getters: identical bytes in every timezone.
 */
export const THREE_MF_ZIP_MTIME = new Date(1980, 0, 1)
 
/** A co-registered, single-filament body: its triangle soup (9 floats/tri, in the
 *  shared render pose) plus the filament it prints on. */
export interface AssemblyBody {
  /** Triangle soup — every triangle three consecutive (x,y,z) vertices, mm. */
  readonly positions: Float32Array
  /** Display/filament color: `#RGB`, `#RRGGBB`, or `#RRGGBBAA`. */
  readonly colorHex: string
  /** Object/part name shown in the slicer. */
  readonly label: string
  /** 1-based filament index (extruder / AMS slot) this body prints on. */
  readonly extruder: number
  /** Orca process keys this part owns, written beside `extruder` in its
   *  `model_settings.config` entry. Per-part config is the one channel that
   *  reaches a submitted print untouched: THH's `--load-settings` replaces the
   *  project's global settings, not a part's own (the support-transition
   *  modifier rides the same channel). Values in Orca's own serialization —
   *  a percent is `"100%"`. */
  readonly process?: Readonly<Record<string, string>>
}
 
export interface Assembled3mf {
  readonly bytes: Uint8Array
  /** Orca's wipe_tower_x/y pin (mm, bed frame) — surfaced for the THH job contract. */
  readonly wipeTower: { readonly xMm: number; readonly yMm: number }
}
 
export interface WipeTowerEnvelopeMm {
  readonly minX: number
  readonly minY: number
  readonly maxX: number
  readonly maxY: number
}
 
export interface WipeTowerProfileGeometry {
  readonly profile: string
  /** The count-unaware bound — the max-filament row. */
  readonly envelopeMm: WipeTowerEnvelopeMm
  /** The bound per filament count, keyed by the count as a string. See
   *  {@link envelopeForFilaments}: reserving the bound on a two-filament plate
   *  throws away bed depth the tower will never occupy. */
  readonly envelopeByFilamentsMm?: Readonly<Record<string, WipeTowerEnvelopeMm>>
  readonly process: {
    readonly prime_tower_width: number
    readonly prime_tower_brim_width: number
    readonly wipe_tower_wall_type: string
  }
}
 
/** Download/back-compat twin of THH's v1 bounded profile. The print path replaces this
 * with the selected printer's advertised copy, so a deploy can update geometry without
 * an Abaci release.
 *
 * The rows mirror THH's measured table (things-haunt-house#433, re-measured for #457):
 * width is count-independent, depth grows ~10.5 mm per purge in the worst layer, and an
 * N-filament layer can purge N times (Orca may enter a layer on a toolchange), so the
 * N row is the (N+1)-swatch depth. The bound was 56 here until #34 — the pre-#433
 * number, which THH's own source records as escaped by the six-filament fine-layer
 * tower it claimed to hold — and 59 until #457, escaped by a two-filament coupon. */
export const DEFAULT_WIPE_TOWER_PROFILE: WipeTowerProfileGeometry = {
  profile: 'orca-rectangle-60-v1',
  envelopeMm: { minX: -4, minY: -4, maxX: 66, maxY: 69.5 },
  envelopeByFilamentsMm: {
    '2': { minX: -4, minY: -4, maxX: 66, maxY: 27.5 },
    '3': { minX: -4, minY: -4, maxX: 66, maxY: 38 },
    '4': { minX: -4, minY: -4, maxX: 66, maxY: 48.5 },
    '5': { minX: -4, minY: -4, maxX: 66, maxY: 59 },
    '6': { minX: -4, minY: -4, maxX: 66, maxY: 69.5 },
  },
  process: {
    prime_tower_width: 60,
    prime_tower_brim_width: 3,
    wipe_tower_wall_type: 'rectangle',
  },
}
 
/**
 * The envelope to reserve for a plate that loads `filaments` filaments.
 *
 * The service measures the tower per filament count and publishes the table
 * alongside the count-unaware bound. Two filaments purge into a 70×21 band; six
 * need 70×63. Reserving the bound for everything costs 42 mm of bed depth that
 * nothing will ever print on — on a 256 bed that is the difference between a
 * 13-column kit fitting one plate and being refused.
 *
 * The floor of 2 is deliberate and is the same reason the reserve is unconditional
 * at all: a one-filament plate grows a tower the moment ticket routing attaches a
 * support-interface spool, and the plate is packed before that routing resolves. At
 * row-2 cost that hedge is nearly free.
 *
 * Falls back to the bound whenever the table is absent (a pre-#433 service), has no
 * row for this count (more filaments than the profile is bounded for), or the row is
 * malformed — a bad optional field must never shrink a reservation.
 */
export function envelopeForFilaments(
  profile: WipeTowerProfileGeometry,
  filaments?: number
): WipeTowerEnvelopeMm {
  const table = profile.envelopeByFilamentsMm
  if (!table || filaments === undefined || !Number.isFinite(filaments)) return profile.envelopeMm
  const row = table[String(Math.max(2, Math.floor(filaments)))]
  const usable =
    !!row &&
    [row.minX, row.minY, row.maxX, row.maxY].every(Number.isFinite) &&
    row.maxX > row.minX &&
    row.maxY > row.minY
  return usable ? row : profile.envelopeMm
}
 
export interface Assemble3mfOpts {
  /** Bounded profile advertised by the selected THH printer. */
  readonly wipeTower?: WipeTowerProfileGeometry
  /** Bake support-enabling keys into `project_settings.config` (printed TPU feet,
   *  Gitea #23: the whole bottom face prints on plate-grown supports + interface).
   *  Also relaxes the >= 2 bodies guard: a printed-feet export must ride the
   *  assembly path even single-bodied (the no-TPU fallback merges feet into the
   *  frame slot), because a `meshesToThreeMf` file carries no project settings and
   *  a directly-sliced download would print the raised bottom face unsupported. */
  readonly support?: boolean
  /** Supports will be ON when the SUBMITTED print is sliced, for a reason other
   *  than printed feet — the operator's ticket style turned `enable_support` on,
   *  which any design can do. Placement only: nothing is baked into
   *  `project_settings.config` (a downloaded file must not inherit a print-panel
   *  setting), but the tower needs a wider gap, because supports push the first
   *  layer past the model outline and Orca's post-slice conflict check rejects a
   *  plate whose tower extrusion touches the model's — "gcode path conflicts found
   *  between WipeTower and abacus" (exit 155). That is what prod hit on 2026-07-29.
   *  See SUPPORT_SKIRT. `support` implies this. */
  readonly supportsAtSlice?: boolean
  /** The bodies ALREADY sit where they print on THIS bed — the packed module-kit
   *  plate (Gitea #32), whose x/y came out of a packer that ran against this bed's
   *  keep-outs and a purge-tower rect reserved BEFORE packing.
   *
   *  Two things change, and both are the same point: on a packed plate the layout
   *  is a decision, not a pose.
   *   1. No bed-centering. Recentering preserves the arrangement but slides it off
   *      the coordinates the packer cleared, which can walk a module into the
   *      filament-cutter zone it was packed around.
   *   2. The tower pin is taken, not derived. `placeWipeTower` reasons about the
   *      model's bounding BOX, and a plate's box swallows the hole reserved inside
   *      it — so re-deriving would either waste the reservation or fail outright on
   *      a full plate. The reserve is what makes the tower fit; honour it.
   *
   *  Also relaxes the >= 2 bodies guard: a plate is a multi-object layout even when
   *  every object rides one filament, and the `meshesToThreeMf` path would re-origin
   *  it into the bed corner. Z is NOT affected: `tz` still drops the plate to the
   *  bed exactly as it drops a mono abacus, which is what keeps the support-transition
   *  modifier on the frame's z=0 plane rather than under the feet. */
  readonly placedOnBed?: {
    /** Orca's `wipe_tower_x/y`, from the reservation the packer left room for. */
    readonly towerPinMm: { readonly x: number; readonly y: number }
  }
  /** How many filaments this plate will load once the ticket resolves — the
   *  emitted bodies plus anything routing adds (a support-interface spool). Picks
   *  the profile's envelope row; see {@link envelopeForFilaments}. Omitted, the
   *  count-unaware bound is reserved, which is safe and wasteful. */
  readonly filaments?: number
  /** The feet-only two-stage variant (Gitea #45): one `support_blocker` volume per
   *  foot, keeping Stage B's PLA support out of the nozzle's way around feet that
   *  are already standing. See {@link supportBlockerRadius}. Never printed, never
   *  moves the footprint. */
  readonly supportBlockers?: readonly SupportBlocker[]
}
 
/** The printed-feet frame starts at source Z=0, above feet that dip below zero.
 *  This modifier follows that frame/support boundary through Orca's slice: the
 *  contact layer plus the next two 0.20 mm layers print slowly enough to settle
 *  onto a low-adhesion support interface before normal process speeds resume. */
export const SUPPORT_TRANSITION_HEIGHT_MM = 0.6
export const SUPPORT_TRANSITION_SPEED_MM_S = 25
export const SUPPORT_TRANSITION_NAME = 'abaci-support-transition-v1'
 
/**
 * Per-foot support blockers for the feet-only two-stage variant (Gitea #45).
 *
 * Stage B prints its support body from layer 1 around feet that are already
 * standing `heightMm` tall on the plate, so every support line has to clear the
 * NOZZLE, not just the foot: a flat tip Ø3 with 45° sides out to Ø12 (the X1C
 * hotend, user-measured 2026-09-09). With the tip at layer top z the foot top is
 * `heightMm − z` above it, so the void about each foot centre is
 *
 *     R(z) = r_foot + slop + min(tip_r + (H − z), cone_r)
 *
 * 7.9 mm at layer 1 → 6.5 mm at the seam for the stock 1.6 mm feet. Graduated on
 * purpose: a flat `support_object_xy_distance` would need the layer-1 value all
 * the way up and double the unsupported ring at the seam. One volume per foot,
 * revolved from that profile (a frustum while H is under the cone's height, a
 * cylinder under a frustum beyond it), source z −H..0 = bed z 0..H. Orca sizes an
 * object by its model parts, so blockers never move the footprint or the tower.
 * `raft_first_layer_expansion` must be 0 alongside: Orca grows support layer 0
 * AFTER blockers apply.
 */
export const NOZZLE_TIP_RADIUS_MM = 1.5
export const NOZZLE_CONE_RADIUS_MM = 6
/** Half a 0.42 mm support line, plus XY slop. */
export const SUPPORT_BLOCKER_SLOP_MM = 0.5
export const SUPPORT_BLOCKER_NAME = 'abaci-support-blocker-v1'
const SUPPORT_BLOCKER_SEGMENTS = 32
 
export interface SupportBlocker {
  /** Foot centre, source XY — the frame the bodies are in. */
  readonly cx: number
  readonly cy: number
  /** The feet stand-off = the seam height (`feet_proud`). */
  readonly heightMm: number
  /** The stand-off's radius about its centre (a square foot's circumradius). */
  readonly rFootMm: number
}
 
/** The support void's radius about a foot centre with the nozzle tip at layer top `zMm` (bed z). */
export function supportBlockerRadius(zMm: number, heightMm: number, rFootMm: number): number {
  return (
    rFootMm +
    SUPPORT_BLOCKER_SLOP_MM +
    Math.min(NOZZLE_TIP_RADIUS_MM + (heightMm - zMm), NOZZLE_CONE_RADIUS_MM)
  )
}
 
/** The revolved profile, bottom to top in bed z: the knots where R(z) bends. */
export function supportBlockerProfile(
  heightMm: number,
  rFootMm: number
): readonly { readonly z: number; readonly r: number }[] {
  // Above this height the cone's full radius is already reached at the plate, so
  // R is flat from the plate up to the knee and slopes only above it.
  const knee = heightMm - (NOZZLE_CONE_RADIUS_MM - NOZZLE_TIP_RADIUS_MM)
  const zs = knee > 0 && knee < heightMm ? [0, knee, heightMm] : [0, heightMm]
  return zs.map((z) => ({ z, r: supportBlockerRadius(z, heightMm, rFootMm) }))
}
 
/**
 * The printed feet's own process keys: the feet print solid, always.
 *
 * Two reasons, both the feet's alone. A soft TPU foot over sparse infill squashes
 * unevenly under the frame, so the feet were settled as 100 % from the start. And
 * in a two-stage print the feet ARE Stage A: their top layers are the seam, with
 * no top shell of their own (the foot's top is interior to the feet+frame union),
 * so Stage B's first layer lands on whatever infill the feet have. Solid feet make
 * that a solid bed. Carried per part rather than as a plate-wide style key so the
 * operator's infill density stays on the frame and beads — the style-wide 100 %
 * the two-stage mode shipped at first printed every bead solid.
 *
 * Only a body that is the feet and nothing else gets these (see
 * `emitThreeMfBodies`): merged into another role's body, the keys would take the
 * frame with them.
 */
export const FEET_PART_PROCESS: Readonly<Record<string, string>> = {
  sparse_infill_density: '100%',
  sparse_infill_pattern: 'rectilinear',
}
 
/** Download fallback. Print submission uses the selected printer's live bed geometry. */
export const BAMBU_256_BED: BedSize = {
  wMm: 256,
  dMm: 256,
  exclude: [{ xMm: 0, yMm: 0, wMm: 18, dMm: 28 }],
}
 
/** Clear space kept between the tower and the model. Exported because the packed
 *  kit plate (abacus-kit-plate.ts) reserves its tower rect BEFORE packing rather
 *  than placing it beside a finished model — same clearance law, applied at the
 *  other end of the pipeline, so the number must not be restated there. */
export const TOWER_GAP = 6
 
// Extra reserve once supports are on: the first layer stops being the model's own
// footprint. Support material grows outward from the overhangs it holds, so extrusion
// reaches past the model outline. Measured 2026-07-29 from THH's supports-on slice
// (the `Support` feature's extents vs the declared object box): 1.47 mm on the sides,
// 5.32 mm at the front, 1.32 mm at the back. SUPPORT_SKIRT reserves the worst case
// with headroom.
//
// WHY 1.5 mm MATTERS MORE THAN IT LOOKS — the exit-155 chain. The gap has to hold two
// growths, not one: the model's, and the tower's own brim (`prime_tower_brim_width` 3,
// which extends back toward the model). TOWER_GAP alone is 6 mm, so with supports on
// prod was left ~1.5 mm of real clearance, and a model brim (`brim_width` 5, auto_brim)
// closes even that. Orca's post-slice `gcode path conflicts check` then fails the
// plate: "gcode path conflicts found between WipeTower and abacus" (exit 155).
// Widening the gap is what Orca's own error text prescribes — "try moving the wipe
// tower further from other models".
//
// NOT A ROTATION. An earlier version of this comment claimed supports make Orca
// re-arrange and rotate the plate; that was a measurement error on our side (the
// `Outer wall` extents include the prime tower, so moving the pin changed the bounding
// box and looked like a transposed model). Two direct observations from our own slice
// disprove it: the log says `before arrange, need_arrange=0` — arrange never runs on a
// 3MF project — and the model printed at its declared min corner (declared 90.25,77.75
// vs printed 90.46,75.96), in its declared orientation. Distance is the only lever.
//
// Exported alongside TOWER_GAP for the packed kit plate's pre-pack reserve.
export const SUPPORT_SKIRT = 8
// How far a cornered tower keeps off the bed edge. The reserve above covers the
// tower's own extrusion, but a tower pushed flush into the corner still trips the
// multi-extruder printable-area check (exit 154, "gcode in unprintable area") —
// measured on the sidecar. 16 mm back from both edges slices clean.
const TOWER_EDGE = 16
 
const ASSEMBLY_ID = 1000 // printable object id; child meshes take 2..N+1
const SUPPORT_TRANSITION_ID = ASSEMBLY_ID + 1
/** Per-foot support blockers take 1002, 1003, … in `FEET_POS` order. */
export const SUPPORT_BLOCKER_ID_BASE = ASSEMBLY_ID + 2
 
// A parameter modifier has to cap every feature Orca may assign to the frame's
// first few layers. In particular, the contact layer is Bridge/Overhang wall,
// while the very next layer becomes ordinary walls + internal solid infill.
const SUPPORT_TRANSITION_SPEED_KEYS = [
  'outer_wall_speed',
  'inner_wall_speed',
  'sparse_infill_speed',
  'internal_solid_infill_speed',
  'top_surface_speed',
  'gap_infill_speed',
  'bridge_speed',
  'overhang_1_4_speed',
  'overhang_2_4_speed',
  'overhang_3_4_speed',
  'overhang_4_4_speed',
  'overhang_totally_speed',
] as const
 
/** Compact mm formatter: fixed 3-decimals (µm), trailing zeros trimmed. */
function fmt(v: number): string {
  let s = v.toFixed(3)
  if (s.includes('.')) s = s.replace(/0+$/, '').replace(/\.$/, '')
  return s === '-0' ? '0' : s
}
 
function escapeXml(s: string): string {
  return s
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
}
 
/** Normalize any accepted hex to a full 8-digit `#RRGGBBAA` (3MF displaycolor). */
function displayColor(hex: string): string {
  let h = hex.replace('#', '')
  if (h.length === 3)
    h = h
      .split('')
      .map((c) => c + c)
      .join('')
  if (h.length === 6) h += 'FF'
  return `#${h.toUpperCase()}`
}
 
/** Normalize to `#RRGGBB` for the slicer's `filament_colour` list. */
function filamentColor(hex: string): string {
  let h = hex.replace('#', '')
  if (h.length === 3)
    h = h
      .split('')
      .map((c) => c + c)
      .join('')
  return `#${h.slice(0, 6).toUpperCase()}`
}
 
/** Weld a body's triangle soup to indexed geometry (µm quantization) and emit the
 *  3MF `<vertices>`/`<triangles>` XML. Welding is per-body — bodies never merge. */
/** A part's own process keys, as Orca writes them beside `extruder`. */
function partProcessXml(b: AssemblyBody): string {
  return Object.entries(b.process ?? {})
    .map(([key, value]) => `<metadata key="${key}" value="${escapeXml(value)}"/>`)
    .join('')
}
 
function emitBodyMesh(positions: Float32Array): { verts: string; tris: string } {
  const index = new Map<string, number>()
  const coords: number[] = []
  const tri: number[] = []
  const n = (positions.length / 9) | 0
  for (let t = 0; t < n; t++) {
    for (let k = 0; k < 3; k++) {
      const o = t * 9 + k * 3
      const x = positions[o]
      const y = positions[o + 1]
      const z = positions[o + 2]
      const key = `${Math.round(x * 1000)},${Math.round(y * 1000)},${Math.round(z * 1000)}`
      let id = index.get(key)
      if (id === undefined) {
        id = coords.length / 3
        index.set(key, id)
        coords.push(x, y, z)
      }
      tri.push(id)
    }
  }
  let verts = ''
  for (let i = 0; i < coords.length; i += 3) {
    verts += `<vertex x="${fmt(coords[i])}" y="${fmt(coords[i + 1])}" z="${fmt(coords[i + 2])}"/>`
  }
  let tris = ''
  for (let i = 0; i < tri.length; i += 3) {
    tris += `<triangle v1="${tri[i]}" v2="${tri[i + 1]}" v3="${tri[i + 2]}"/>`
  }
  return { verts, tris }
}
 
/** Closed box used as an Orca parameter-modifier volume (it is never printed). */
function emitModifierBox(
  x0: number,
  y0: number,
  x1: number,
  y1: number,
  z0: number,
  z1: number
): { verts: string; tris: string } {
  const vertices = [
    [x0, y0, z0],
    [x1, y0, z0],
    [x1, y1, z0],
    [x0, y1, z0],
    [x0, y0, z1],
    [x1, y0, z1],
    [x1, y1, z1],
    [x0, y1, z1],
  ] as const
  const faces = [
    [0, 2, 1],
    [0, 3, 2],
    [4, 5, 6],
    [4, 6, 7],
    [0, 1, 5],
    [0, 5, 4],
    [1, 2, 6],
    [1, 6, 5],
    [2, 3, 7],
    [2, 7, 6],
    [3, 0, 4],
    [3, 4, 7],
  ] as const
  return {
    verts: vertices
      .map(([x, y, z]) => `<vertex x="${fmt(x)}" y="${fmt(y)}" z="${fmt(z)}"/>`)
      .join(''),
    tris: faces.map(([a, b, c]) => `<triangle v1="${a}" v2="${b}" v3="${c}"/>`).join(''),
  }
}
 
/** A solid of revolution about (cx, cy) from a bottom-to-top profile of (z, r)
 *  rings — a blocker volume, never printed. Wound outward. */
function emitRevolvedSolid(
  cx: number,
  cy: number,
  zOffset: number,
  profile: readonly { readonly z: number; readonly r: number }[],
  segments: number
): { verts: string; tris: string } {
  const n = segments
  const pts: [number, number, number][] = []
  for (const ring of profile) {
    for (let i = 0; i < n; i++) {
      const a = (2 * Math.PI * i) / n
      pts.push([cx + ring.r * Math.cos(a), cy + ring.r * Math.sin(a), zOffset + ring.z])
    }
  }
  const bottomCentre = pts.length
  pts.push([cx, cy, zOffset + profile[0].z])
  const topCentre = pts.length
  pts.push([cx, cy, zOffset + profile[profile.length - 1].z])
  const top = (profile.length - 1) * n
  const faces: [number, number, number][] = []
  for (let i = 0; i < n; i++) {
    const j = (i + 1) % n
    faces.push([bottomCentre, j, i])
    faces.push([topCentre, top + i, top + j])
    for (let k = 0; k + 1 < profile.length; k++) {
      const lo = k * n
      const hi = (k + 1) * n
      faces.push([lo + i, lo + j, hi + j])
      faces.push([lo + i, hi + j, hi + i])
    }
  }
  return {
    verts: pts.map(([x, y, z]) => `<vertex x="${fmt(x)}" y="${fmt(y)}" z="${fmt(z)}"/>`).join(''),
    tris: faces.map(([a, b, c]) => `<triangle v1="${a}" v2="${b}" v3="${c}"/>`).join(''),
  }
}
 
interface Box {
  x0: number
  y0: number
  x1: number
  y1: number
}
const intersects = (a: Box, b: Box): boolean =>
  a.x0 < b.x1 && a.x1 > b.x0 && a.y0 < b.y1 && a.y1 > b.y0
const clamp = (v: number, lo: number, hi: number): number => Math.max(lo, Math.min(hi, v))
 
/** Grow a box outwards by `d` on every side. */
function inflate(b: Box, d: number): Box {
  return { x0: b.x0 - d, y0: b.y0 - d, x1: b.x1 + d, y1: b.y1 + d }
}
 
/**
 * Pin the prime tower into a gap that stays clear of the model.
 *
 * The tower goes beside the model: the sides are tried in descending free-room
 * order (right / front / back / left) and the first that fits on the bed and clears
 * every keep-out zone wins. With supports on, the keep-out is the model grown by
 * SUPPORT_SKIRT, so the gap widens far enough that the tower's brim can never reach
 * the model's support extrusion (see SUPPORT_SKIRT).
 *
 * The rectangle being packed is THH's pin-relative reservation — not
 * `prime_tower_width`, and not a guessed min corner. That distinction is the whole
 * contract: Orca's real bbox extends on both sides of its x/y pin and changes with
 * purge inputs. THH verifies the output remains within this reservation.
 */
function placeWipeTower(
  bed: BedSize,
  obj: Box,
  opts: Assemble3mfOpts = {}
): { xMm: number; yMm: number } {
  const margin = bed.marginMm ?? 0
  const excludes: Box[] = (bed.exclude ?? []).map((e) => ({
    x0: e.xMm,
    y0: e.yMm,
    x1: e.xMm + e.wMm,
    y1: e.yMm + e.dMm,
  }))
  const cx = (obj.x0 + obj.x1) / 2
  const cy = (obj.y0 + obj.y1) / 2
  const profile = opts.wipeTower ?? DEFAULT_WIPE_TOWER_PROFILE
  const envelope = envelopeForFilaments(profile, opts.filaments)
  const pinMidX = (envelope.minX + envelope.maxX) / 2
  const pinMidY = (envelope.minY + envelope.maxY) / 2
 
  // What the tower must stay off. Supports push the first layer outward, so the
  // keep-out is the model plus SUPPORT_SKIRT — and the side candidates below hug
  // THAT box, which is the whole fix: supports simply widen the gap, so the tower's
  // brim and the model's support extrusion can never meet.
  const supportSkirt = opts.support === true || opts.supportsAtSlice === true ? SUPPORT_SKIRT : 0
  const grown = supportSkirt > 0 ? inflate(obj, supportSkirt) : obj
  const keepOut: Box[] = [grown]
 
  // Candidate PINS on each side, ordered by free room. Convert the desired reservation
  // edge back through its pin-relative offset; wipe_tower_x/y is not the bbox min corner.
  const sides = [
    {
      room: bed.wMm - margin - grown.x1,
      x: grown.x1 + TOWER_GAP - envelope.minX,
      y: cy - pinMidY,
    }, // right
    {
      room: grown.y0 - margin,
      x: cx - pinMidX,
      y: grown.y0 - TOWER_GAP - envelope.maxY,
    }, // front
    {
      room: bed.dMm - margin - grown.y1,
      x: cx - pinMidX,
      y: grown.y1 + TOWER_GAP - envelope.minY,
    }, // back
    {
      room: grown.x0 - margin,
      x: grown.x0 - TOWER_GAP - envelope.maxX,
      y: cy - pinMidY,
    }, // left
  ].sort((a, b) => b.room - a.room)
 
  const fits = (pinX: number, pinY: number): Box | null => {
    const rect: Box = {
      x0: pinX + envelope.minX,
      y0: pinY + envelope.minY,
      x1: pinX + envelope.maxX,
      y1: pinY + envelope.maxY,
    }
    const onBed =
      rect.x0 >= margin &&
      rect.y0 >= margin &&
      rect.x1 <= bed.wMm - margin &&
      rect.y1 <= bed.dMm - margin
    const clear = ![...keepOut, ...excludes].some((k) => intersects(rect, k))
    return onBed && clear ? rect : null
  }
 
  for (const s of sides) {
    const x = clamp(s.x, margin - envelope.minX, bed.wMm - margin - envelope.maxX)
    const y = clamp(s.y, margin - envelope.minY, bed.dMm - margin - envelope.maxY)
    if (fits(x, y)) return { xMm: x, yMm: y }
  }

  // No side works: push the reservation into each bed corner and keep whichever sits
  // furthest from hot extrusion. This is a last resort for unusually full plates.
  const nearX = margin + TOWER_EDGE - envelope.minX
  const farX = bed.wMm - margin - TOWER_EDGE - envelope.maxX
  const nearY = margin + TOWER_EDGE - envelope.minY
  const farY = bed.dMm - margin - TOWER_EDGE - envelope.maxY
  const corners = [
    { x: farX, y: farY },
    { x: nearX, y: farY },
    { x: farX, y: nearY },
    { x: nearX, y: nearY },
  ]
  let best: { xMm: number; yMm: number; room: number } | null = null
  for (const c of corners) {
    const rect = fits(c.x, c.y)
    if (!rect) continue
    const room = Math.min(
      ...[...keepOut, ...excludes].map((k) =>
        Math.max(k.x0 - rect.x1, rect.x0 - k.x1, k.y0 - rect.y1, rect.y0 - k.y1)
      )
    )
    if (!best || room > best.room) best = { xMm: c.x, yMm: c.y, room }
  }
  if (best) return { xMm: best.xMm, yMm: best.yMm }

  throw new Error(
    'abacus is too large to fit a prime tower on the print bed — reduce the size or filament count'
  )
}
 
/**
 * Assemble co-registered per-filament bodies into one bed-centered printable
 * object with an owned prime tower. Multicolor only (`bodies.length >= 2`).
 */
export function assembleAbacus3mf(
  bodies: readonly AssemblyBody[],
  bed: BedSize,
  opts: Assemble3mfOpts = {}
): Assembled3mf {
  if (bodies.length === 0) {
    throw new Error('assembleAbacus3mf needs at least one filament body')
  }
  if (bodies.length < 2 && !opts.support && !opts.placedOnBed) {
    throw new Error(
      'assembleAbacus3mf needs >= 2 filament bodies (single color rides meshesToThreeMf)'
    )
  }
 
  // Merged bounding box across every body (shared render pose).
  let minX = Infinity
  let minY = Infinity
  let minZ = Infinity
  let maxX = -Infinity
  let maxY = -Infinity
  for (const b of bodies) {
    const p = b.positions
    for (let i = 0; i < p.length; i += 3) {
      if (p[i] < minX) minX = p[i]
      if (p[i] > maxX) maxX = p[i]
      if (p[i + 1] < minY) minY = p[i + 1]
      if (p[i + 1] > maxY) maxY = p[i + 1]
      if (p[i + 2] < minZ) minZ = p[i + 2]
    }
  }
 
  // Center the footprint on the bed and drop it to z=0 — unless the bodies were
  // PLACED on this bed already (see Assemble3mfOpts.placedOnBed), in which case
  // their x/y are the layout and only the z-drop applies.
  const bedCx = bed.wMm / 2
  const bedCy = bed.dMm / 2
  const placed = opts.placedOnBed
  const tx = placed ? 0 : bedCx - (minX + maxX) / 2
  const ty = placed ? 0 : bedCy - (minY + maxY) / 2
  const tz = -minZ
 
  const objBox: Box = {
    x0: bedCx - (maxX - minX) / 2,
    y0: bedCy - (maxY - minY) / 2,
    x1: bedCx + (maxX - minX) / 2,
    y1: bedCy + (maxY - minY) / 2,
  }
  const tower = placed
    ? { xMm: placed.towerPinMm.x, yMm: placed.towerPinMm.y }
    : placeWipeTower(bed, objBox, opts)
 
  // ---- 3D/3dmodel.model ----
  const childIds = bodies.map((_, i) => i + 2)
  const baseEntries = bodies
    .map(
      (b, i) => `<base name="${escapeXml(b.label)}" displaycolor="${displayColor(b.colorHex)}"/>`
    )
    .join('')
  const objectsXml = bodies
    .map((b, i) => {
      const { verts, tris } = emitBodyMesh(b.positions)
      // object-level pid/pindex paints the whole body for core-3MF slicers
      // (PrusaSlicer/Cura); Bambu/Orca color comes from the part `extruder` below.
      return `<object id="${childIds[i]}" type="model" pid="1" pindex="${i}"><mesh><vertices>${verts}</vertices><triangles>${tris}</triangles></mesh></object>`
    })
    .join('')
  // A generic core 3MF cannot carry Orca's height-range file reliably: Orca
  // ignores that file unless the package is a fully native Bambu project. A
  // modifier part in model_settings.config works on both generic and native
  // projects, moves with this one assembly, and survives THH's --arrange 0 path.
  const transitionMesh = opts.support
    ? emitModifierBox(minX, minY, maxX, maxY, 0, SUPPORT_TRANSITION_HEIGHT_MM)
    : null
  const transitionObjectXml = transitionMesh
    ? `<object id="${SUPPORT_TRANSITION_ID}" type="model"><mesh><vertices>${transitionMesh.verts}</vertices><triangles>${transitionMesh.tris}</triangles></mesh></object>`
    : ''
  // The feet-only variant's blockers (Gitea #45): one revolved volume per foot in
  // source space, so they ride the same build transform as the bodies — source
  // −H..0 lands on bed 0..H exactly where the foot stand-off does.
  const blockers = (opts.supportBlockers ?? []).map((b, k) => ({
    id: SUPPORT_BLOCKER_ID_BASE + k,
    mesh: emitRevolvedSolid(
      b.cx,
      b.cy,
      -b.heightMm,
      supportBlockerProfile(b.heightMm, b.rFootMm),
      SUPPORT_BLOCKER_SEGMENTS
    ),
  }))
  const blockerObjectsXml = blockers
    .map(
      ({ id, mesh }) =>
        `<object id="${id}" type="model"><mesh><vertices>${mesh.verts}</vertices><triangles>${mesh.tris}</triangles></mesh></object>`
    )
    .join('')
  const componentsXml = childIds
    .map((id) => `<component objectid="${id}" transform="1 0 0 0 1 0 0 0 1 0 0 0"/>`)
    .concat(
      transitionMesh
        ? [`<component objectid="${SUPPORT_TRANSITION_ID}" transform="1 0 0 0 1 0 0 0 1 0 0 0"/>`]
        : [],
      blockers.map(({ id }) => `<component objectid="${id}" transform="1 0 0 0 1 0 0 0 1 0 0 0"/>`)
    )
    .join('')
  const assemblyXml = `<object id="${ASSEMBLY_ID}" type="model"><components>${componentsXml}</components></object>`
  const model =
    `<?xml version="1.0" encoding="UTF-8"?>\n` +
    `<model unit="millimeter" xml:lang="en-US" xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02">` +
    `<resources><basematerials id="1">${baseEntries}</basematerials>${objectsXml}${transitionObjectXml}${blockerObjectsXml}${assemblyXml}</resources>` +
    `<build><item objectid="${ASSEMBLY_ID}" transform="1 0 0 0 1 0 0 0 1 ${fmt(tx)} ${fmt(ty)} ${fmt(tz)}"/></build>` +
    `</model>`
 
  // ---- Metadata/model_settings.config (Bambu/Orca per-part extruder) ----
  const partsXml = bodies
    .map(
      (b, i) =>
        `<part id="${childIds[i]}" subtype="normal_part"><metadata key="name" value="${escapeXml(b.label)}"/><metadata key="extruder" value="${b.extruder}"/>${partProcessXml(b)}</part>`
    )
    .join('')
  const transitionPartXml = transitionMesh
    ? `<part id="${SUPPORT_TRANSITION_ID}" subtype="modifier_part">` +
      `<metadata key="name" value="${SUPPORT_TRANSITION_NAME}"/>` +
      SUPPORT_TRANSITION_SPEED_KEYS.map(
        (key) => `<metadata key="${key}" value="${fmt(SUPPORT_TRANSITION_SPEED_MM_S)}"/>`
      ).join('') +
      `</part>`
    : ''
  const blockerPartsXml = blockers
    .map(
      ({ id }, k) =>
        `<part id="${id}" subtype="support_blocker"><metadata key="name" value="${SUPPORT_BLOCKER_NAME}-foot-${k + 1}"/></part>`
    )
    .join('')
  const modelSettings =
    `<?xml version="1.0" encoding="UTF-8"?>\n<config>` +
    `<object id="${ASSEMBLY_ID}"><metadata key="name" value="abacus"/>${partsXml}${transitionPartXml}${blockerPartsXml}</object>` +
    `</config>`
 
  // ---- Metadata/project_settings.config (owned tower + filament pin) ----
  // `filament_map` pins every filament to AMS group 1 (single-extruder X1C/A1) —
  // defeats Orca's filament-grouping rejection (exit 156). THH re-sets these on its
  // side; we include them so a directly-sliced download is correct too.
  //
  // The support block (printed feet, Gitea #23) is the same belt: THH re-derives
  // support policy from the ticket style, but a direct download must slice with
  // the bottom face supported on its own. `support_on_build_plate_only` is load-
  // bearing — nothing may grow inside the bead channels or on the beads; the only
  // support this print wants rises plate→bottom-face. `support_interface_filament`
  // is deliberately absent: the interface spool isn't a 3MF body, THH owns that
  // key (computed from ticket filament order).
  const projectSettings = JSON.stringify({
    filament_colour: bodies.map((b) => filamentColor(b.colorHex)),
    filament_map_mode: 'Manual',
    filament_map: bodies.map(() => '1'),
    wipe_tower_x: fmt(tower.xMm),
    wipe_tower_y: fmt(tower.yMm),
    // Direct-download slices use the same bounded shape as API prints. THH owns and
    // re-applies these on submit; embedding them here keeps the standalone 3MF honest.
    prime_tower_width: fmt(
      (opts.wipeTower ?? DEFAULT_WIPE_TOWER_PROFILE).process.prime_tower_width
    ),
    prime_tower_brim_width: fmt(
      (opts.wipeTower ?? DEFAULT_WIPE_TOWER_PROFILE).process.prime_tower_brim_width
    ),
    wipe_tower_wall_type: (opts.wipeTower ?? DEFAULT_WIPE_TOWER_PROFILE).process
      .wipe_tower_wall_type,
    ...(opts.support
      ? {
          enable_support: '1',
          support_type: 'normal(auto)',
          support_on_build_plate_only: '1',
          support_interface_top_layers: '2',
          support_top_z_distance: '0.2',
        }
      : {}),
  })
 
  const contentTypes =
    `<?xml version="1.0" encoding="UTF-8"?>\n` +
    `<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">` +
    `<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>` +
    `<Default Extension="model" ContentType="application/vnd.ms-package.3dmanufacturing-3dmodel+xml"/>` +
    `</Types>`
  const rels =
    `<?xml version="1.0" encoding="UTF-8"?>\n` +
    `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
    `<Relationship Target="/3D/3dmodel.model" Id="rel-1" Type="http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel"/>` +
    `</Relationships>`
 
  const entries = {
    '[Content_Types].xml': strToU8(contentTypes),
    '_rels/.rels': strToU8(rels),
    '3D/3dmodel.model': strToU8(model),
    'Metadata/model_settings.config': strToU8(modelSettings),
    'Metadata/project_settings.config': strToU8(projectSettings),
  }
  const bytes = zipSync(entries, { mtime: THREE_MF_ZIP_MTIME })
 
  return { bytes, wipeTower: tower }
}