lithop 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +7 -0
  3. data/Cargo.lock +1 -1
  4. data/README.md +14 -11
  5. data/RELEASING.md +3 -3
  6. data/docs/README.md +4 -3
  7. data/docs/artifacts.md +27 -15
  8. data/docs/bambu-studio.md +12 -10
  9. data/docs/calibration.md +4 -4
  10. data/docs/color-model.md +20 -18
  11. data/docs/command.md +28 -12
  12. data/docs/config.md +42 -30
  13. data/docs/getting-started.md +12 -6
  14. data/docs/local-ui.md +12 -9
  15. data/docs/tiling.md +18 -19
  16. data/docs/troubleshooting.md +3 -3
  17. data/ext/lithop_native/Cargo.toml +1 -1
  18. data/lib/lithop/calibration/target.rb +5 -5
  19. data/lib/lithop/cli/image_adjustment_options.rb +33 -0
  20. data/lib/lithop/cli/image_options.rb +5 -18
  21. data/lib/lithop/cli/options.rb +4 -4
  22. data/lib/lithop/export/color_metadata.rb +46 -23
  23. data/lib/lithop/export/generation.rb +8 -10
  24. data/lib/lithop/export/planning.rb +82 -45
  25. data/lib/lithop/export/print_readme.rb +20 -4
  26. data/lib/lithop/formats/three_mf/bambu_metadata.rb +11 -8
  27. data/lib/lithop/formats/three_mf/model_writer.rb +2 -5
  28. data/lib/lithop/image/pipeline.rb +40 -26
  29. data/lib/lithop/image/svg_loader.rb +11 -2
  30. data/lib/lithop/layer_structure.rb +0 -9
  31. data/lib/lithop/model/filament_profile.rb +3 -3
  32. data/lib/lithop/model/material_display_map.rb +2 -1
  33. data/lib/lithop/model/z_region_model.rb +44 -0
  34. data/lib/lithop/settings.rb +147 -15
  35. data/lib/lithop/settings_file.rb +1 -0
  36. data/lib/lithop/version.rb +1 -1
  37. data/lib/lithop/web/app.rb +9 -5
  38. data/lib/lithop/web/public/app.css +48 -37
  39. data/lib/lithop/web/public/app.js +149 -47
  40. data/lib/lithop/web/settings_form.rb +8 -5
  41. data/lib/lithop/web/views/index.erb +129 -75
  42. data/lib/lithop.rb +2 -1
  43. data/sig/lithop.rbs +16 -0
  44. metadata +3 -2
  45. data/lib/lithop/model/layer_region_model.rb +0 -24
@@ -3,6 +3,7 @@ const form = document.querySelector("#conversion-form");
3
3
  const fileInput = document.querySelector("#image-input");
4
4
  const filePicker = document.querySelector("#file-picker");
5
5
  const fileName = document.querySelector("#file-name");
6
+ const submitButton = document.querySelector("#submit-button");
6
7
  const previewIdInput = document.querySelector("#preview-id");
7
8
  const sourceImage = document.querySelector("#source-image");
8
9
  const compareSourceImage = document.querySelector("#compare-source-image");
@@ -29,9 +30,11 @@ const previewLabels = Object.fromEntries(previewKinds.map(({ name, label }) => [
29
30
  const settingLabels = {
30
31
  width: "幅", height: "高さ", pixel_size: "XYセル", color_pixel_size: "色セル",
31
32
  image_fit: "配置", crop_position_x: "横位置", crop_position_y: "縦位置", crop_scale: "クロップ倍率",
32
- brightness: "明るさ", contrast: "コントラスト", saturation: "彩度", tile_count: "タイル数",
33
+ brightness: "明るさ", contrast: "コントラスト", saturation: "彩度", suppress_pale_colors: "淡色をWhite化",
34
+ yellow_mix_color: "Yellow混色", yellow_mix_percent: "Yellow混色率", pigment_order: "色の積層順",
35
+ tile_count: "タイル数",
33
36
  tile_layout: "タイル配置", tile_rows: "行数", tile_columns: "列数", separate_z_parts: "Z分割",
34
- color_region_parts: "色領域数", minimum_thickness: "最小厚み", maximum_thickness: "最大厚み",
37
+ color_region_parts: "最大Zプレート数", minimum_thickness: "最小厚み", maximum_thickness: "最大厚み",
35
38
  nozzle_diameter: "ノズル", margin_top: "上余白", margin_right: "右余白",
36
39
  margin_bottom: "下余白", margin_left: "左余白"
37
40
  };
@@ -71,6 +74,15 @@ const appViewLabels = { create: "作成", jobs: "ジョブ", presets: "プリセ
71
74
  let currentView = requestedView();
72
75
  let currentJobDetailTab = requestedJobTab();
73
76
 
77
+ function updateHeaderHeight() {
78
+ const height = document.querySelector(".site-header")?.getBoundingClientRect().height;
79
+ if (height) document.documentElement.style.setProperty("--site-header-height", `${Math.ceil(height)}px`);
80
+ }
81
+
82
+ updateHeaderHeight();
83
+ if (window.ResizeObserver) new ResizeObserver(updateHeaderHeight).observe(document.querySelector(".site-header"));
84
+ else window.addEventListener("resize", updateHeaderHeight);
85
+
74
86
  function requestedView() {
75
87
  const parameters = new URL(window.location.href).searchParams;
76
88
  const view = parameters.get("view");
@@ -91,11 +103,6 @@ function viewUrl(view) {
91
103
  return `/?${parameters}`;
92
104
  }
93
105
 
94
- function closeNavigation() {
95
- document.body.classList.remove("navigation-open");
96
- document.querySelector("#menu-toggle")?.setAttribute("aria-expanded", "false");
97
- }
98
-
99
106
  function showAppView(view, { push = false, focus = false } = {}) {
100
107
  if (!Object.hasOwn(appViewLabels, view)) view = "create";
101
108
  currentView = view;
@@ -109,7 +116,6 @@ function showAppView(view, { push = false, focus = false } = {}) {
109
116
  });
110
117
  document.querySelector("#current-view-label").textContent = appViewLabels[view];
111
118
  document.title = `${appViewLabels[view]} · Lithop Local UI`;
112
- closeNavigation();
113
119
  if (push) window.history.pushState({ jobId: currentJobId, view }, "", viewUrl(view));
114
120
  if (focus) document.querySelector(`[data-app-view="${view}"] .view-title`)?.focus({ preventScroll: true });
115
121
  window.requestAnimationFrame(() => viewerStates?.forEach?.((_state, id) => applyViewer(id)));
@@ -121,20 +127,31 @@ document.querySelectorAll("[data-view-target]").forEach((control) => {
121
127
  showAppView(control.dataset.viewTarget, { push: currentView !== control.dataset.viewTarget, focus: true });
122
128
  });
123
129
  });
124
- document.querySelector("#menu-toggle")?.addEventListener("click", () => {
125
- const open = !document.body.classList.contains("navigation-open");
126
- document.body.classList.toggle("navigation-open", open);
127
- document.querySelector("#menu-toggle").setAttribute("aria-expanded", String(open));
128
- if (open) document.querySelector("#primary-navigation [aria-current='page']")?.focus();
130
+
131
+ function selectSettingsSection(id, { scroll = true, focus = true } = {}) {
132
+ const section = document.getElementById(id);
133
+ if (!section) return;
134
+ section.open = true;
135
+ document.querySelectorAll("[data-settings-target]").forEach((button) => {
136
+ if (button.dataset.settingsTarget === id) button.setAttribute("aria-current", "true");
137
+ else button.removeAttribute("aria-current");
138
+ });
139
+ if (scroll) section.scrollIntoView({ block: "start", behavior: "smooth" });
140
+ if (focus) window.setTimeout(() => section.querySelector("summary")?.focus({ preventScroll: true }), 0);
141
+ }
142
+
143
+ document.querySelectorAll("[data-settings-target]").forEach((button) => {
144
+ button.addEventListener("click", () => selectSettingsSection(button.dataset.settingsTarget));
129
145
  });
130
- document.querySelector("#navigation-scrim")?.addEventListener("click", closeNavigation);
131
- document.addEventListener("keydown", (event) => {
132
- if (event.key === "Escape" && document.body.classList.contains("navigation-open")) {
133
- closeNavigation();
134
- document.querySelector("#menu-toggle")?.focus();
135
- }
146
+ document.querySelectorAll(".settings-section").forEach((section) => {
147
+ section.addEventListener("toggle", () => {
148
+ if (!section.open) return;
149
+ document.querySelectorAll("[data-settings-target]").forEach((button) => {
150
+ if (button.dataset.settingsTarget === section.id) button.setAttribute("aria-current", "true");
151
+ else button.removeAttribute("aria-current");
152
+ });
153
+ });
136
154
  });
137
-
138
155
  function selectJobDetailTab(name, { focus = false, push = false } = {}) {
139
156
  const button = document.querySelector(`[data-job-tab="${name}"]`);
140
157
  if (!button) return;
@@ -231,14 +248,16 @@ function updateDraftSummary() {
231
248
  const resolvedLayout = resolvedSummary
232
249
  ? `${resolvedSummary.tile_rows}行 × ${resolvedSummary.tile_columns}列`
233
250
  : layoutLabels[values.tile_layout] || values.tile_layout;
251
+ const zPartCount = resolvedSummary?.z_plate_count || resolvedStandardZPartCount(values.color_region_parts);
234
252
  const z = separateZParts?.checked
235
- ? `色領域${values.color_region_parts} + Top White(${resolvedSummary?.z_plate_count || Number(values.color_region_parts) + 1}プレート)`
253
+ ? `連続Z分割(${zPartCount}プレート・CMYW 8層・${zPartBaseWhiteSummary(zPartCount)}・最小${minimumZPlateThickness(zPartCount)} mm)`
236
254
  : "一体出力";
237
255
  const entries = [
238
256
  ["入力", fileInput.files?.[0]?.name || "未選択"],
239
257
  ["完成寸法", `${formatSetting(values.width)} × ${formatSetting(values.height)} mm`],
240
258
  ["セル", `XY ${formatSetting(values.pixel_size, " mm")} / 色 ${formatSetting(values.color_pixel_size, " mm")}`],
241
- ["画像", `${values.image_fit === "contain" ? "全体を収める" : "全面に配置"} / 明るさ ${values.brightness}% / コントラスト ${values.contrast}% / 彩度 ${values.saturation}%`],
259
+ ["画像", `${values.image_fit === "contain" ? "全体を収める" : "全面に配置"} / 明るさ ${values.brightness}% / コントラスト ${values.contrast}% / 彩度 ${values.saturation}% / 淡色White化 ${values.suppress_pale_colors ? "有効" : "無効"} / Yellow→${values.yellow_mix_color === "magenta" ? "Magenta" : "Cyan"} ${values.yellow_mix_percent}%`],
260
+ ["色積層", (values.pigment_order || "").split(",").map((pigment) => pigment[0]?.toUpperCase()).join("")],
242
261
  ["分割", `${values.tile_count}タイル・${resolvedLayout}(${layoutLabels[values.tile_layout] || values.tile_layout}) / ${z}`]
243
262
  ];
244
263
  summary.replaceChildren();
@@ -277,9 +296,24 @@ function updateDraftState(message) {
277
296
  else if (submittedSignature && submittedSignature !== settingsSignature()) state.textContent = "実行した設定から変更されています";
278
297
  else state.textContent = "入力プレビューに反映済み";
279
298
  updateDraftSummary();
299
+ updateStartAvailability();
280
300
  if (displayedJob) updateJobContext(displayedJob);
281
301
  }
282
302
 
303
+ function updateStartAvailability() {
304
+ if (!submitButton) return;
305
+ const hasFile = Boolean(fileInput.files?.[0]);
306
+ const previewFailed = hasFile && !stagePromise && !previewId;
307
+ submitButton.disabled = submitting || Boolean(activeJobId) || !hasFile || previewFailed;
308
+ let label = "変換を開始";
309
+ if (submitting) label = "変換を準備しています…";
310
+ else if (activeJobId) label = "変換中";
311
+ else if (!hasFile) label = "画像を選択してください";
312
+ else if (previewFailed) label = "画像を確認してください";
313
+ else if (stagePromise) label = "準備完了後に開始";
314
+ if (submitButton.textContent !== label) submitButton.textContent = label;
315
+ }
316
+
283
317
  function showFormError(message) {
284
318
  const error = document.querySelector("#form-error");
285
319
  error.textContent = message;
@@ -413,6 +447,7 @@ function stageFile(file, { render = true } = {}) {
413
447
  previewId = payload.id;
414
448
  previewIdInput.value = previewId;
415
449
  if (render) await renderPreview(previewId);
450
+ updateStartAvailability();
416
451
  })
417
452
  .catch((error) => {
418
453
  if (requestNumber === selectionSequence) {
@@ -422,8 +457,10 @@ function stageFile(file, { render = true } = {}) {
422
457
  })
423
458
  .finally(() => {
424
459
  if (stagePromise === promise) stagePromise = undefined;
460
+ updateStartAvailability();
425
461
  });
426
462
  stagePromise = promise;
463
+ updateStartAvailability();
427
464
  return promise;
428
465
  }
429
466
 
@@ -436,6 +473,7 @@ function selectFile(file) {
436
473
  window.clearTimeout(renderTimer);
437
474
  discardPreview(oldPreviewId);
438
475
  showFormError("");
476
+ updateStartAvailability();
439
477
  if (!file) {
440
478
  fileName.textContent = "ファイルをここへ選択";
441
479
  showSourceStatus("画像未選択");
@@ -504,24 +542,22 @@ function updateZFields() {
504
542
  field.hidden = !visible;
505
543
  input.disabled = !visible;
506
544
  input.required = visible;
545
+ const partCount = resolvedStandardZPartCount(input.value);
507
546
  document.querySelector("#z-parts-summary").textContent = visible
508
- ? `Base White付き色領域 ${input.value}プレート + Top White 1プレート`
547
+ ? `CMYW 8層・${zPartBaseWhiteSummary(partCount)}付き・各プレート最小${minimumZPlateThickness(partCount)} mm(実出力 ${partCount}プレート)`
509
548
  : "Z方向は一体のCMYWモデルとして出力します";
510
549
  const diagram = document.querySelector("#z-parts-diagram");
511
550
  diagram.replaceChildren();
512
551
  if (visible) {
513
- const partCount = Number(input.value);
514
- const size = Math.floor(7 / partCount);
515
- let remainder = 7 % partCount;
516
- let layer = 0;
552
+ const sourceWidth = 0.96 / partCount;
517
553
  const labels = Array.from({ length: partCount }, (_value, index) => {
518
- const count = size + (remainder-- > 0 ? 1 : 0);
519
- const start = 0.2 + (layer * 0.1);
520
- layer += count;
521
- const finish = 0.2 + (layer * 0.1);
522
- return `色領域 ${index + 1}\n${start.toFixed(1)}–${finish.toFixed(1)} mm`;
554
+ const start = 0.1 + (index * sourceWidth);
555
+ const finish = 0.1 + ((index + 1) * sourceWidth);
556
+ const endLabel = index === partCount - 1 ? `可変上面(最小 ${formatZMillimeters(finish)} mm)` : `${formatZMillimeters(finish)} mm`;
557
+ const baseWhite = zPartBaseWhiteThickness(partCount, index);
558
+ return `Zプレート ${index + 1}\nBase White ${formatZMillimeters(baseWhite)} mm + 元Z ${formatZMillimeters(start)} mm–${endLabel}`;
523
559
  });
524
- labels.concat("Top White\n0.9 mm–可変上面").forEach((label) => {
560
+ labels.forEach((label) => {
525
561
  const part = document.createElement("span");
526
562
  part.textContent = label;
527
563
  diagram.append(part);
@@ -530,6 +566,31 @@ function updateZFields() {
530
566
  diagram.hidden = !visible;
531
567
  }
532
568
 
569
+ function resolvedStandardZPartCount(requested) {
570
+ return Math.min(Math.max(Number(requested) || 1, 1), 3);
571
+ }
572
+
573
+ function minimumZPlateThickness(partCount) {
574
+ const lastSourceStart = 0.1 + ((partCount - 1) * (0.96 / partCount));
575
+ return formatZMillimeters((0.82 - lastSourceStart) + zPartBaseWhiteThickness(partCount, partCount - 1));
576
+ }
577
+
578
+ function zPartBaseWhiteLayers(partCount, index) {
579
+ return partCount === 3 && index === 2 ? 3 : 1;
580
+ }
581
+
582
+ function zPartBaseWhiteThickness(partCount, index) {
583
+ return 0.1 + ((zPartBaseWhiteLayers(partCount, index) - 1) * 0.08);
584
+ }
585
+
586
+ function zPartBaseWhiteSummary(partCount) {
587
+ return partCount === 3 ? "Base White 1層(Zプレート3は3層)" : "Base White 1層";
588
+ }
589
+
590
+ function formatZMillimeters(value) {
591
+ return value.toFixed(3).replace(/0+$/, "").replace(/\.$/, "");
592
+ }
593
+
533
594
  function updateDimensions() {
534
595
  const width = Number(document.querySelector("#width").value);
535
596
  const height = Number(document.querySelector("#height").value);
@@ -546,6 +607,7 @@ updateGridFields();
546
607
  updateZFields();
547
608
  updateDimensions();
548
609
  updateDraftSummary();
610
+ updateStartAvailability();
549
611
  refreshResolvedSummary();
550
612
 
551
613
  const widthInput = document.querySelector("#width");
@@ -599,23 +661,23 @@ form?.addEventListener("input", (event) => {
599
661
  }
600
662
  });
601
663
  form?.addEventListener("change", (event) => {
602
- if (event.target !== fileInput) {
664
+ const alreadyHandledByInput = ["number", "range", "text"].includes(event.target.type);
665
+ if (event.target !== fileInput && !alreadyHandledByInput) {
603
666
  schedulePreview();
604
667
  scheduleTileOverlay();
605
668
  updateDraftState();
606
669
  }
607
670
  });
608
671
  form?.addEventListener("invalid", (event) => {
609
- event.target.closest("details")?.setAttribute("open", "");
672
+ const section = event.target.closest("details.settings-section");
673
+ if (section) selectSettingsSection(section.id, { scroll: false, focus: false });
610
674
  const label = event.target.closest("label")?.childNodes?.[0]?.textContent?.trim();
611
675
  showFormError(`${label || "入力値"}を確認してください`);
612
676
  }, true);
613
677
 
614
678
  function setSubmitting(value) {
615
679
  submitting = value;
616
- const button = document.querySelector("#submit-button");
617
- button.disabled = value;
618
- button.textContent = value ? "変換を準備しています…" : "変換を開始";
680
+ updateStartAvailability();
619
681
  }
620
682
 
621
683
  form?.addEventListener("submit", async (event) => {
@@ -625,6 +687,9 @@ form?.addEventListener("submit", async (event) => {
625
687
  showFormError("");
626
688
  try {
627
689
  if (stagePromise) await stagePromise;
690
+ if (!previewId) throw new Error("画像の準備に失敗しました。画像を選び直してください");
691
+ window.clearTimeout(renderTimer);
692
+ await renderPreview(previewId);
628
693
  const data = new FormData(form);
629
694
  if (previewId) {
630
695
  data.delete("image");
@@ -673,7 +738,7 @@ function applySettings(values, sourceLabel) {
673
738
  const detail = differences.length
674
739
  ? differences.map((difference) => `${difference.label}: ${difference.before} → ${difference.after}`).join("\n")
675
740
  : "現在の設定との差はありません。";
676
- if (!window.confirm(`${sourceLabel}を編集へ読み込みます。\n\n${detail}`)) return false;
741
+ if (!window.confirm(`${sourceLabel}を作成に適用します。\n\n${detail}`)) return false;
677
742
  Object.entries(settingLabels).forEach(([key]) => {
678
743
  if (!(key in values)) return;
679
744
  const control = form.elements.namedItem(key);
@@ -694,7 +759,12 @@ function applySettings(values, sourceLabel) {
694
759
  document.querySelector("#settings-library-status").textContent = fileInput.files?.[0]
695
760
  ? `${sourceLabel}を反映しました。入力画像は変更していません。`
696
761
  : `${sourceLabel}を反映しました。画像は保存されないため、選択してください。`;
697
- showAppView("create", { push: currentView !== "create", focus: true });
762
+ showAppView("create", { push: currentView !== "create" });
763
+ const state = document.querySelector("#draft-state");
764
+ window.setTimeout(() => {
765
+ state.scrollIntoView({ block: "center" });
766
+ state.focus({ preventScroll: true });
767
+ }, 0);
698
768
  return true;
699
769
  }
700
770
 
@@ -715,10 +785,16 @@ function renderSettingsLibrary() {
715
785
  select.replaceChildren(new Option("プリセットを選択", ""));
716
786
  Object.keys(settingsLibrary.presets || {}).forEach((name) => select.add(new Option(name, name)));
717
787
  if (settingsLibrary.presets?.[selected]) select.value = selected;
788
+ updatePresetSelectionControls();
718
789
  document.querySelector("#draft-restore").disabled = !settingsLibrary.draft;
719
790
  document.querySelector("#draft-delete").disabled = !settingsLibrary.draft;
720
791
  }
721
792
 
793
+ function updatePresetSelectionControls() {
794
+ const selected = document.querySelector("#preset-select").value;
795
+ document.querySelector("#preset-load").disabled = !settingsLibrary.presets?.[selected];
796
+ }
797
+
722
798
  async function loadSettingsLibrary() {
723
799
  try {
724
800
  const response = await fetch("/api/settings-library", { cache: "no-store" });
@@ -732,6 +808,7 @@ async function loadSettingsLibrary() {
732
808
 
733
809
  document.querySelector("#preset-select")?.addEventListener("change", (event) => {
734
810
  document.querySelector("#preset-name").value = event.target.value;
811
+ updatePresetSelectionControls();
735
812
  });
736
813
  document.querySelector("#preset-save")?.addEventListener("click", async () => {
737
814
  try {
@@ -740,6 +817,7 @@ document.querySelector("#preset-save")?.addEventListener("click", async () => {
740
817
  values.set("name", name);
741
818
  await settingsLibraryRequest("/api/settings-library/presets", values);
742
819
  document.querySelector("#preset-select").value = name;
820
+ updatePresetSelectionControls();
743
821
  document.querySelector("#settings-library-status").textContent = `「${name}」へ現在設定を保存しました。画像は保存していません。`;
744
822
  } catch (error) { showFormError(error.message); }
745
823
  });
@@ -829,6 +907,7 @@ function updateGlobalJob(job) {
829
907
  const representedId = representedJob?.job_id || activeJobId || currentJobId;
830
908
  if (!representedId) {
831
909
  control.hidden = true;
910
+ updateStartAvailability();
832
911
  return;
833
912
  }
834
913
  const state = representedJob?.state || "queued";
@@ -841,6 +920,7 @@ function updateGlobalJob(job) {
841
920
  cancel.hidden = !["queued", "running", "cancelling"].includes(state);
842
921
  cancel.disabled = state === "cancelling";
843
922
  cancel.textContent = state === "cancelling" ? "停止要求中" : "停止";
923
+ updateStartAvailability();
844
924
  }
845
925
 
846
926
  function showJobPanel(jobId) {
@@ -947,7 +1027,7 @@ function renderTileModels(job) {
947
1027
  const previews = new Map(job.artifacts.map((artifact) => [artifact.name, artifact]));
948
1028
  const metadata = new Map((job.tiles || []).map((tile) => [tile.file, tile]));
949
1029
  const plateCount = job.z_part_separation?.enabled
950
- ? (job.z_part_separation.color_regions?.length || 0) + 1
1030
+ ? (job.z_part_separation.plates?.length || job.z_part_separation.resolved_parts || 0)
951
1031
  : 1;
952
1032
  list.replaceChildren();
953
1033
  section.querySelector(".tile-map")?.remove();
@@ -1177,7 +1257,10 @@ function renderJob(job) {
1177
1257
  if (job.job_id === activeJobId) {
1178
1258
  activeJob = job;
1179
1259
  updateGlobalJob(job);
1180
- if (!active) activeJobId = undefined;
1260
+ if (!active) {
1261
+ activeJobId = undefined;
1262
+ updateStartAvailability();
1263
+ }
1181
1264
  } else if (!activeJobId) {
1182
1265
  updateGlobalJob(job);
1183
1266
  }
@@ -1249,7 +1332,10 @@ async function pollJob(jobId) {
1249
1332
  if (isActiveJob) {
1250
1333
  activeJob = job;
1251
1334
  updateGlobalJob(job);
1252
- if (!active) activeJobId = undefined;
1335
+ if (!active) {
1336
+ activeJobId = undefined;
1337
+ updateStartAvailability();
1338
+ }
1253
1339
  }
1254
1340
  if (jobId === currentJobId) renderJob(job);
1255
1341
  if (active && (isActiveJob || jobId === currentJobId)) pollTimer = window.setTimeout(() => pollJob(jobId), 900);
@@ -1318,9 +1404,15 @@ function updateHistorySelection() {
1318
1404
  const selected = [...selectedJobIds].map((id) => historyJobs.get(id)).filter(Boolean);
1319
1405
  const completed = selected.filter((job) => job.state === "completed");
1320
1406
  const final = selected.filter((job) => ["completed", "failed", "cancelled"].includes(job.state));
1407
+ const selectable = [...historyJobs.values()].filter((job) => ["completed", "failed", "cancelled"].includes(job.state));
1408
+ const allSelected = selectable.length > 0 && selectable.every((job) => selectedJobIds.has(job.job_id));
1321
1409
  const size = selected.reduce((sum, job) => sum + Number(job.size_bytes || 0), 0);
1322
1410
  document.querySelector("#history-selection-status").textContent = selected.length
1323
1411
  ? `${selected.length}件選択・${formatBytes(size)}` : (historyNotice || "未選択");
1412
+ const selectAll = document.querySelector("#select-all-jobs");
1413
+ selectAll.disabled = selectable.length === 0;
1414
+ selectAll.setAttribute("aria-pressed", String(allSelected));
1415
+ selectAll.textContent = allSelected ? "全ての選択を解除する" : "全てを選択する";
1324
1416
  document.querySelector("#compare-jobs").disabled = selected.length !== 2 || completed.length !== 2;
1325
1417
  document.querySelector("#delete-selected-jobs").disabled = selected.length === 0 || final.length !== selected.length;
1326
1418
  }
@@ -1446,6 +1538,19 @@ document.querySelector("#history-query")?.addEventListener("input", () => {
1446
1538
  historySearchTimer = window.setTimeout(() => refreshHistory(), 250);
1447
1539
  });
1448
1540
  document.querySelector("#load-more-jobs")?.addEventListener("click", () => refreshHistory({ append: true }));
1541
+ document.querySelector("#select-all-jobs")?.addEventListener("click", () => {
1542
+ historyNotice = undefined;
1543
+ const selectable = [...historyJobs.values()].filter((job) => ["completed", "failed", "cancelled"].includes(job.state));
1544
+ const allSelected = selectable.length > 0 && selectable.every((job) => selectedJobIds.has(job.job_id));
1545
+ selectable.forEach((job) => {
1546
+ if (allSelected) selectedJobIds.delete(job.job_id);
1547
+ else selectedJobIds.add(job.job_id);
1548
+ });
1549
+ document.querySelectorAll("#job-list .history-select").forEach((checkbox) => {
1550
+ checkbox.checked = selectedJobIds.has(checkbox.dataset.jobId);
1551
+ });
1552
+ updateHistorySelection();
1553
+ });
1449
1554
 
1450
1555
  document.querySelector("#delete-selected-jobs")?.addEventListener("click", async () => {
1451
1556
  const jobs = [...selectedJobIds].map((id) => historyJobs.get(id)).filter(Boolean);
@@ -1571,7 +1676,6 @@ document.querySelector("#close-job-compare")?.addEventListener("click", () => do
1571
1676
  const job = comparedJobs[index];
1572
1677
  if (job && applySettings(job, `ジョブ ${job.job_id} の設定`)) {
1573
1678
  document.querySelector("#job-compare-dialog").close();
1574
- document.querySelector("#conversion-form").scrollIntoView({ block: "start" });
1575
1679
  }
1576
1680
  });
1577
1681
  });
@@ -1622,9 +1726,7 @@ document.querySelector("#choose-another-image")?.addEventListener("click", () =>
1622
1726
  });
1623
1727
  document.querySelector("#review-settings")?.addEventListener("click", () => {
1624
1728
  showAppView("create", { push: currentView !== "create" });
1625
- const settings = document.querySelector(".settings-section");
1626
- settings.open = true;
1627
- settings.scrollIntoView({ block: "start" });
1729
+ selectSettingsSection("settings-basic", { focus: false });
1628
1730
  document.querySelector("#width")?.focus();
1629
1731
  });
1630
1732
  document.querySelector("#retry-status-button")?.addEventListener("click", () => {
@@ -6,12 +6,13 @@ module Lithop
6
6
  class SettingsForm
7
7
  FLOAT_FIELDS = %i[
8
8
  width height margin_top margin_right margin_bottom margin_left crop_position_x crop_position_y
9
- crop_scale brightness contrast saturation minimum_thickness maximum_thickness pixel_size
9
+ crop_scale brightness contrast saturation yellow_mix_percent minimum_thickness maximum_thickness pixel_size
10
10
  color_pixel_size nozzle_diameter
11
11
  ].freeze
12
12
  INTEGER_FIELDS = %i[tile_count tile_rows tile_columns color_region_parts].freeze
13
- STRING_FIELDS = %i[image_fit tile_layout].freeze
14
- FIELD_NAMES = (FLOAT_FIELDS + INTEGER_FIELDS + STRING_FIELDS + [:separate_z_parts]).map(&:to_s).freeze
13
+ STRING_FIELDS = %i[image_fit tile_layout yellow_mix_color pigment_order].freeze
14
+ BOOLEAN_FIELDS = %i[suppress_pale_colors separate_z_parts].freeze
15
+ FIELD_NAMES = (FLOAT_FIELDS + INTEGER_FIELDS + STRING_FIELDS + BOOLEAN_FIELDS).map(&:to_s).freeze
15
16
 
16
17
  attr_reader :defaults
17
18
 
@@ -27,7 +28,9 @@ module Lithop
27
28
  end
28
29
 
29
30
  def values(settings = defaults)
30
- settings.to_h.slice(*Settings::DEFAULTS.keys).transform_keys(&:to_s)
31
+ result = settings.to_h.slice(*Settings::DEFAULTS.keys).transform_keys(&:to_s)
32
+ result["pigment_order"] = settings.pigment_order.join(",")
33
+ result
31
34
  end
32
35
 
33
36
  private
@@ -37,7 +40,7 @@ module Lithop
37
40
  FLOAT_FIELDS.each { |key| assign_number(values, params, key, Float) }
38
41
  INTEGER_FIELDS.each { |key| assign_number(values, params, key, Integer) }
39
42
  STRING_FIELDS.each { |key| values[key] = params[key.to_s] if present?(params[key.to_s]) }
40
- values[:separate_z_parts] = params["separate_z_parts"] == "1"
43
+ BOOLEAN_FIELDS.each { |key| values[key] = params[key.to_s] == "1" }
41
44
  clear_unused_grid(values)
42
45
  values
43
46
  rescue ArgumentError