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 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 1434 1435 1436 1437 1438 1439 1440 1441 1442 1443 1444 1445 1446 1447 1448 1449 1450 1451 1452 1453 1454 1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 1471 1472 1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487 1488 1489 1490 1491 1492 1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 1504 1505 1506 1507 1508 1509 1510 1511 1512 1513 1514 1515 1516 1517 1518 1519 1520 1521 1522 1523 1524 1525 1526 1527 1528 1529 1530 1531 1532 1533 1534 1535 1536 1537 1538 1539 1540 1541 1542 1543 1544 1545 1546 1547 1548 1549 1550 1551 1552 1553 1554 1555 1556 1557 1558 1559 1560 1561 1562 1563 1564 1565 1566 1567 1568 1569 1570 1571 1572 1573 1574 1575 1576 1577 1578 1579 1580 1581 1582 1583 1584 1585 1586 1587 1588 1589 1590 1591 1592 1593 1594 1595 1596 1597 1598 1599 1600 1601 1602 1603 1604 1605 1606 1607 1608 1609 1610 1611 1612 1613 1614 1615 1616 1617 1618 1619 1620 1621 1622 1623 1624 1625 1626 1627 1628 1629 1630 1631 1632 1633 1634 1635 1636 1637 1638 1639 1640 1641 1642 1643 1644 1645 1646 1647 1648 1649 1650 1651 1652 1653 1654 1655 1656 1657 1658 1659 1660 1661 1662 1663 1664 1665 1666 1667 1668 1669 1670 1671 1672 1673 1674 1675 1676 1677 1678 1679 1680 1681 1682 1683 1684 1685 1686 1687 1688 1689 1690 1691 | 'use client' // Abacus Studio — print-service panel (Phase 2b, Gitea #9). // // The right-side companion to the studio's control panel: submit the current // design to the paired THH print service as a multi-material 3MF + v2 ticket, // tune slicer settings through `@eink/print-dialog`'s schema-driven editor, // and watch the job move. All reads/writes go through abaci's own proxy // (#8.3) — the package client only ever sees the injected transport. // // Invariants this component holds: // • The settings editor is CONTROLLED — the TicketStyle here is the single // source of truth — and once opened it stays mounted (visibility toggles // via CSS), so solver re-runs and doorbell invalidations flow in as data, // never as a remount. // • Submission is v2-discipline: the ticket carries the editor's style // verbatim; per-key rejects render through `parseInvalidTicket` and the // service's `applied` clamp echoes feed straight back into the editor. // • Job state moves on events, not a timer: doorbell rings invalidate the // job queries, and `usePrintJobRing`'s reconnect reconcile repairs // anything missed during a disconnect. Progress % between phase rings is // deliberately coarse until THH ships throttled progress rings. import type { FilamentPlanResponseV1, ParamScalarValue, SupportRecommendation, SupportRosterEntry, TicketStartPolicy, TicketStyle, } from '@eink/print-dialog' import { PrintSettingsEditor, SupportRoleEditor } from '@eink/print-dialog/ui' import '@eink/print-dialog/ui/style.css' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react' import { Disclosure } from '@/components/studio/Disclosure' import { StudioNotice } from '@/components/studio/StudioNotice' import { button, CARD, EYEBROW_RULED, notice, STUDIO, toggleRow } from '@/components/studio/theme' import { persistAbacusDesign } from '@/hooks/useAbacusDesignSnapshot' import { useAbacusPrintJobs, useCancelPrintJob, useStartPrintJob } from '@/hooks/useAbacusPrintJobs' import { useAbacusPrintSettings, useSaveAbacusPrintSettings } from '@/hooks/useAbacusPrintSettings' import { useKitPlateLayout } from '@/hooks/useKitPlateLayout' import { usePrintJobRing } from '@/hooks/usePrintJobRing' import { useUserId } from '@/hooks/useUserId' import { createAbacusPrintClient } from '@/lib/abacus/print/browser-transport' import type { PrintUnavailableReason, ThhBedGeometry, ThhWipeTowerCapability, } from '@/lib/abacus/print/filament-wire' import { api } from '@/lib/queryClient' import { abacusPrintKeys } from '@/lib/queryKeys' import { type AbacusExportParts, bedSizeFromThh, buildAbacusThreeMf } from './abacus-3mf' import { type FilamentCatalog, spoolSupportKind } from './abacus-catalog' import { commitmentSummary } from './abacus-commitment' import { buildKitPlateThreeMf, KitPlateFitError, kitPlateSignature } from './abacus-kit-plate' import type { FilamentMap, Params } from './abacus-model' import type { ModuleExportParts } from './abacus-module-kit' import { abacusPrintPanelState, designSlotIds, FEET_SUPPORT_PROCESS, feetSupportGate, SLOW_FIRST_LAYER_PROCESS, slowFirstLayerEnabled, supportsEnabled, supportsSlowFirstLayer, withSlowFirstLayer, } from './abacus-print-panel-state' import { buildAbacusAuthoring, buildAbacusTicket } from './abacus-ticket' import { JobNotices } from './JobNotices' import { KitPlatePreview } from './KitPlatePreview' import { ParkedJobCard } from './ParkedJobCard' import { PairPrinterPrompt } from './PrintConnectionsManager' import { PrintDecision } from './PrintDecision' import { PrintSubmitErrorNotice } from './PrintSubmitErrorNotice' import { abacusModelFileName, abacusPrintSignature, type IdempotencyToken, resolveIdempotencyKey, } from './print-idempotency' import { describeJobError, isParked } from './print-jobs' import { PrintServiceError, type SubmitFailure } from './print-submit-failure' import { StageAPrepCard } from './StageAPrepCard' import { studioHref } from './studio-url' import { TwoStageHandoffCard } from './TwoStageHandoffCard' import { checkSeam, clearTwoStageRecord, FEET_ONLY_UNAVAILABLE_COPY, feetOnlyAvailability, handoffView, jobIdFromSubmitBody, loadTwoStageRecord, loadUnattendedStart, recordVariant, safeStorage, saveTwoStageRecord, saveUnattendedStart, sha256Hex, TWO_STAGE_FEED_FAMILY, TWO_STAGE_REAR_BAND_MM, TWO_STAGE_SEAM_TOOL_OVERRIDES, TWO_STAGE_VARIANT_COPY, TwoStageDriftError, type TwoStageRecord, type TwoStageVariant, twoStageAvailability, withTwoStageProcess, } from './two-stage-print' export interface PrintPanelProps { /** Rendered but hidden when false — internal state (style edits) survives. */ visible: boolean /** * Docked in a normal-flow rail (studio full-bleed CP1a) rather than floated * over the canvas. Switches the root from absolute/top-right to static/full- * width; the `visible` display-toggle (single mount) is unchanged. */ embedded?: boolean params: Params filamentMap: FilamentMap catalog: FilamentCatalog servicePlan?: FilamentPlanResponseV1 | null /** Manual filament-role pins — part of the RESTORABLE design snapshot the * submit persists (abaci#22), unlike filamentMap which is provenance. */ overrides: Record<string, string> /** Printer profile — a print setting, and part of the design snapshot. */ profileId: string /** The discovered THH printer (multi-material preferred), or null. */ printerId: string | null /** Whether the chosen printer is AMS-equipped (static model capability) — the * back-compat fallback for wording the empty-roster notice when the live * `amsPresent` signal is absent (pre things-haunt-house#382 service). */ printerMultiMaterial?: boolean /** Live bed dimensions/exclusions from THH's addressed Orca machine profile. */ printerBed?: ThhBedGeometry /** Bounded wipe-tower profile advertised by the selected THH printer. */ wipeTower?: ThhWipeTowerCapability | null /** Live AMS presence from the roster read (#382): `true`/`false` when reported, * `undefined` against a pre-#382 service. Preferred over `printerMultiMaterial` * for empty-roster wording (`amsPresent ?? printerMultiMaterial`) — a live * `false` must not fall through to the static flag. */ amsPresent?: boolean /** A spool is loaded on the external holder but THH couldn't identify its * material (`external:true`, `family:null`) — so the catalog dropped it and the * print isn't settable. Distinct from an empty roster: something IS loaded. */ externalUnprintable?: boolean /** Service reachable + printer found, but it reports zero loaded spools. A * distinct, surfaced state — not a failure and not printable-yet. */ rosterEmpty?: boolean /** The first printer+filament read is still in flight. Kept distinct from a * resolved-empty roster so a mid-load studio never shows the settled * "nothing loaded" notice — "still asking" and "asked, nothing" differ. */ isLoading?: boolean /** A background refetch is running while cached data is shown (e.g. after Try * again) — used to put the retry control in a pending state. */ isFetching?: boolean /** The connection all print reads/writes here target. Omit for the sole- * connection fallback; required once the user has paired more than one. */ connectionId?: string unavailable: PrintUnavailableReason | null /** The service's own sentence for a `refused` plan — quoted verbatim, because * it names WHICH constraint it refused ("palette supports at most 8 entries") * and nothing client-side can reconstruct that. Only rendered for 'refused'. */ unavailableDetail?: string | null /** Solver gate — a design that won't print can't be submitted either. */ exportBlocked: boolean /** Role labels the filament planner could not serve from the loaded roster * (Gitea #37). Non-empty blocks the submit: those roles render in the color the * user DESIGNED, which no loaded spool can lay down, so the plate would carry a * body pointing at an extruder that is never loaded. Empty on an unplanned * design — nothing has been judged yet, and `isLoading` describes that. */ unplacedRoles?: readonly string[] /** One-shot high-quality export renders (whole abacus + the ArUco marker part * passes), all from a single params snapshot taken inside the viewer. */ requestExportParts: () => Promise<AbacusExportParts> /** Whose abacus (the page's `?player=` selection, null = the user's own) — * rides the authoring hand-off so the job's edit link reopens the studio on * the same student (things-haunt-house#408). */ playerId?: string | null /** Present in modular mode (Gitea #30/#32): the design prints as a KIT — every * column module packed onto one bed — instead of the one-piece abacus, so the * submit renders the module bundle and packs it. Absent = the mono print. * Everything downstream (ticket, idempotency, jobs, settings) is shared: a * kit's filament slots are the same abacus's slots. */ kit?: AbacusKitPrint } export interface AbacusKitPrint { /** The modular counterpart to `requestExportParts` — one snapshot, every * module pass (the same bundle the kit zip download builds from). */ requestExportModuleParts: () => Promise<ModuleExportParts> } /** How long the export render may take before the submit gives up. */ const EXPORT_TIMEOUT_MS = 180_000 /** Stable identity for the default `unplacedRoles`, so the prop doesn't re-key * memos on every render of a design that has nothing unplaced (the common case). */ const EMPTY_ROLES: readonly string[] = [] /** "the frame", "the frame and the beads", "the frame, the beads and the feet". * Truncated past four so a design that lost its whole palette to an empty AMS * reads as a sentence rather than a wall of role names. */ function listRoles(labels: readonly string[]): string { const shown = labels.slice(0, 4) const rest = labels.length - shown.length const joined = shown.length <= 1 ? (shown[0] ?? '') : `${shown.slice(0, -1).join(', ')} and ${shown[shown.length - 1]}` return rest > 0 ? `${joined} (+${rest} more)` : joined } /** Host-named first-screen keys (#535) — the handful an abacus print actually * tweaks. Keys the capability document doesn't declare are dropped by the kit. */ const COMMON_KEYS = [ 'layer_height', // NOT `sparse_infill_density`: the design's infill (abacus-infill.ts) now rides // every body as per-part config, and a part's own keys beat the project's. A // plate-wide density here would be a live-looking control that changes nothing // about the print — worse than no control. The infill control sits in the // rail's Print options block above this panel. 'wall_loops', 'enable_support', // Printed feet make this one first-screen material: the whole bottom face // prints on supports, and off, Orca is free to grow them on the beads. See // FEET_SUPPORT_PROCESS. 'support_on_build_plate_only', 'initial_layer_speed', 'initial_layer_acceleration', 'brim_type', ] as const const UNAVAILABLE_COPY: Record<PrintUnavailableReason, string> = { 'not-configured': 'No print service paired — download the 3MF instead.', unreachable: 'Print service unreachable right now.', unauthorized: 'The print service rejected our credentials — re-pair to reconnect.', 'no-printer': 'The print service has no printers.', refused: 'The print service read this design and refused to plan it — its answer is below. This isn’t a hiccup: the same design gets the same answer, so change the design (fewer colors, fewer text groups) — or download the 3MF above and pick the filaments yourself.', error: 'The print service hit an unexpected error reading your filaments — retry, or check the connection in Settings.', } /** The service's clamp echoes (`style.applied`) from a submit response, if any. */ function extractApplied(body: unknown): Record<string, ParamScalarValue> | undefined { if (typeof body !== 'object' || body === null) return undefined const style = (body as { style?: unknown }).style if (typeof style !== 'object' || style === null) return undefined const applied = (style as { applied?: unknown }).applied if (typeof applied !== 'object' || applied === null) return undefined const out: Record<string, ParamScalarValue> = {} for (const [key, value] of Object.entries(applied)) { if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { out[key] = value } } return Object.keys(out).length > 0 ? out : undefined } /** Which job a submit is (Gitea #38): the ordinary one-job print, or one of the * two stages of a feet-first split print. `stage-b-vouched` is Stage B submitted on * the operator's word that the plate holds Stage A's parts, past the ledger checks * that would otherwise refuse it — see TwoStageChain.vouchedByOperator. */ type SubmitStage = 'single' | 'stage-a' | 'stage-b' | 'stage-b-vouched' const isStageB = (stage: SubmitStage): boolean => stage === 'stage-b' || stage === 'stage-b-vouched' const stageTagStyle = { marginLeft: 6, color: STUDIO.color.accentText, fontSize: 11, fontWeight: 700, } as const export function PrintPanel(props: PrintPanelProps) { const { visible, embedded = false, params, filamentMap, catalog, servicePlan = null, overrides, profileId, printerId, printerMultiMaterial = false, printerBed, wipeTower, amsPresent, externalUnprintable = false, rosterEmpty = false, isLoading = false, isFetching = false, connectionId, unavailable, unavailableDetail = null, exportBlocked, unplacedRoles = EMPTY_ROLES, requestExportParts, playerId = null, kit, } = props // Which body state to render — the pure decision lives in `abacusPrintPanelState` // (framework-free, unit-tested) so this component owns only the wording. The four // degrade `kind`s double as the `data-degrade` attribute below; `printable` carries // whether it's the single-external-spool case that needs the monochrome note (#19). const panelState = abacusPrintPanelState({ unavailable, isLoading, catalog, rosterEmpty, externalUnprintable, amsPresent, printerMultiMaterial, }) const monochromeExternal = panelState.kind === 'printable' && panelState.monochromeExternal const queryClient = useQueryClient() const userId = useUserId().data ?? undefined // Doorbell listener: rings invalidate the job/filament queries read below. const ring = usePrintJobRing(userId) const serviceReady = unavailable === null && printerId !== null // ---- capabilities through the package client (ETag revalidation inside) --- // The client is scoped to the selected connection; re-created when it changes // so the ?connectionId= rides every read the kit makes. const client = useMemo(() => createAbacusPrintClient(connectionId), [connectionId]) const caps = useQuery({ queryKey: abacusPrintKeys.capabilities(connectionId), queryFn: () => client.getCapabilities(), enabled: visible && serviceReady, staleTime: 5 * 60_000, retry: 1, // contract-version skew is permanent — don't retry-storm it }) // ---- the controlled ticket style (single source of truth) ----------------- // Three-tier seed: this session's edits > the user's persisted overlay > // the capability doc's default intent. The overlay stores the user's edits // verbatim — the service's `applied` clamp echoes are never persisted. const [styleEdits, setStyleEdits] = useState<TicketStyle | null>(null) const savedSettings = useAbacusPrintSettings(visible && serviceReady) const seededStyle = useMemo<TicketStyle | null>(() => { const presets = caps.data?.basePresets if (!presets) return null const preset = presets.intents[presets.defaultIntent]?.preset return preset ? { basePreset: preset, process: {} } : null }, [caps.data]) const style = styleEdits ?? savedSettings.data ?? seededStyle const slowFirstLayerSupported = supportsSlowFirstLayer(caps.data) const slowFirstLayerActive = slowFirstLayerEnabled(style) // ---- support-interface negotiation (Gitea #23, THH#367) ------------------- // Active only when the style EXPLICITLY enables supports (the client owns // enable_support; THH 400s a role entry on a supports-off ticket). The // recommendation is the service's pre-slice availability-first ladder over the // live roster — keyed on the FRAME spool's family (the body material the // supports touch), refetched on a slow cadence so a mid-session spool swap // updates the ★ without a manual refresh. const supportsWanted = supportsEnabled(style) const frameSpool = catalog.spools[filamentMap.frame] const modelFamily = catalog.source === 'thh-ams' && frameSpool ? frameSpool.material : null const supportRec = useQuery({ queryKey: abacusPrintKeys.supportRecommendation( printerId ?? 'none', modelFamily ?? 'unknown', connectionId ), queryFn: async (): Promise<SupportRecommendation> => { const qs = new URLSearchParams({ modelFamily: modelFamily ?? '' }) if (connectionId) qs.set('connectionId', connectionId) const res = await api( `abacus/print/printers/${encodeURIComponent(printerId ?? '')}/support-recommendation?${qs}` ) if (!res.ok) throw new Error(`support recommendation read failed: ${res.status}`) return (await res.json()) as SupportRecommendation }, enabled: visible && serviceReady && supportsWanted && modelFamily !== null, refetchInterval: 30_000, }) // The operator's pick — controlled state for the kit's SupportRoleEditor. // `null` is a REAL state (interface prints in the model material), so the // recommendation seeds it exactly once: the first recommendation to land // sets the pick, after which the user's choice — including choosing null — // is never clobbered by a refetch. const [supportSlotId, setSupportSlotId] = useState<string | null>(null) // Seeded once per RECOMMENDATION, not once per mount: the recommendation is // computed for a specific printer / connection / model family, so switching any // of those (changing the frame spool changes the family) produces a different // answer that the old pick has no claim on. Within one set of inputs the latch // still holds, so a 30 s refetch never clobbers the operator. const supportSeedKey = `${printerId ?? 'none'}|${modelFamily ?? 'unknown'}|${connectionId ?? ''}` const supportSeededRef = useRef<string | null>(null) // The roster the editor renders, projected from the loaded catalog. External // spools are excluded (a role entry must be a LOADED slot); slots the design // already prints in are excluded because the ticket can't honour them (see // designSlotIds); the editor itself shows only supportKind === 'interface' // rows as pickable. const supportRoster = useMemo<SupportRosterEntry[]>(() => { if (catalog.source !== 'thh-ams') return [] const taken = designSlotIds(filamentMap, catalog.spools) return catalog.spools .filter((s) => !s.external && !taken.has(s.id)) .map((s) => ({ slotId: s.id, label: `Slot ${s.id}`, family: s.material, colorHex: s.hex, product: s.name, supportKind: spoolSupportKind(s), })) }, [catalog, filamentMap]) useEffect(() => { if (supportSeededRef.current === supportSeedKey || !supportRec.data) return supportSeededRef.current = supportSeedKey // Seed only from a slot the editor can actually show. THH's ladder can drop // to a cross-material rung and land on a spool the design itself uses; that // pick is unhonourable, so it seeds as "model material" rather than leaving // the controlled value pointing at a row that isn't rendered. const recommended = supportRec.data.slotId setSupportSlotId( recommended !== null && supportRoster.some((r) => r.slotId === recommended) ? recommended : null ) }, [supportRec.data, supportSeedKey, supportRoster]) // What actually rides the ticket + idempotency signature: the pick exists // only while supports are on (flipping them off must not leave a phantom // role entry — or a stale idempotency key), and only while its spool is still // loaded. Unloading the picked spool otherwise gets as far as submit, where // buildAbacusTicket throws "not in the loaded roster". // ---- two-stage feet print (Gitea #38 / THH #456) -------------------------- // An OPTION at submit, never a design parameter: with a TPU-for-AMS tray loaded // the one-job path stays the default and the feet geometry is identical — only // the feed source differs. On, Stage A prints everything below the feet seam // from the external spool and Stage B chains the rest onto it, off ONE slice. // THH keeps the retained half by model bytes + filament plan, so what the // client remembers between the two (`twoStageRecord`) is the Stage A job id // and the bytes it shipped — per printer, in localStorage, because Stage A // prints for the better part of an hour and the tab won't necessarily last. const twoStage = twoStageAvailability({ params, filamentMap, catalog }) const [twoStageWanted, setTwoStageWanted] = useState(false) const twoStageOn = twoStageWanted && twoStage.ok const seamCheck = twoStage.ok ? checkSeam(twoStage.atZMm, style) : null const [twoStageRecord, setTwoStageRecord] = useState<TwoStageRecord | null>(null) useEffect(() => { setTwoStageRecord(printerId ? loadTwoStageRecord(safeStorage(), printerId) : null) }, [printerId]) const forgetTwoStage = () => { if (printerId) clearTwoStageRecord(safeStorage(), printerId) setTwoStageRecord(null) } // Opt-in, off by default: let the service start Stage B itself once its sensor reports // the external spool gone (`chain.startWhenFeedClears`). A preference about this // operator's printer, kept per browser; read at submit, so it shapes the NEXT Stage B // ticket and never a job the service already holds. const [unattendedStart, setUnattendedStart] = useState(false) useEffect(() => { setUnattendedStart(loadUnattendedStart(safeStorage())) }, []) const setUnattendedStartPersisted = (on: boolean) => { saveUnattendedStart(safeStorage(), on) setUnattendedStart(on) } // The experimental feet-only variant (Gitea #45): Stage A prints ONLY the feet // and Stage B prints PLA supports around them from layer 1. A checkbox beside // the mode, off by default. Stage B never reads it — the record carries the // variant Stage A shipped, since THH admits Stage B only on Stage A's plan. const feetOnly = feetOnlyAvailability({ filamentMap, kit: !!kit }) const [feetOnlyWanted, setFeetOnlyWanted] = useState(false) const feetOnlyOn = twoStageOn && feetOnlyWanted && feetOnly.ok const stageAVariant: TwoStageVariant = feetOnlyOn ? 'feet-only' : 'tpu-floor' // In the TPU-floor two-stage mode the support interface prints in filament 0: // its layers sit right under the frame — under the seam — so routing it to // another spool is a tool change inside Stage A, which THH refuses as // `split_failed`. The feet-only variant partitions Stage A by role instead (the // interface prints in Stage B), so there the pick stands. const supportPick = (!twoStageOn || feetOnlyOn) && supportsWanted && supportRoster.some((r) => r.slotId === supportSlotId) ? supportSlotId : null // Persist edits with a 600ms trailing debounce (event-driven — armed only by // onChange), deduped against the last-saved snapshot, flushed on unmount so // a tune-then-navigate never drops the last edit. const saveSettings = useSaveAbacusPrintSettings() const saveSettingsRef = useRef(saveSettings.mutate) saveSettingsRef.current = saveSettings.mutate const lastSavedRef = useRef<string | null>(null) const pendingSaveRef = useRef<{ timer: ReturnType<typeof setTimeout> style: TicketStyle } | null>(null) if (lastSavedRef.current === null && savedSettings.data) { lastSavedRef.current = JSON.stringify(savedSettings.data) } const flushPendingSave = () => { const pending = pendingSaveRef.current if (!pending) return clearTimeout(pending.timer) pendingSaveRef.current = null const snapshot = JSON.stringify(pending.style) if (snapshot === lastSavedRef.current) return lastSavedRef.current = snapshot saveSettingsRef.current(pending.style) } const flushPendingSaveRef = useRef(flushPendingSave) flushPendingSaveRef.current = flushPendingSave const handleStyleChange = (next: TicketStyle) => { setStyleEdits(next) if (pendingSaveRef.current) clearTimeout(pendingSaveRef.current.timer) pendingSaveRef.current = { style: next, timer: setTimeout(() => flushPendingSaveRef.current(), 600), } } useEffect(() => () => flushPendingSaveRef.current(), []) // The Disclosure below owns the fold; the panel keeps the state because the // floating shape widens for an open editor. const [settingsOpen, setSettingsOpen] = useState(false) // Auto-start is the studio default: the print goes the moment THH's preflight // clears (idle printer, clean bed, passing checks). When it can't, THH parks // the job and the roster's ParkedJobCard resolves it in place — so there's no // held-job dead-end, and no reason to make the user choose a policy up front. const startPolicy: TicketStartPolicy = 'auto' // ---- submit --------------------------------------------------------------- // The idempotency key is bound to the submit's CONTENT, not the panel's // lifetime (see ./print-idempotency): an unchanged resubmit — the lost- or // ambiguous-response retry the key exists for — reuses it so THH replays the // one job, while editing any print-determining input rotates it so the edit // becomes a new job instead of being silently deduped onto the stale one. A // success clears it, so the next print (even an identical one) is fresh. const idemRef = useRef<IdempotencyToken | null>(null) const submit = useMutation({ mutationFn: async (stage: SubmitStage): Promise<unknown> => { if (!printerId) throw new Error('No printer available') if (!style) throw new Error('Print settings are still loading') // Two-stage (Gitea #38): BOTH stages ride the mode's process keys, the // seam-tool overlay and a filament-0 support interface, so THH's identity // gate sees one resolved plan. Stage B additionally needs Stage A's record // and — checked below, once the model is rebuilt — the same bytes. const staged = stage !== 'single' const seam = staged && twoStage.ok ? twoStage : null if (staged && !seam) throw new Error('This design can no longer print in two stages') const priorStage = isStageB(stage) ? twoStageRecord : null if (isStageB(stage) && !priorStage) { throw new Error('No Stage A on record for this printer — print Stage A first') } // The operator's assertion about the plate. It never relaxes the check below that // this is the SAME model Stage A was sliced from: that one is what stops a whole // abacus printing on top of the feet, and the service enforces it too. const vouched = stage === 'stage-b-vouched' // The variant is Stage A's choice and Stage B's inheritance (the record). const variant: TwoStageVariant = priorStage ? recordVariant(priorStage) : stageAVariant const feetOnlyStage = staged && variant === 'feet-only' // The TPU-floor mode prints its interface in filament 0 (no pick); the // feet-only variant carries one, and Stage B repeats Stage A's rather than // the editor's current pick — THH admits Stage B only on the same plan. const interfacePick = !staged ? supportPick : !feetOnlyStage ? null : priorStage ? (priorStage.supportInterfaceSlotId ?? null) : supportPick const ticketStyle = staged ? withTwoStageProcess(style, { variant, dedicatedInterface: interfacePick !== null }) : style // The support body (things-haunt-house#466): the frame's own spool, tagged // on its existing ticket entry. const frameSpool = catalog.spools[filamentMap.frame] if (feetOnlyStage && !frameSpool) { throw new Error('The frame has no loaded spool to print the supports in') } const supportBodySlotId = feetOnlyStage && frameSpool ? frameSpool.id : null // Stage B auto-starts like everything else. The one thing that has to // happen between the stages — unloading the external spool — is read off // the printer's own sensor by the gateway (`awaiting_spool_swap`), which // parks the job until the spool is actually gone; a `hold` on top of that // only added a Start tap that decided nothing. // The race covers the whole bundle (frame + marker part passes) — the // bundle promise resolves only after all renders land. The 3MF builds from // the bundle's own params snapshot; the ticket/idempotency below keep using // the live `params` prop (they describe submit intent, and any divergence // requires editing the design inside the render window). // // The design-snapshot persist (abaci#22) rides IN PARALLEL with the render: // it's bounded and TOTAL — null on any failure — so a dead snapshot API // costs the job its deep edit link, never the print. The envelope is the // restorable intent (params + pins + profile); the AMS projection // (filamentMap/slotLabels) rides as provenance only. const slotLabels = catalog.spools.map((s) => s.name) const bed = bedSizeFromThh(printerBed) // Started here, awaited below, so it overlaps the render. It's bounded and // TOTAL — null on any failure — so a dead snapshot API costs the job its // deep edit link, never the print. The envelope is the restorable intent // (params + pins + profile); the AMS projection (filamentMap/slotLabels) // rides as provenance only. const snapshot = persistAbacusDesign( { v: 1, params, overrides, profileId }, { filamentMap, slotLabels } ) // The race covers the whole bundle (every part pass) — the bundle promise // resolves only after all renders land. The 3MF builds from the bundle's own // params snapshot; the ticket/idempotency below keep using the live `params` // prop (they describe submit intent, and any divergence requires editing the // design inside the render window). const raceRender = <T,>(bundle: Promise<T>): Promise<T> => Promise.race([ bundle, new Promise<never>((_, reject) => setTimeout( () => reject(new Error("The 3D render didn't finish — try again")), EXPORT_TIMEOUT_MS ) ), ]) // Supports push the first layer past the model outline, eating into the 6 mm the // tower was pinned at — the best available reading of prod's exit 155 on // 2026-07-29 (a later A/B slices 6 mm clean, so treat the wider gap as margin, // not a proven cure). The style's `enable_support`, not the design's feet // setting, is the print's real source of truth, so the gap keys off the style. // // Two shapes of print, one tail: a KIT packs every column module onto a // single bed around the reserved tower (Gitea #32) and throws // KitPlateFitError when they won't fit; the mono path emits the one-piece // abacus. Both hand back the same {bytes, bodies, wipeTower}, so the // ticket, idempotency, upload and job plumbing below are shared verbatim — // a kit's filament slots ARE the mono print's slots, same abacus. // // `extraFilaments` is what the ticket adds that no body carries — the routed // support-interface spool. It has to reach the model builder because the tower's // reservation is sized from the plate's filament COUNT (the service publishes a // shallower envelope for fewer filaments), and that reservation is decided before // the ticket exists. The panel withholds design slots from the pickable interface // roster, so a pick is always a filament the bodies don't already carry. const extraFilaments = interfacePick ? 1 : 0 const model = kit ? buildKitPlateThreeMf({ parts: await raceRender(kit.requestExportModuleParts()), filamentMap, slotLabels, supportsAtSlice: supportsWanted, bed, wipeTower: wipeTower ?? undefined, extraFilaments, // A split plate keeps the back of the bed for the hand-off (Z home + // purge lane); the packer refuses with `rear-band` when it can't. rearBandMm: staged ? TWO_STAGE_REAR_BAND_MM : undefined, }) : buildAbacusThreeMf({ ...(await raceRender(requestExportParts())), filamentMap, slotLabels, supportsAtSlice: supportsWanted, bed, wipeTower: wipeTower ?? undefined, extraFilaments, feetOnlyStageA: feetOnlyStage, }) const designId = await snapshot // A packed kit plate carries its arrangement into the key: everything else // in the signature DERIVES the layout, so a packer change is the one way // two submits differ with no user-visible input moving. See kitPlateSignature. const kitLayout = 'placements' in model ? kitPlateSignature(model) : undefined // Reuse the key only for an identical resubmit; any edit rotates it. const idem = resolveIdempotencyKey( idemRef.current, abacusPrintSignature({ params, filamentMap, slotLabels, style: ticketStyle, startPolicy, supportInterfaceSlotId: interfacePick, printerBed, wipeTower, kitLayout, twoStage: seam ? priorStage ? { stage: 'B', continuesJobId: priorStage.stageAJobId, variant, // A retry after a failed Stage B has to be a NEW job, not a replay // of the failed one — see `retryOf`. ...(priorStage.stageBJobId ? { retryOf: priorStage.stageBJobId } : {}), ...(vouched ? { vouched: true as const } : {}), ...(unattendedStart ? { unattended: true as const } : {}), } : { stage: 'A', atZMm: seam.atZMm, feedFamily: seam.feedFamily, variant } : null, }), () => crypto.randomUUID() ) idemRef.current = idem // A kit is named for what lands on the bed — the operator reading the job // list sees N loose modules to assemble, not one finished abacus. const label = kit ? `${params.cols}-column abacus kit` : `${params.cols}-column abacus` const baseName = kit ? `Abacus kit — ${params.cols} columns` : `Abacus — ${params.cols} columns` const baseTicket = buildAbacusTicket({ name: seam ? `${baseName} · ${priorStage ? 'Stage B (body)' : 'Stage A (feet)'}` : baseName, // With a persisted snapshot the artifact IS the design row (abaci#22); // a failed persist degrades to the pre-#22 shallow provenance. The kit // suffix keeps the two OUTPUTS of one design row distinguishable — the // same design prints as a one-piece abacus or as a module kit, and a // job pointing at "the design" alone couldn't say which shipped. source: designId ? { artifactId: kit ? `design-${designId}-kit` : `design-${designId}`, artifactUrl: `${window.location.origin}${studioHref('/create/abacus', { playerId: null, designId, })}`, label, } : { artifactId: kit ? `abacus-kit-${params.cols}col-x${params.scale_factor}` : `abacus-${params.cols}col-x${params.scale_factor}`, artifactUrl: `${window.location.origin}/create/abacus`, label, }, bodies: model.bodies, catalog, style: ticketStyle, startPolicy, idempotencyKey: idem.key, supportInterfaceSlotId: interfacePick, supportBodySlotId, ...(seam ? { seamToolOverrides: TWO_STAGE_SEAM_TOOL_OVERRIDES, ...(priorStage ? { chain: { continuesJobId: priorStage.stageAJobId, ...(vouched ? { vouchedByOperator: true as const } : {}), ...(unattendedStart ? { startWhenFeedClears: true as const } : {}), }, } : { split: { atZMm: seam.atZMm, feed: { external: true as const, family: seam.feedFamily }, ...(feetOnlyStage ? { partition: 'seam-tool-model' as const } : {}), }, }), } : {}), // A link back to the editor, not print content — deliberately outside // the idempotency signature (changing players must not rotate the key, // and neither may a transiently failed snapshot persist). authoring: buildAbacusAuthoring(playerId, { designId }), }) // Send the machine-readable contract only when this service advertised it and the // FINAL routed filament list (including an added support-interface spool) is inside // the profile's tested bound. Older/over-capacity jobs still carry the embedded pin // and follow THH's non-blocking legacy leg. // // `packedForFilaments` is the count the reservation was SIZED for, and the service // rejects it outright if it disagrees with the plan it resolves. It can only ever // over-count here (the ticket drops an interface entry that coincides with a model // slot — unreachable through the panel, but a real branch in the builder), which // means an over-reserved hole: harmless to the slice, fatal to the cross-check. So // on a disagreement, drop the field rather than the whole contract — the pin still // rides, and THH takes its legacy leg instead of 400ing a plate that would print. const ticket = wipeTower && model.wipeTower && baseTicket.filaments.length > 1 && baseTicket.filaments.length <= wipeTower.maxFilaments ? { ...baseTicket, wipeTower: model.wipeTower.packedForFilaments === baseTicket.filaments.length ? model.wipeTower : { profile: model.wipeTower.profile, pinMm: model.wipeTower.pinMm }, } : baseTicket // Stage B must be the SAME bytes THH retained for Stage A: a chained job with // different bytes isn't refused — it slices on its own and silently prints // the whole model. Refused here, before anything leaves the browser. const modelSha256 = seam ? await sha256Hex(model.bytes) : null if (priorStage && modelSha256 !== priorStage.modelSha256) { throw new TwoStageDriftError(priorStage) } const form = new FormData() form.set( 'model', new File( [model.bytes as BlobPart], abacusModelFileName(params.cols, idem.sig, kit ? 'kit' : 'abacus'), { type: 'model/3mf' } ) ) form.set('job', JSON.stringify(ticket)) const cq = connectionId ? `?connectionId=${encodeURIComponent(connectionId)}` : '' const res = await api(`abacus/print/printers/${encodeURIComponent(printerId)}/jobs${cq}`, { method: 'POST', body: form, }) const body: unknown = await res.json().catch(() => null) if (!res.ok) throw new PrintServiceError(res.status, body) // Remember the stage: THH neither persists nor echoes `split`, so this // client is the only party that knows which job was a Stage A. const jobId = seam ? jobIdFromSubmitBody(body) : null if (seam && jobId && modelSha256) { const record: TwoStageRecord = priorStage ? { ...priorStage, stageBJobId: jobId } : { v: 1, printerId, stageAJobId: jobId, modelSha256, designSig: idem.sig, atZMm: seam.atZMm, feedFamily: seam.feedFamily, name: baseName, submittedAt: Date.now(), variant, supportInterfaceSlotId: interfacePick, } saveTwoStageRecord(safeStorage(), record) setTwoStageRecord(record) } return body }, onSuccess: () => { idemRef.current = null queryClient.invalidateQueries({ queryKey: abacusPrintKeys.jobs() }) }, }) // Two kinds of "no": the service refused the ticket, or WE refused to build a // plate that can't be printed (Gitea #32 — the kit needs more than one bed). // The second never leaves the browser, but it's the same shape of news to the // user, so it renders through the same notice instead of a parallel one. The // `kit_` code prefix keeps them apart in the DOM and in any support screenshot. const submitFailure: SubmitFailure | null = submit.error instanceof PrintServiceError ? submit.error.failure : submit.error instanceof TwoStageDriftError ? { code: 'two_stage_model_drift', headline: 'This isn’t the model Stage A printed.', remediation: submit.error.message, blockingJobId: null, invalidTicket: null, missing: [], } : submit.error instanceof KitPlateFitError ? { code: `kit_${submit.error.reason.replace(/-/g, '_')}`, headline: submit.error.headline, remediation: submit.error.remediation, blockingJobId: null, invalidTicket: null, missing: [], } : null const invalidDetail = submitFailure?.invalidTicket ?? undefined const applied = useMemo(() => extractApplied(submit.data), [submit.data]) // ---- jobs roster + resolve actions (ring-invalidated; no poll) ------------ const { jobRows } = useAbacusPrintJobs({ enabled: visible && serviceReady, connectionId }) const startJob = useStartPrintJob() const cancelJob = useCancelPrintJob() // One mutation instance backs every row; scope its pending/error to the row // it's acting on by matching the in-flight variables' jobId, so acting on one // parked job never spins or reddens another. const startFailureFor = (jobId: string): SubmitFailure | null => startJob.error instanceof PrintServiceError && startJob.variables?.jobId === jobId ? startJob.error.failure : null const cancelFailureFor = (jobId: string): SubmitFailure | null => cancelJob.error instanceof PrintServiceError && cancelJob.variables?.jobId === jobId ? cancelJob.error.failure : null // When the service refused because the printer is busy, name the job that's // holding it — correlate its id against the roster we already read. const blockingJob = useMemo( () => submitFailure?.blockingJobId ? (jobRows.find((j) => j.id === submitFailure.blockingJobId) ?? null) : null, [submitFailure, jobRows] ) // Printed TPU feet stand the bottom face off the bed — a supports-off submit // would print a floating first layer. Blocked with a one-click fix (below), // never a silent style injection (see feetSupportGate). const feetGate = feetSupportGate(params, style) // The two-stage hand-off (Gitea #38): what Stage A is doing, and whether Stage // B can go. Phases come from the same roster the rows render from. const handoff = twoStageRecord ? handoffView(twoStageRecord, (id) => jobRows.find((j) => j.id === id)?.phase ?? null) : null const seamMisses = seamCheck?.misses ?? false // The bed preview (Gitea #32). Runs the SAME plan the submit runs, so what's // drawn is what ships — including the refusals, which is the point: "this kit // needs two beds" belongs on screen while the column count is still in the // user's hand, not after they press print. Mono prints get no preview; there's // one object and the container centres it. const kitPlate = useKitPlateLayout({ enabled: visible && serviceReady && !!kit, requestExportModuleParts: kit?.requestExportModuleParts, params, filamentMap, supportsAtSlice: supportsWanted, bed: printerBed, wipeTower, extraFilaments: supportPick ? 1 : 0, }) const summary = useMemo( () => commitmentSummary({ params, filamentMap, catalog, plan: servicePlan, printer: { kind: 'paired', bedMm: printerBed ? { x: printerBed.sizeMm.x, y: printerBed.sizeMm.y } : null, monochromeExternal, supports: { enabled: supportsWanted, interface: supportRoster.find((entry) => entry.slotId === supportPick)?.product ?? null, }, feetGate, twoStage: { on: twoStageOn, feetSpool: twoStage.ok ? twoStage.feetSlot.name : null, feetOnly: feetOnlyOn, }, // 'spills' only on a real refusal; an idle or failed plate query has no // fit result, so the line falls back to the plain piece count kit: !kit ? 'none' : kitPlate.pending ? 'pending' : kitPlate.refusal ? 'spills' : kitPlate.layout ? 'fits' : 'none', }, }), [ params, filamentMap, catalog, servicePlan, printerBed, monochromeExternal, supportsWanted, supportPick, feetGate, twoStageOn, feetOnlyOn, twoStage, kit, kitPlate, ] ) const submitBlocked = exportBlocked || !serviceReady || !style || catalog.source !== 'thh-ams' || unplacedRoles.length > 0 || submit.isPending || feetGate.blocked // Stage A shares the ordinary gate plus the seam: THH can only split between // layers, and a miss is a `split_failed` minutes into a slice. const stageABlocked = submitBlocked || seamMisses // Stage B ignores the toggle — the record, not the switch, is what it chains // on — but still needs the mode available (feet tray still TPU, still loaded). const stageBBlocked = submitBlocked || !twoStage.ok || seamMisses const gates: ReactNode[] = [] if (unplacedRoles.length > 0) { gates.push( <StudioNotice key="unplaced-roles" tone="warn" dataElement="print-unplaced-roles-gate"> <span style={{ display: 'flex', gap: 8 }}> <span aria-hidden="true">🎯</span> <span> No loaded filament can print {listRoles(unplacedRoles)} —{' '} {unplacedRoles.length === 1 ? 'it shows' : 'they show'} here in the color you chose, which nothing on the printer can lay down. Load a closer spool, or recolor{' '} {unplacedRoles.length === 1 ? 'it' : 'them'} to something you have. </span> </span> </StudioNotice> ) } if (feetGate.missing.length > 0) { gates.push( <div key="feet-support" data-element="print-feet-support-gate" data-grade={feetGate.blocked ? 'blocking' : 'advisory'} // status either way: the gate recomputes with the print style/params, so // an assertive role would interrupt on every edit role="status" // the `notice()` fragment rather than <StudioNotice>: this gate carries // `data-grade`, which the primitive has no passthrough for style={{ ...notice(feetGate.blocked ? 'warn' : 'info'), display: 'flex', flexDirection: 'column', gap: 8, }} > <span> <span aria-hidden="true">🦶 </span> {feetGate.blocked ? 'Printed TPU feet stand the abacus off the bed, so the bottom face needs supports — turn them on to print.' : 'Supports are on. Keep them off the model too, or they can grow inside the bead channels where you cannot reach them.'} </span> <button type="button" data-action="enable-feet-supports" disabled={!style} onClick={() => style && handleStyleChange({ ...style, process: { ...style.process, ...Object.fromEntries( feetGate.missing.map((key) => [key, FEET_SUPPORT_PROCESS[key]]) ), }, }) } style={{ ...button('fix', { disabled: !style }), alignSelf: 'flex-start' }} > {feetGate.blocked ? 'Enable supports' : 'Keep supports off the model'} </button> </div> ) } if (twoStageOn && seamCheck?.misses) { gates.push( <StudioNotice key="two-stage-seam" tone="warn" dataElement="two-stage-seam-warning"> The feet seam at {seamCheck.atZMm} mm doesn’t land between layers at{' '} {seamCheck.layerHeightMm} mm {seamCheck.firstLayerMm !== seamCheck.layerHeightMm && ` (first layer ${seamCheck.firstLayerMm} mm)`} , and the print service can only split on a layer boundary. Pick a layer height that divides the feet stand-off, or change the stand-off in the editor. </StudioNotice> ) } return ( <div data-component="abacus-studio-print-panel" data-embedded={embedded || undefined} style={{ position: embedded ? 'static' : 'absolute', width: embedded ? '100%' : settingsOpen ? 380 : 280, display: visible ? 'flex' : 'none', flexDirection: 'column', gap: STUDIO.space.section, color: STUDIO.color.text2, fontSize: 12, // Embedded, the panel IS the rail: no second surface, no second border, // no second padding — only the section rhythm. The floating shape (no // caller today) keeps its own chrome, theme-backed. ...(embedded ? {} : { top: 12, right: 12, maxHeight: 'calc(100% - 24px)', overflowY: 'auto', padding: 12, borderRadius: STUDIO.radius.panel, background: STUDIO.color.rail, border: `1px solid ${STUDIO.color.border}`, transition: 'width 0.15s ease', }), }} > <div style={{ ...EYEBROW_RULED, display: 'flex', alignItems: 'center', gap: 6 }}> <span aria-hidden="true">🖨</span> Print service {ring.connected && ( <span data-element="print-ring-live" title="Live job updates connected" style={{ color: STUDIO.color.tone.ok.bar }} > · live </span> )} </div> {unavailable !== null ? ( <div data-element="print-service-unavailable" style={{ color: STUDIO.color.muted }}> {UNAVAILABLE_COPY[unavailable]} {unavailable === 'refused' && unavailableDetail && ( // The service's own words, marked as a quotation so it reads as the // printer talking rather than as our copy. <div data-element="print-plan-refusal-detail" style={{ marginTop: 6, fontStyle: 'italic', color: STUDIO.color.text2 }} > “{unavailableDetail}” </div> )} {/* Remediation is per-reason: not-configured (zero connections) gets an inline quick-pair (0 → 1, keeps the sole-connection fallback); unreachable/error get a retry that re-runs the reads; unauthorized/ error also link to Settings › Printing (a dead or ambiguous connection must be fixed there, not re-paired inline). no-printer is service-side with no client action, so it stays copy-only — and so does refused: retrying re-asks a question the planner already answered (staleTime Infinity plus a request-bytes key means the answer only changes when the design does), so a control here would be a lie. */} {unavailable === 'not-configured' ? ( <PairPrinterPrompt /> ) : ( <div style={{ display: 'flex', gap: 12, marginTop: 8, alignItems: 'center' }}> {/* Transient reads (unreachable) and unexpected faults (error) are worth retrying in place — re-run the printer/filament reads. */} {(unavailable === 'unreachable' || unavailable === 'error') && ( <button type="button" data-action="retry-print-service" onClick={() => queryClient.invalidateQueries({ queryKey: abacusPrintKeys.all })} style={button('chip')} > Try again </button> )} {/* unauthorized means a broken connection already exists; re-pairing inline would make a SECOND one and break the sole-connection fallback, so send those to Settings › Printing to remove the dead one first. 'error' is ambiguous enough (a 400 from a connection the studio can't disambiguate, a 5xx) that Settings is the right escape hatch too. */} {(unavailable === 'unauthorized' || unavailable === 'error') && ( <a data-action="manage-print-connections" href="/settings?tab=printing" style={{ fontSize: 12, fontWeight: 600, color: STUDIO.color.accentText, textDecoration: 'underline', }} > Manage printers in Settings → </a> )} </div> )} </div> ) : isLoading ? ( // First roster read in flight. Deliberately its OWN neutral state, never // the amber empty-roster notice below: "still asking the printer" and // "asked, nothing loaded" are different states, and only the latter is // actionable. No retry control — nothing to retry while a read is running. <div data-element="print-service-loading" style={{ display: 'flex', alignItems: 'center', gap: 8, color: STUDIO.color.muted, lineHeight: 1.5, }} > <span aria-hidden="true">⏳</span> Reading your printer’s loaded filaments… </div> ) : catalog.source !== 'thh-ams' ? ( // Service reachable + a printer found + the read RESOLVED, but there's no // printable live roster to map onto. Not a failure and NOT loading (those // are the branches above). This is a PRIORITY CHAIN, most-specific first: // E. externalUnprintable — a spool IS loaded on the external holder but // its material is unresolved (family:null → catalog dropped it). Must // be checked BEFORE rosterEmpty: a row exists (rosterEmpty is false), // yet something is physically loaded, so "nothing loaded" would lie. // defensive — roster read didn't resolve to an empty count either (should // not normally reach source!=='thh-ams'); preview the designed colors. // C(AMS) — hasAms: an AMS that reports no loaded spools. // C(noAMS)— else: no AMS and the external holder is empty. // hasAms = live amsPresent ?? static printerMultiMaterial (see above). <div data-element="print-roster-empty" data-degrade={panelState.kind} // status, not alert: the retry button inside flips Try again/Checking… // on every fetch, which would re-announce this whole box role="status" // the `notice()` fragment rather than <StudioNotice>: this box carries // `data-degrade`, which the primitive has no passthrough for style={{ ...notice('warn'), display: 'flex', flexDirection: 'column', gap: 10, }} > <div style={{ ...STUDIO.type.strong, color: 'inherit', display: 'flex', alignItems: 'center', gap: 6, }} > <span aria-hidden="true"> {panelState.kind === 'external-unprintable' ? '🎨' : '🎞️'} </span>{' '} {panelState.kind === 'external-unprintable' ? 'Loaded filament not recognized' : 'No loaded filament to print with'} </div> <div> {panelState.kind === 'external-unprintable' ? 'A spool is loaded on the external holder, but the printer couldn’t identify its material, so one-click print can’t choose settings for it.' : panelState.kind === 'roster-unavailable' ? 'The live filament roster isn’t available right now, so the studio is previewing your designed colors instead of the real spools.' : panelState.kind === 'ams-empty' ? 'The printer is connected, but its AMS reports no loaded spools. One-click print maps your designed colors onto the filaments that are actually loaded, so it needs at least one.' : 'The printer is connected, but nothing is loaded — no AMS, and the external spool holder is empty. Load a spool and press Try again.'} </div> <div style={{ ...STUDIO.type.note, color: 'inherit', opacity: 0.85 }}> {panelState.kind === 'external-unprintable' ? ( <> Reload a recognized filament, or use <strong>Download 3MF to print</strong> above and pick the material yourself. </> ) : ( <> Use <strong>Download 3MF to print</strong> above to slice it yourself, or load filament and press Try again. </> )} </div> <button type="button" data-action="retry-print-service" onClick={() => queryClient.invalidateQueries({ queryKey: abacusPrintKeys.all })} disabled={isFetching} style={{ ...button('fix', { disabled: isFetching }), alignSelf: 'flex-start', cursor: isFetching ? 'progress' : 'pointer', }} > {isFetching ? 'Checking…' : 'Try again'} </button> </div> ) : ( <> {slowFirstLayerSupported && ( <div data-element="slow-first-layer-choice" data-active={slowFirstLayerActive || undefined} style={toggleRow(slowFirstLayerActive)} > <span style={{ display: 'flex', flexDirection: 'column', gap: 2, lineHeight: 1.35 }}> <strong style={STUDIO.type.strong}>Slow first layer</strong> <span style={STUDIO.type.note}> {SLOW_FIRST_LAYER_PROCESS.initial_layer_speed} mm/s ·{' '} {SLOW_FIRST_LAYER_PROCESS.initial_layer_acceleration} mm/s². Try this before adding a brim. </span> </span> <button type="button" data-action="toggle-slow-first-layer" aria-pressed={slowFirstLayerActive} disabled={!style} onClick={() => style && handleStyleChange(withSlowFirstLayer(style, !slowFirstLayerActive)) } style={{ ...button('pill', { on: slowFirstLayerActive, disabled: !style }), flex: '0 0 auto', }} > {slowFirstLayerActive ? 'Use preset speed' : 'Slow it down'} </button> </div> )} {twoStage.ok && ( <div data-element="two-stage-choice" data-active={twoStageOn || undefined} style={toggleRow(twoStageOn)} > <span style={{ display: 'flex', flexDirection: 'column', gap: 2, lineHeight: 1.35 }}> <strong style={STUDIO.type.strong}>Two-stage feet</strong> <span style={STUDIO.type.note}> Below {twoStage.atZMm} mm from the external spool (soft {TWO_STAGE_FEED_FAMILY}), the rest chained from the AMS — one slice, two jobs, a spool swap between. </span> </span> <button type="button" data-action="toggle-two-stage" aria-pressed={twoStageOn} onClick={() => setTwoStageWanted((v) => !v)} style={{ ...button('pill', { on: twoStageOn }), flex: '0 0 auto' }} > {twoStageOn ? 'Print in one job' : 'Split at the feet'} </button> </div> )} {twoStageOn && ( <label data-element="two-stage-feet-only" data-active={feetOnlyOn || undefined} data-unavailable={feetOnly.ok ? undefined : feetOnly.reason} style={{ ...toggleRow(feetOnlyOn), alignItems: 'flex-start', justifyContent: 'flex-start', gap: 8, padding: '8px 10px', // experimental → amber rather than the accent while it is on ...(feetOnlyOn && { background: STUDIO.color.tone.warn.bg, border: `1px solid ${STUDIO.color.tone.warn.bar}`, }), color: feetOnly.ok ? STUDIO.color.text : STUDIO.color.muted, cursor: feetOnly.ok ? 'pointer' : 'default', fontSize: 11, lineHeight: 1.35, }} > <input type="checkbox" data-action="toggle-feet-only" checked={feetOnlyOn} disabled={!feetOnly.ok} onChange={(e) => setFeetOnlyWanted(e.target.checked)} style={{ marginTop: 2 }} /> <span style={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <span> <strong>Experimental</strong> — feet-only Stage A: Stage B prints PLA supports around the feet </span> <span style={STUDIO.type.note}> {feetOnly.ok ? `Stage A ${TWO_STAGE_VARIANT_COPY['feet-only'].stageA}; Stage B ${TWO_STAGE_VARIANT_COPY['feet-only'].stageB}. Nothing but the feet is TPU on the plate.` : FEET_ONLY_UNAVAILABLE_COPY[feetOnly.reason]} </span> </span> </label> )} <PrintDecision summary={summary} plate={ kit ? ( <div data-element="kit-plate-preview-slot" style={{ opacity: kitPlate.pending && kitPlate.layout ? 0.55 : 1 }} > <KitPlatePreview layout={kitPlate.layout} refusal={kitPlate.refusal} pending={kitPlate.pending} filaments={kitPlate.filaments ?? undefined} /> </div> ) : undefined } gates={gates} prep={ twoStageOn && !twoStageRecord ? <StageAPrepCard variant={stageAVariant} /> : undefined } submit={{ label: submit.isPending ? 'Rendering & submitting…' : kit ? '🖨 Print this kit' : twoStageOn ? '🖨 Print Stage A (feet)' : '🖨 Print this abacus', disabled: twoStageOn ? stageABlocked : submitBlocked, pending: submit.isPending, onClick: () => submit.mutate(twoStageOn ? 'stage-a' : 'single'), title: exportBlocked ? 'Fix the printability errors first' : feetGate.blocked ? 'Enable supports first — printed feet need them' : twoStageOn && seamMisses ? 'The feet seam has to land on a layer boundary' : twoStageOn ? 'Slice once; print the feet from the external spool first' : 'Slice and print on the paired printer', }} /> {submit.isSuccess && ( <StudioNotice tone="ok" dataElement="print-submit-success"> {submit.variables === 'stage-a' ? 'Stage A submitted — the feet print from the external spool first. When it completes, the hand-off below walks you to Stage B.' : submit.variables === 'stage-b' ? 'Stage B submitted and held — start it from its job card below once the spool swap is done.' : 'Job submitted — it’ll start on its own once the printer’s ready, or show up below to resolve if the bed needs a look.'} {applied && ' Some settings were adjusted by the printer — see the editor.'} </StudioNotice> )} {submit.isError && ( <PrintSubmitErrorNotice failure={submitFailure} blockingJob={blockingJob} fallbackMessage={ submit.error instanceof Error ? submit.error.message : 'Submit failed.' } /> )} {twoStageRecord && handoff && ( <TwoStageHandoffCard name={twoStageRecord.name} variant={recordVariant(twoStageRecord)} view={handoff} disabled={stageBBlocked} disabledReason={ !twoStage.ok ? 'The feet tray is no longer an AMS TPU spool — reload it to chain Stage B' : seamMisses ? 'The feet seam has to land on a layer boundary' : null } submitting={submit.isPending && isStageB(submit.variables ?? 'single')} onSubmitStageB={() => submit.mutate('stage-b')} onVouchStageB={() => submit.mutate('stage-b-vouched')} onForget={forgetTwoStage} unattendedStart={unattendedStart} onUnattendedStartChange={setUnattendedStartPersisted} /> )} {/* settings disclosure — `keepMounted` IS the old `settingsEverOpened`: the editor is expensive and holds unsaved edits, so it mounts on first open and stays mounted (hidden) after. */} <Disclosure label="Print settings" keepMounted open={settingsOpen} onOpenChange={setSettingsOpen} dataAction="toggle-print-settings" dataElement="print-settings" > <div data-element="print-settings-editor"> {caps.isError ? ( <div style={{ color: STUDIO.color.dangerInline, lineHeight: 1.45 }}> Couldn't load the printer's settings schema. </div> ) : caps.data && style ? ( <PrintSettingsEditor doc={caps.data} value={style} onChange={handleStyleChange} errors={invalidDetail} applied={applied} theme="dark" commonKeys={COMMON_KEYS} /> ) : ( <div style={{ color: STUDIO.color.muted }}>Loading settings…</div> )} </div> </Disclosure> {/* Support-interface routing (Gitea #23, THH 367) — visible whenever the style enables supports, independent of the settings disclosure: the pick changes what the printer lays down, so it must never hide behind a collapsed editor. The recommendation ★ and any service caution render inside the kit editor; the reminder ("load your Support for PLA") is service DATA the kit leaves to the host. */} {supportsWanted && !twoStageOn && ( <div data-element="print-support-role" style={{ display: 'flex', flexDirection: 'column', gap: 6 }} > {/* A `null` recommendation is the kit's "supports aren't needed" state, so an unresolved query must NOT be flattened into one — that would tell the operator no support is needed directly under the banner that just forced supports on. Say what's true instead: still asking, or couldn't ask. */} {supportRec.data === undefined ? ( <span data-element="print-supports-line" style={{ ...STUDIO.type.note, fontWeight: 600 }} > {supportRec.isError ? "Couldn't reach the printer for a support-interface recommendation — the interface will print in the model's own filament." : 'Checking which filament should face the supports…'} </span> ) : ( <SupportRoleEditor roster={supportRoster} recommendation={supportRec.data} value={supportSlotId} onChange={setSupportSlotId} errors={invalidDetail} theme="dark" /> )} {supportRec.data?.reminder && ( <div data-element="print-support-reminder" style={{ color: STUDIO.color.warnInline, lineHeight: 1.45 }} > <span aria-hidden="true">💡 </span> Loading {supportRec.data.reminder.product} ({supportRec.data.reminder.family}) would give these supports a cleaner release. </div> )} </div> )} {/* recent jobs — identifiers from the ring, truth from the proxy read */} {jobRows.length > 0 && ( <div data-element="print-jobs-list" style={{ display: 'flex', flexDirection: 'column', gap: 4 }} > <div style={{ ...STUDIO.type.eyebrow, marginBottom: 2 }}>Jobs</div> {jobRows.slice(0, 5).map((job) => { const failure = job.error ? describeJobError(job.error) : null const supportCollision = job.error?.context?.supportsEnabled === true return ( <div key={job.id} data-element="print-job-row" style={{ ...CARD, padding: '8px 10px', borderRadius: STUDIO.radius.button, display: 'flex', flexDirection: 'column', gap: 3, }} > <div style={{ display: 'flex', justifyContent: 'space-between', gap: 8 }}> <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', }} > {job.name} {twoStageRecord?.stageAJobId === job.id && ( <span data-element="job-stage-tag" style={stageTagStyle}> Stage A </span> )} {job.chain && ( <span data-element="job-stage-tag" style={stageTagStyle}> Stage B · chained </span> )} </span> <span style={{ color: STUDIO.color.muted, whiteSpace: 'nowrap' }}> {job.phase} {job.progress !== null && ` · ${Math.round(job.progress)}%`} </span> </div> {job.error && failure && ( <div data-element="print-job-error" role="alert" style={{ color: STUDIO.color.warnInline, whiteSpace: 'normal', overflowWrap: 'anywhere', display: 'flex', flexDirection: 'column', gap: 5, }} > <div> <span aria-hidden="true">✕ </span> <strong>{failure.summary}</strong> </div> <div>{failure.action}</div> {supportCollision && ( <div> Printed feet add supports beneath the abacus. Those supports occupy plate space too, so replacing TPU with another filament does not remove the collision. </div> )} <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}> {failure.recommended === 'relayout_plate' && ( <button type="button" onClick={() => submit.mutate( submit.variables ?? (twoStageOn ? 'stage-a' : 'single') ) } disabled={submit.isPending} style={{ ...button('fix', { disabled: submit.isPending }), cursor: submit.isPending ? 'wait' : 'pointer', }} > {submit.isPending ? 'Rebuilding…' : 'Rebuild plate & resubmit'} </button> )} {job.authoring && ( <a href={job.authoring.editUrl} target="_blank" rel="noreferrer" style={{ color: STUDIO.color.accentText, padding: '4px 0' }} > Edit in {job.authoring.editTool ?? 'Abacus Studio'} ↗ </a> )} </div> <details> <summary style={{ cursor: 'pointer' }}>Technical details</summary> <div style={{ marginTop: 3 }}>{failure.technical ?? job.error.code}</div> </details> </div> )} {/* What the service couldn't CHECK before running it (#29). Reads after the failure and before the resolver: facts first, then the thing you can press. Renders on every phase — a notice-only job never parks, so ParkedJobCard below would never mount to carry it. */} <JobNotices notices={job.notices} /> {/* Auto-start couldn't just go (or paused mid-print): resolve it here — bed photo, the service's reasons, start-anyway or cancel — instead of leaving it a dead-end. */} {(isParked(job.phase) || job.attention.length > 0) && ( <ParkedJobCard job={job} onStart={(acknowledge) => startJob.mutate({ jobId: job.id, acknowledge })} onCancel={(stopPrint) => cancelJob.mutate({ jobId: job.id, stopPrint })} startPending={startJob.isPending && startJob.variables?.jobId === job.id} cancelPending={cancelJob.isPending && cancelJob.variables?.jobId === job.id} startFailure={startFailureFor(job.id)} cancelFailure={cancelFailureFor(job.id)} /> )} </div> ) })} </div> )} </> )} </div> ) } |