All files / web/src/components/create/abacus FabricationRail.tsx

76.13% Statements 351/461
79.16% Branches 19/24
22.22% Functions 2/9
76.13% Lines 351/461

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 4621x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x                                 9x 9x                       9x 9x 9x 9x 9x 2x 2x 2x 2x 2x 2x 2x 7x 7x 7x 7x 7x 7x 7x 7x 7x   7x 7x   7x 7x 7x 6x 1x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 7x 9x 9x       9x 9x 9x 9x 1x 1x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 4x 2x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x                                                                                                                                             9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x                 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x  
'use client'
 
// FabricationRail — the studio's RIGHT docked rail (Gitea epic #5, full-bleed
// CP1a). It answers the studio's second and third questions: HOW is this design
// made, and WHAT am I committing my printer and myself to?
//   • the filament reconcile strip (how the designed colors land on the loaded
//     filaments), the printer profile and the printability verdict,
//   • "Print options" — infill, the modular joint's fit + coupon, and the two
//     slicer knobs (bead clearance, curve smoothness) behind "Advanced",
//   • the commitment: ONE primary action, and the files.
// Mounted only on the 3D-print target, so the paper lane pays for none of it.
// The 3D model always shows the user's designed colors — the design→filament
// reconciliation is whispered by the strip, not a preview toggle.
//
// ONE PRIMARY ACTION. Paired with a print service, the cyan button is the
// PrintPanel's submit and the files sit below it in the secondary style. Unpaired,
// there is nothing to submit to, so the main file (the 3MF, or the module kit)
// takes the cyan and sits ABOVE the panel's pairing prompt. Never two.
//
// The heavy renders stay bound to the three.js viewer; this rail calls the
// store's registered `requestExportParts()` (whole abacus + the ArUco marker
// part passes, one params snapshot) and assembles the 3MF from that bundle plus
// the store's live filamentMap/catalog. `exporterReady` gates the buttons while
// the viewer chunk is still loading.
 
import { useMemo, useState } from 'react'
import { Disclosure } from '@/components/studio/Disclosure'
import { StudioNotice } from '@/components/studio/StudioNotice'
import { StudioSection } from '@/components/studio/StudioSection'
import { StudioSelect } from '@/components/studio/StudioSelect'
import { StudioSlider } from '@/components/studio/StudioSlider'
import { button, STUDIO } from '@/components/studio/theme'
import { useAbacusStudio } from './AbacusStudioContext'
import { buildAbacusThreeMf } from './abacus-3mf'
import { commitmentSummary } from './abacus-commitment'
import { isModular } from './abacus-model'
import { PRINTER_PROFILES } from './abacus-solver'
import { downloadBlob } from './download-blob'
import { FilamentPlanPanel } from './FilamentPlanPanel'
import { InfillControls } from './InfillControls'
import {
  ModularFitPanel,
  ModuleKitExport,
  type SeamBusy,
  type SeamBusyProps,
} from './ModularSeamPanel'
import { PrintCommitmentCard } from './PrintCommitmentCard'
import { PrintPanel } from './PrintPanel'
 
/**
 * ExportFiles — the "Files" section: what you can carry to a slicer yourself.
 * `primary` is true only when nothing else in the rail is the primary action
 * (i.e. no paired print service), and then the MAIN file — the 3MF, or the
 * module kit in modular mode — wears the cyan. The mono buttons are simply
 * absent in modular mode rather than shown disabled: the kit IS the file, and
 * the old "3MF is one piece — kit below" label only existed because the kit was
 * far away.
 */
function ExportFiles({ primary, ...busyProps }: { primary: boolean } & SeamBusyProps) {
  const {
    params,
    profile,
    catalog,
    filamentMap,
    servicePlan,
    exportBlocked,
    exporterReady,
    requestExportStl,
    requestExportParts,
  } = useAbacusStudio()
 
  // A failed export render (e.g. a marker part pass) now REJECTS instead of
  // hanging — surfaced inline under the button. Silently swallowing it would
  // recreate the markerless-print bug in UX form (Gitea #12).
  const [exportError, setExportError] = useState<string | null>(null)
 
  const modular = isModular(params)
  const canExport = exporterReady && !exportBlocked
  const summary = useMemo(
    () =>
      commitmentSummary({
        params,
        filamentMap,
        catalog,
        plan: servicePlan ?? null,
        printer: { kind: 'unpaired' },
      }),
    [params, filamentMap, catalog, servicePlan]
  )
 
  // the multi-material 3MF — the print projection's colors baked in as one
  // co-registered body per filament slot (#9), plus the ArUco corner marker
  // bodies from their own part renders (#12). Falls back to the raw colorless
  // STL for anyone whose slicer wants that.
  const onExport3mf = async () => {
    setExportError(null)
    try {
      const parts = await requestExportParts()
      const { bytes } = buildAbacusThreeMf({
        ...parts, // stl + marker/feet part renders + the params snapshot they rendered from
        filamentMap,
        slotLabels: catalog.spools.map((s) => s.name),
      })
      downloadBlob(
        new Blob([bytes as BlobPart], { type: 'model/3mf' }),
        `abacus-${params.cols}col-x${params.scale_factor}.3mf`
      )
    } catch (err) {
      setExportError(String((err as Error)?.message ?? err))
    }
  }
 
  const onExportPlainStl = async () => {
    setExportError(null)
    try {
      const stl = await requestExportStl()
      downloadBlob(
        new Blob([stl], { type: 'model/stl' }),
        `abacus-${params.cols}col-x${params.scale_factor}.stl`
      )
    } catch (err) {
      setExportError(String((err as Error)?.message ?? err))
    }
  }
 
  return (
    <StudioSection label="Files" dataElement="abacus-section-files">
      {primary && <PrintCommitmentCard summary={summary} />}
      {modular ? (
        <>
          <ModuleKitExport primary={primary} {...busyProps} />
          <div data-element="modular-kit-caveat" style={{ ...STUDIO.type.note, fontSize: 10 }}>
            End modules carry engraved marker pockets, not printed markers. Words on the top/bottom
            rails and front/back walls print on the one-piece abacus only.
          </div>
        </>
      ) : (
        <>
          <button
            type="button"
            data-action="export-3mf"
            onClick={onExport3mf}
            disabled={!canExport}
            title={
              exportBlocked
                ? `Fix the errors above to print on ${profile.label}`
                : exporterReady
                  ? 'Download a print-ready multi-material 3MF'
                  : 'Preparing the 3D exporter…'
            }
            style={
              primary
                ? button('primary', { disabled: !canExport })
                : button('secondary', { disabled: !canExport })
            }
          >
            ⬇ 3MF
          </button>
          <button
            type="button"
            data-action="export-stl"
            onClick={onExportPlainStl}
            disabled={!canExport}
            style={button('secondary', { disabled: !canExport })}
          >
            ⬇ plain STL
          </button>
        </>
      )}
      {exportError != null && (
        <StudioNotice tone="danger" dataElement="abacus-studio-export-error">
          Export failed: {exportError}
        </StudioNotice>
      )}
    </StudioSection>
  )
}
 
export function FabricationRail() {
  const {
    params,
    set,
    profileId,
    setProfileId,
    profile,
    overrides,
    setOverrides,
    design,
    thhFilaments,
    connections,
    selectedConnectionId,
    selectConnection,
    catalog,
    servicePlan,
    unpinnedServicePlan,
    servicePlanUnavailable,
    servicePlanUnavailableDetail,
    planPending,
    unplacedRoles,
    filamentMap,
    solveResult,
    errors,
    warnings,
    exportBlocked,
    scaleFix,
    clearanceFix,
    requestExportParts,
    requestExportModuleParts,
    setRevealIntrinsic,
    setHighlightRole,
    modelPick,
    playerId,
  } = useAbacusStudio()
 
  // Modular columns (Gitea #30): the whole-abacus exports would print a
  // fused-seam monolith with dead sockets, so in modular mode the Files section
  // offers the kit instead. The print-service panel does NOT switch away: it
  // submits the kit (Gitea #32), packing every module onto one bed, and refuses
  // only when the modules genuinely don't fit one plate.
  const modular = isModular(params)
 
  // One render at a time across the two seam downloads — they sit in different
  // sections now (fit/coupon in Print options, kit in Files), so the interlock
  // they always had lives here, in their common owner.
  const [seamBusy, setSeamBusy] = useState<SeamBusy>(null)
 
  // Rebuilt only when the requestor identity changes, so the panel's submit
  // mutation isn't handed a fresh object every render.
  const kitPrint = useMemo(() => ({ requestExportModuleParts }), [requestExportModuleParts])
 
  // The single primary action. When the panel can actually offer a submit, that
  // is the point of the rail and the files are a fallback; otherwise there is
  // nothing to submit to, so the file IS the commitment and takes the cyan —
  // and the commitment card with it. "Paired" is not enough: a paired printer
  // whose service is unreachable, or whose roster is empty, leaves the
  // download as the only real action, and it must not hide below a dead panel.
  // Loading is not a degrade — the panel is about to offer the submit.
  const submitOffered =
    connections.length > 0 &&
    (thhFilaments.unavailable ?? servicePlanUnavailable) == null &&
    !thhFilaments.rosterEmpty
  const files = <ExportFiles primary={!submitOffered} busy={seamBusy} onBusy={setSeamBusy} />
 
  return (
    <div
      data-component="fabrication-rail"
      style={{
        display: 'flex',
        flexDirection: 'column',
        gap: STUDIO.space.rail,
        padding: '16px',
        color: STUDIO.color.text,
        fontSize: 12,
      }}
    >
      <div style={{ ...STUDIO.type.heading, letterSpacing: '0.02em' }}>3D print</div>
 
      {/* the single part-aware filament↔color list (Gitea #17): one row per abacus
          part, each with a thumbnail + a flecked tile of the filament it prints on.
          Hovering a row highlights that part on the 3D hero (onHighlightRole);
          hovering its tile flips the hero to the designed colors (onRevealIntrinsic).
          The reverse binding (#18): clicking a part on the hero opens its row here
          (modelPick, emitted by the viewer's raycaster). */}
      <FilamentPlanPanel
        design={design}
        catalog={catalog}
        overrides={overrides}
        servicePlan={servicePlan}
        unpinnedServicePlan={unpinnedServicePlan}
        planPending={planPending}
        onOverridesChange={setOverrides}
        onRevealIntrinsic={setRevealIntrinsic}
        onHighlightRole={setHighlightRole}
        modelPick={modelPick}
      />
 
      {/* printer profile — a first-class print setting (drives the gate below) */}
      <StudioSelect
        label="printer profile"
        value={profileId}
        options={PRINTER_PROFILES.map((p) => ({ value: p.id, label: p.label }))}
        onChange={setProfileId}
        dataElement="abacus-studio-profile"
        dataAction="select-profile"
      />
 
      {/* printability verdict: red errors block Export (with one-click fixes),
          amber warnings inform but don't block */}
      {solveResult.reasons.length > 0 && (
        <div
          data-element="abacus-studio-solver-reasons"
          style={{ display: 'flex', flexDirection: 'column', gap: 8 }}
        >
          {errors.length > 0 && (
            <StudioNotice
              tone="danger"
              // status, not alert: this box is present for as long as the design
              // is unprintable and its text recomputes on every step of the bead
              // clearance drag below — an alert would interrupt on each one.
              role="status"
              dataElement="abacus-studio-solver-errors"
              title={
                <span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
                  <span aria-hidden="true">⛔</span> Won&apos;t print on {profile.label}
                </span>
              }
              actions={
                scaleFix != null || clearanceFix != null ? (
                  <>
                    {scaleFix != null && (
                      <button
                        type="button"
                        data-action="apply-solver-fix"
                        onClick={() => set('scale_factor', scaleFix)}
                        style={button('fix')}
                      >
                        ⤢ Scale up to {scaleFix}×
                      </button>
                    )}
                    {clearanceFix != null && (
                      <button
                        type="button"
                        data-action="apply-solver-fix"
                        onClick={() => set('clearance', clearanceFix)}
                        style={button('fix')}
                      >
                        ↕ Raise bead clearance to {clearanceFix.toFixed(2)} mm
                      </button>
                    )}
                  </>
                ) : undefined
              }
            >
              <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
                {errors.map((r) => (
                  <div key={r.dim}>{r.message}</div>
                ))}
              </div>
            </StudioNotice>
          )}
          {warnings.length > 0 && (
            <StudioNotice
              tone="warn"
              role="status"
              dataElement="abacus-studio-solver-warnings"
              title={
                <span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
                  <span aria-hidden="true">⚠️</span> Heads up
                </span>
              }
            >
              <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
                {warnings.map((r) => (
                  <div key={r.dim}>{r.message}</div>
                ))}
              </div>
            </StudioNotice>
          )}
        </div>
      )}
 
      {/* HOW it's made. Everything here is invisible in the finished design and
          meaningless on paper: density, the joint's tuning, and the two slicer
          knobs that used to sit in the design rail. */}
      <StudioSection label="Print options" dataElement="abacus-section-print-options">
        <InfillControls />
        {modular && <ModularFitPanel busy={seamBusy} onBusy={setSeamBusy} />}
        {/* Two real knobs that almost nobody should touch: one is a printer's
            tolerance, the other is preview/slice cost. Collapsed, not removed. */}
        <Disclosure
          label="Advanced"
          dataElement="abacus-print-advanced"
          dataAction="toggle-print-advanced"
        >
          <StudioSlider
            label="bead clearance (mm)"
            value={params.clearance}
            min={0.1}
            max={0.8}
            step={0.01}
            onChange={(v) => set('clearance', v)}
            formatValue={(v) => v.toFixed(2)}
          />
          <div data-element="abacus-clearance-note" style={STUDIO.type.note}>
            Gap between each bead and its rod. Raise it if beads bind on your printer.
          </div>
          <StudioSlider
            label="curve smoothness"
            value={params.fn}
            min={8}
            max={64}
            step={1}
            onChange={(v) => set('fn', v)}
          />
          <div data-element="abacus-fn-note" style={STUDIO.type.note}>
            Facets per curve. Higher is smoother, and slower to preview and slice.
          </div>
        </Disclosure>
      </StudioSection>
 
      {/* which paired print service this design prints to. Only shown once the
          user has more than one — with a single connection there's nothing to
          choose and the proxy resolves it implicitly. Switching re-reads the
          printer, filament roster, capabilities, and job list for that service. */}
      {connections.length > 1 && (
        <StudioSelect
          label="print service"
          value={selectedConnectionId ?? ''}
          options={connections.map((c) => ({ value: c.id, label: c.name }))}
          onChange={selectConnection}
          dataElement="abacus-studio-print-connection"
          dataAction="select-print-connection"
        />
      )}
 
      {!submitOffered && files}
 
      {/* print-service panel (Gitea #9) — embedded (normal flow) in the rail.
          One panel, two shapes of print: in modular mode `kit` switches the
          submit onto the packed module plate (Gitea #32) instead of the
          one-piece abacus, which in modular mode would be the same footgun the
          whole-abacus download buttons are. Everything else — settings, jobs,
          filament roster — is the same abacus and is shared verbatim. */}
      <PrintPanel
        embedded
        visible={true}
        params={params}
        filamentMap={filamentMap}
        catalog={catalog}
        servicePlan={servicePlan}
        overrides={overrides}
        profileId={profileId}
        printerId={thhFilaments.printerId}
        printerMultiMaterial={thhFilaments.printerMultiMaterial}
        printerBed={thhFilaments.printerBed}
        wipeTower={thhFilaments.wipeTower}
        amsPresent={thhFilaments.amsPresent}
        externalUnprintable={thhFilaments.externalUnprintable}
        rosterEmpty={thhFilaments.rosterEmpty}
        isLoading={thhFilaments.isLoading}
        isFetching={thhFilaments.isFetching}
        connectionId={selectedConnectionId}
        // The roster read wins: when it fails the plan read never fires (an empty
        // rosterSignature disables it), so this `??` is belt-and-braces, not a
        // precedence rule anyone should rely on. The detail rides unconditionally
        // because only the PLAN hook can produce 'refused', and the panel shows
        // the detail only under that reason — so a catalog failure can never wear
        // the planner's words.
        unavailable={thhFilaments.unavailable ?? servicePlanUnavailable}
        unavailableDetail={servicePlanUnavailableDetail}
        exportBlocked={exportBlocked}
        unplacedRoles={unplacedRoles}
        requestExportParts={requestExportParts}
        playerId={playerId}
        kit={modular ? kitPrint : undefined}
      />
 
      {submitOffered && files}
    </div>
  )
}