lithop 0.2.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 (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -0
  3. data/Cargo.lock +284 -0
  4. data/Cargo.toml +6 -0
  5. data/README.md +34 -13
  6. data/RELEASING.md +24 -6
  7. data/docs/README.md +6 -3
  8. data/docs/artifacts.md +35 -16
  9. data/docs/bambu-studio.md +17 -11
  10. data/docs/calibration.md +4 -4
  11. data/docs/color-model.md +20 -18
  12. data/docs/command.md +53 -14
  13. data/docs/config.md +43 -31
  14. data/docs/getting-started.md +17 -8
  15. data/docs/images/cmyw-lithophane-preview.png +0 -0
  16. data/docs/local-ui.md +117 -0
  17. data/docs/tiling.md +20 -19
  18. data/docs/troubleshooting.md +12 -2
  19. data/ext/lithop_native/Cargo.toml +17 -0
  20. data/ext/lithop_native/build.rs +4 -0
  21. data/ext/lithop_native/extconf.rb +6 -0
  22. data/ext/lithop_native/src/column_mesh.rs +388 -0
  23. data/ext/lithop_native/src/lib.rs +37 -0
  24. data/ext/lithop_native/src/mesh.rs +187 -0
  25. data/ext/lithop_native/src/palette.rs +67 -0
  26. data/ext/lithop_native/src/xml.rs +134 -0
  27. data/lib/lithop/calibration/export.rb +2 -2
  28. data/lib/lithop/calibration/target.rb +5 -5
  29. data/lib/lithop/cli/image_adjustment_options.rb +33 -0
  30. data/lib/lithop/cli/image_options.rb +5 -18
  31. data/lib/lithop/cli/options.rb +17 -4
  32. data/lib/lithop/cli.rb +28 -0
  33. data/lib/lithop/export/color_metadata.rb +46 -23
  34. data/lib/lithop/export/generation.rb +38 -27
  35. data/lib/lithop/export/job.rb +2 -2
  36. data/lib/lithop/export/metadata.rb +8 -3
  37. data/lib/lithop/export/planning.rb +82 -45
  38. data/lib/lithop/export/previews.rb +25 -1
  39. data/lib/lithop/export/print_readme.rb +20 -4
  40. data/lib/lithop/formats/three_mf/bambu_metadata.rb +11 -8
  41. data/lib/lithop/formats/three_mf/mesh.rb +72 -3
  42. data/lib/lithop/formats/three_mf/model_writer.rb +21 -21
  43. data/lib/lithop/formats/three_mf/part_writer.rb +37 -0
  44. data/lib/lithop/formats/three_mf.rb +1 -1
  45. data/lib/lithop/image/pipeline.rb +84 -31
  46. data/lib/lithop/image/svg_loader.rb +16 -7
  47. data/lib/lithop/job_execution.rb +132 -4
  48. data/lib/lithop/layer_structure.rb +0 -9
  49. data/lib/lithop/mesh/column_geometry.rb +8 -0
  50. data/lib/lithop/mesh/column_topology.rb +2 -0
  51. data/lib/lithop/model/filament_profile.rb +3 -3
  52. data/lib/lithop/model/material_display_map.rb +2 -1
  53. data/lib/lithop/model/material_model.rb +17 -5
  54. data/lib/lithop/model/palette.rb +21 -3
  55. data/lib/lithop/model/z_region_model.rb +44 -0
  56. data/lib/lithop/native.rb +39 -0
  57. data/lib/lithop/settings.rb +147 -15
  58. data/lib/lithop/settings_file.rb +1 -0
  59. data/lib/lithop/tiling.rb +3 -2
  60. data/lib/lithop/version.rb +1 -1
  61. data/lib/lithop/web/app.rb +517 -0
  62. data/lib/lithop/web/bambu_studio_launcher.rb +82 -0
  63. data/lib/lithop/web/job_launcher.rb +214 -0
  64. data/lib/lithop/web/job_store.rb +390 -0
  65. data/lib/lithop/web/preview_store.rb +182 -0
  66. data/lib/lithop/web/public/app.css +330 -0
  67. data/lib/lithop/web/public/app.js +1949 -0
  68. data/lib/lithop/web/server.rb +21 -0
  69. data/lib/lithop/web/settings_form.rb +67 -0
  70. data/lib/lithop/web/settings_library.rb +154 -0
  71. data/lib/lithop/web/views/index.erb +418 -0
  72. data/lib/lithop/web/worker.rb +27 -0
  73. data/lib/lithop.rb +3 -1
  74. data/sig/lithop.rbs +41 -0
  75. metadata +86 -3
  76. data/lib/lithop/model/layer_region_model.rb +0 -24
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+ require "securerandom"
7
+ require "time"
8
+ require_relative "job_launcher"
9
+
10
+ module Lithop
11
+ module Web
12
+ # Owns short-lived input files used for pre-conversion browser previews.
13
+ # rubocop:disable-next Metrics/ClassLength
14
+ class PreviewStore
15
+ DIRECTORY = ".previews"
16
+ MAXIMUM_AGE = 24 * 60 * 60
17
+ MAXIMUM_CACHE_FILES = 8
18
+ MAXIMUM_CACHE_BYTES = 32 * 1024 * 1024
19
+ ID_PATTERN = /\A[0-9a-f]{32}\z/
20
+ CACHE_KEY_PATTERN = /\A[0-9a-f]{64}\z/
21
+
22
+ def initialize(project_dir:, random: SecureRandom, clock: Time)
23
+ @root = File.join(File.expand_path(project_dir), "build", DIRECTORY)
24
+ @random = random
25
+ @clock = clock
26
+ @mutex = Mutex.new
27
+ @preview_locks = {}
28
+ recover
29
+ end
30
+
31
+ def stage(upload:, filename:)
32
+ extension = validate_extension(filename)
33
+ @mutex.synchronize do
34
+ validate_root!
35
+ preview_id = unique_id
36
+ directory = File.join(@root, preview_id)
37
+ Dir.mkdir(directory, 0o700)
38
+ input_path = File.join(directory, "input#{extension}")
39
+ upload.rewind if upload.respond_to?(:rewind)
40
+ File.open(input_path, "wb", 0o600) { |file| IO.copy_stream(upload, file) }
41
+ File.write(
42
+ File.join(directory, "preview.json"),
43
+ JSON.generate(id: preview_id, filename: File.basename(filename.to_s), created_at: timestamp) << "\n"
44
+ )
45
+ preview_id
46
+ rescue StandardError
47
+ FileUtils.rm_r(directory) if defined?(directory) && File.directory?(directory) && !File.symlink?(directory)
48
+ raise
49
+ end
50
+ end
51
+
52
+ def with_input(preview_id)
53
+ lock_for(preview_id).synchronize do
54
+ input_path, filename, = resolve_input(preview_id)
55
+ yield input_path, filename
56
+ end
57
+ rescue JSON::ParserError, KeyError, SystemCallError
58
+ raise InputError, "preview input not found"
59
+ end
60
+
61
+ def render(preview_id, cache_key)
62
+ raise InputError, "invalid preview cache key" unless cache_key.to_s.match?(CACHE_KEY_PATTERN)
63
+
64
+ lock_for(preview_id).synchronize do
65
+ input_path, filename, directory = resolve_input(preview_id)
66
+ cache_path = File.join(directory, "render-#{cache_key}.png")
67
+ if File.file?(cache_path) && !File.symlink?(cache_path)
68
+ File.utime(@clock.now, @clock.now, cache_path)
69
+ return [File.binread(cache_path), true]
70
+ end
71
+
72
+ contents = yield input_path, filename
73
+ temporary = File.join(directory, ".render-#{cache_key}-#{SecureRandom.hex(6)}.tmp")
74
+ File.binwrite(temporary, contents)
75
+ File.rename(temporary, cache_path)
76
+ prune_cache(directory, keep: cache_path)
77
+ [contents, false]
78
+ ensure
79
+ FileUtils.rm_f(temporary) if defined?(temporary) && temporary
80
+ end
81
+ rescue JSON::ParserError, KeyError, SystemCallError
82
+ raise InputError, "preview input not found"
83
+ end
84
+
85
+ def discard(preview_id)
86
+ lock_for(preview_id).synchronize do
87
+ directory = directory_for(preview_id)
88
+ return false unless safe_directory?(directory)
89
+
90
+ FileUtils.rm_r(directory)
91
+ true
92
+ end
93
+ end
94
+
95
+ private
96
+
97
+ def lock_for(preview_id)
98
+ directory_for(preview_id)
99
+ @mutex.synchronize { @preview_locks[preview_id] ||= Mutex.new }
100
+ end
101
+
102
+ def resolve_input(preview_id)
103
+ directory = directory_for(preview_id)
104
+ raise InputError, "preview input not found" unless safe_directory?(directory)
105
+
106
+ metadata = JSON.parse(File.read(File.join(directory, "preview.json")))
107
+ extension = validate_extension(metadata.fetch("filename"))
108
+ input_path = File.join(directory, "input#{extension}")
109
+ raise InputError, "preview input not found" if File.symlink?(input_path) || !File.file?(input_path)
110
+
111
+ [input_path, metadata.fetch("filename"), directory]
112
+ end
113
+
114
+ def prune_cache(directory, keep:)
115
+ paths = Dir.glob("render-*.png", base: directory).map { |name| File.join(directory, name) }
116
+ paths.select! { |path| File.file?(path) && !File.symlink?(path) }
117
+ total = paths.sum { |path| File.size(path) }
118
+ remaining = paths.length
119
+ paths.sort_by { |path| File.mtime(path) }.each do |path|
120
+ break if remaining <= MAXIMUM_CACHE_FILES && total <= MAXIMUM_CACHE_BYTES
121
+ next if path == keep
122
+
123
+ total -= File.size(path)
124
+ FileUtils.rm_f(path)
125
+ remaining -= 1
126
+ end
127
+ end
128
+
129
+ def validate_root!
130
+ build_dir = File.dirname(@root)
131
+ raise Error, "refusing to use a symbolic build directory" if File.symlink?(build_dir)
132
+ raise Error, "refusing to use a symbolic preview directory" if File.symlink?(@root)
133
+
134
+ FileUtils.mkdir_p(@root)
135
+ end
136
+
137
+ def validate_extension(filename)
138
+ extension = File.extname(filename.to_s).downcase
139
+ return extension if JobLauncher::ALLOWED_EXTENSIONS.include?(extension)
140
+
141
+ raise InputError, "image must be a JPEG, PNG, or SVG file"
142
+ end
143
+
144
+ def directory_for(preview_id)
145
+ raise InputError, "invalid preview ID" unless preview_id.to_s.match?(ID_PATTERN)
146
+
147
+ File.join(@root, preview_id)
148
+ end
149
+
150
+ def safe_directory?(directory)
151
+ File.directory?(directory) && !File.symlink?(directory) && File.dirname(directory) == @root
152
+ end
153
+
154
+ def unique_id
155
+ 10.times do
156
+ candidate = @random.hex(16)
157
+ return candidate if candidate.match?(ID_PATTERN) && !File.exist?(File.join(@root, candidate))
158
+ end
159
+ raise Error, "could not allocate preview input"
160
+ end
161
+
162
+ def recover
163
+ return unless File.directory?(@root) && !File.symlink?(@root)
164
+
165
+ cutoff = @clock.now - MAXIMUM_AGE
166
+ Dir.children(@root).each do |name|
167
+ next unless name.match?(ID_PATTERN)
168
+
169
+ directory = File.join(@root, name)
170
+ next unless safe_directory?(directory)
171
+ next unless File.mtime(directory) < cutoff
172
+
173
+ FileUtils.rm_r(directory)
174
+ end
175
+ rescue SystemCallError
176
+ nil
177
+ end
178
+
179
+ def timestamp = @clock.now.utc.iso8601(6)
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,330 @@
1
+ :root {
2
+ color-scheme: light;
3
+ --ink: #14231d;
4
+ --muted: #617068;
5
+ --paper: #f3f1e9;
6
+ --panel: #fffdf8;
7
+ --line: #d9d7cd;
8
+ --cyan: #0086d6;
9
+ --magenta: #ec008c;
10
+ --yellow: #f4d800;
11
+ --green: #1f6d50;
12
+ --danger: #a83131;
13
+ --site-header-height: 116px;
14
+ font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
15
+ }
16
+
17
+ * { box-sizing: border-box; }
18
+ html { scroll-behavior: smooth; }
19
+ body {
20
+ margin: 0;
21
+ color: var(--ink);
22
+ background:
23
+ radial-gradient(circle at 12% 2%, rgba(0, 134, 214, 0.12), transparent 28rem),
24
+ radial-gradient(circle at 88% 4%, rgba(236, 0, 140, 0.09), transparent 26rem),
25
+ var(--paper);
26
+ }
27
+
28
+ button, input, select { font: inherit; }
29
+ button, summary, select, input[type="range"] { touch-action: manipulation; }
30
+ button:focus-visible, input:focus-visible, select:focus-visible, summary:focus-visible, a:focus-visible, .file-picker:focus-visible {
31
+ outline: 3px solid rgba(0, 134, 214, 0.35);
32
+ outline-offset: 2px;
33
+ }
34
+
35
+ .site-header { position: sticky; top: 0; z-index: 20; border-bottom: 1px solid rgba(217,215,205,.9); background: rgba(243,241,233,.94); box-shadow: 0 8px 28px rgba(38,48,42,.06); backdrop-filter: blur(14px); }
36
+ .site-header-main { display: flex; align-items: center; gap: 14px; min-height: 64px; padding: 9px max(20px, calc((100vw - 1800px) / 2)); }
37
+ .brand-block { display: grid; gap: 1px; min-width: 128px; }
38
+ .brand { color: var(--ink); font: 500 1.8rem/.9 Georgia,"Times New Roman",serif; letter-spacing: -.045em; text-decoration: none; }
39
+ .eyebrow, .step { margin: 0 0 10px; color: var(--green); font-size: 0.72rem; font-weight: 800; letter-spacing: 0.17em; }
40
+ .brand-block .eyebrow { margin: 0; font-size: .57rem; }
41
+ .header-context { display: flex; align-items: center; gap: 8px; min-width: 0; color: var(--muted); font-size: .76rem; }
42
+ .header-context .project-name { overflow: hidden; max-width: 220px; text-overflow: ellipsis; white-space: nowrap; }
43
+ .header-context strong { color: var(--ink); }
44
+ .global-job-control { display: flex; align-items: center; gap: 4px; margin-left: auto; border: 1px solid var(--line); border-radius: 11px; padding: 3px; background: rgba(255,255,255,.72); }
45
+ .header-action { border: 0; border-radius: 8px; padding: 8px 10px; color: var(--ink); background: transparent; cursor: pointer; font-size: .74rem; font-weight: 800; }
46
+ .header-action:hover { background: #e6e9e3; }
47
+ #global-job-link { display: flex; align-items: baseline; gap: 8px; }
48
+ #global-job-link small { color: var(--muted); font: .68rem ui-monospace,monospace; }
49
+ .local-badge, .state-pill, .dimension-badge { border: 1px solid var(--line); border-radius: 999px; padding: 8px 13px; background: rgba(255, 255, 255, 0.72); font: 700 0.72rem ui-monospace, SFMono-Regular, Menlo, monospace; }
50
+ .context-badge { border-radius: 999px; padding: 7px 12px; color: var(--green); background: #e8f2ec; font-size: 0.72rem; font-weight: 800; }
51
+
52
+ .workspace { width: min(1800px, calc(100% - 40px)); margin-inline: auto; padding: 20px 0 80px; }
53
+ .app-shell { min-width: 0; }
54
+ .app-content, .app-view { min-width: 0; }
55
+ .view-title { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
56
+ .primary-navigation { display: flex; gap: 4px; overflow-x: auto; padding: 0 max(20px, calc((100vw - 1800px) / 2)); scrollbar-width: thin; }
57
+ .primary-navigation button { flex: none; display: flex; align-items: baseline; gap: 7px; border: 0; border-bottom: 3px solid transparent; border-radius: 7px 7px 0 0; padding: 10px 14px 9px; color: var(--ink); background: transparent; cursor: pointer; }
58
+ .primary-navigation button span { color: var(--muted); font: .66rem ui-monospace,monospace; }
59
+ .primary-navigation button strong { font-size: .8rem; }
60
+ .primary-navigation button small { color: var(--muted); font-size: .68rem; font-weight: 500; }
61
+ .primary-navigation button:hover { background: rgba(255,255,255,.62); }
62
+ .primary-navigation button[aria-current="page"] { border-bottom-color: var(--green); background: rgba(255,255,255,.72); }
63
+ .workspace-primary { display: grid; grid-template-columns: minmax(360px,400px) minmax(0,1fr); align-items: start; gap: 18px; }
64
+ .jobs-workspace { display: grid; grid-template-columns: minmax(300px,350px) minmax(0,1fr); grid-template-areas: "history detail"; align-items: start; gap: 18px; }
65
+ .presets-workspace { display: grid; grid-template-columns: minmax(240px,300px) minmax(0,880px); align-items: start; gap: 18px; }
66
+ .jobs-workspace .history-panel { grid-area: history; position: sticky; top: calc(var(--site-header-height) + 20px); max-height: calc(100vh - var(--site-header-height) - 40px); overflow: auto; }
67
+ .preset-list-panel { position: sticky; top: calc(var(--site-header-height) + 20px); }
68
+ .jobs-workspace .job-panel { grid-area: detail; }
69
+ .panel { min-width: 0; border: 1px solid var(--line); border-radius: 22px; background: rgba(255, 253, 248, 0.96); box-shadow: 0 18px 50px rgba(38, 48, 42, 0.07); padding: clamp(20px, 3vw, 34px); }
70
+ .preview-panel { position: sticky; top: calc(var(--site-header-height) + 20px); }
71
+ .notice { padding: 13px 16px; border-radius: 12px; }
72
+ .notice.error { color: var(--danger); background: #fff0ed; border: 1px solid #efc0b8; }
73
+ .notice.info { color: #405049; background: #eef4f0; border: 1px solid #cbd9d0; }
74
+
75
+ .section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 22px; }
76
+ .section-heading h2 { margin: 0; font: 500 clamp(1.6rem, 3vw, 2.3rem) Georgia, serif; }
77
+ .section-heading > p { margin: 4px 0 0; color: var(--muted); font-size: 0.82rem; text-align: right; }
78
+ .compact-heading { align-items: center; }
79
+
80
+ .file-picker { display: grid; gap: 6px; min-height: 104px; place-content: center; text-align: center; border: 1.5px dashed #94aaa0; border-radius: 16px; background: #f7faf7; cursor: pointer; transition: border-color 150ms, background 150ms, transform 150ms; }
81
+ .file-picker:hover, .file-picker.is-dragging { border-color: var(--green); background: #edf7f0; }
82
+ .file-picker.is-dragging { transform: scale(1.01); }
83
+ .file-picker input { position: absolute; inline-size: 1px; block-size: 1px; opacity: 0; }
84
+ .file-title { font-weight: 800; }
85
+ #file-name { color: var(--muted); font-size: 0.84rem; }
86
+
87
+ .conversion-actions { display: grid; gap: 9px; margin-top: 12px; border: 1px solid var(--line); border-radius: 14px; padding: 10px; background: rgba(255,253,248,.97); box-shadow: 0 10px 28px rgba(20,35,29,.11); }
88
+ .conversion-action-row { display: flex; align-items: center; gap: 14px; }
89
+ #draft-state { flex: 1; color: var(--muted); font-size: .76rem; line-height: 1.45; }
90
+ .conversion-actions .notice { margin: 0; }
91
+ .settings-navigation { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 14px; padding-bottom: 12px; border-bottom: 1px solid var(--line); }
92
+ .settings-navigation button { border: 1px solid var(--line); border-radius: 999px; padding: 7px 10px; color: var(--muted); background: white; cursor: pointer; font-size: .72rem; font-weight: 800; }
93
+ .settings-navigation button:hover { border-color: #aeb8b2; color: var(--ink); }
94
+ .settings-navigation button[aria-current="true"] { border-color: var(--green); color: var(--green); background: #edf7f0; }
95
+ .settings-section { scroll-margin-top: calc(var(--site-header-height) + 20px); margin-top: 12px; border: 1px solid var(--line); border-radius: 14px; background: rgba(255,255,255,0.5); }
96
+ .settings-section > summary { display: flex; align-items: baseline; justify-content: space-between; gap: 18px; padding: 15px 16px; cursor: pointer; list-style: none; font-weight: 800; }
97
+ .settings-section > summary::-webkit-details-marker { display: none; }
98
+ .settings-section > summary::after { content: "+"; order: 3; color: var(--green); font-size: 1.2rem; }
99
+ .settings-section[open] > summary::after { content: "−"; }
100
+ .settings-section summary small { margin-left: auto; color: var(--muted); font-weight: 500; }
101
+ .settings-body { padding: 0 16px 16px; border-top: 1px solid #ebe9e0; }
102
+ .form-grid, .adjustment-grid, .quad-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
103
+ .adjustment-grid { grid-template-columns: 1fr; }
104
+ .split-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
105
+ .form-grid > label, .quad-grid > label, .range-field { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 7px; margin-top: 12px; font-size: 0.84rem; }
106
+ .form-grid label span, .quad-grid label span, .range-field span { color: var(--muted); font-size: 0.72rem; }
107
+ input[type="number"], input[type="text"], input[type="search"], input[type="date"], select { grid-column: 1 / -1; width: 100%; min-height: 40px; border: 1px solid #c9cdc8; border-radius: 9px; padding: 7px 9px; color: var(--ink); background: white; }
108
+ select { appearance: none; padding-right: 40px; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='m1 1 5 5 5-5' fill='none' stroke='%23192a23' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 14px center; background-size: 12px 8px; }
109
+ input:invalid:not(:placeholder-shown) { border-color: var(--danger); }
110
+ .range-field .range-control { grid-column: 1; width: 100%; accent-color: var(--green); }
111
+ .range-field input[type="number"] { grid-column: 2; grid-row: 2; width: 92px; }
112
+ .check-label { display: flex !important; grid-template-columns: none !important; align-items: center !important; gap: 8px !important; }
113
+ .check-label input { width: 18px; height: 18px; accent-color: var(--green); }
114
+ .z-parts-toggle { grid-column: 1 / -1; }
115
+ .z-parts-diagram { display: flex; gap: 5px; min-height: 42px; margin-top: 9px; }
116
+ .z-parts-diagram span { display: grid; flex: 1; place-items: center; min-width: 0; border: 1px solid var(--line); border-radius: 7px; padding: 7px 4px; background: linear-gradient(#fff 0 36%,rgba(0,134,214,.2) 36% 58%,rgba(236,0,140,.18) 58% 79%,rgba(244,216,0,.28) 79%); font-size: 0.66rem; text-align: center; }
117
+ .z-parts-diagram span:last-child { background: linear-gradient(#fff 0 82%,#e8e8e4 82%); }
118
+ .inline-actions, .section-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: 10px; margin-top: 12px; }
119
+ .inline-actions .check-label { margin-right: auto; }
120
+ .text-button { border: 0; padding: 5px 2px; color: var(--green); background: transparent; cursor: pointer; font-size: 0.76rem; font-weight: 800; }
121
+ .danger-text { color: var(--danger); }
122
+ .settings-library { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 10px 14px; padding-top: 14px; }
123
+ .settings-library > label { display: grid; gap: 5px; color: var(--muted); font-size: .76rem; }
124
+ .preset-select-label { display: grid; gap: 6px; color: var(--muted); font-size: .76rem; }
125
+ .preset-apply-button { width: 100%; margin-top: 10px; }
126
+ .draft-apply { display: grid; gap: 6px; margin-top: 18px; border-top: 1px solid var(--line); padding-top: 16px; }
127
+ .draft-apply small { color: var(--muted); font-size: .72rem; line-height: 1.45; }
128
+ .draft-apply .secondary-button { margin-top: 3px; }
129
+ .library-actions { grid-column: 1 / -1; display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; }
130
+ .draft-actions { padding-top: 10px; border-top: 1px solid var(--line); }
131
+ .settings-library .fine-print { grid-column: 1 / -1; margin: 0; }
132
+ .preset-intro { color: var(--muted); font-size: .74rem; line-height: 1.5; }
133
+ .preset-panel .settings-library { margin-top: 18px; border-top: 1px solid var(--line); }
134
+ .draft-summary { border-top: 1px solid var(--line); padding: 9px 3px 1px; }
135
+ .draft-summary > summary { cursor: pointer; list-style: none; }
136
+ .draft-summary > summary::-webkit-details-marker { display: none; }
137
+ .draft-summary > summary::after { content: "詳細を見る"; color: var(--green); font-size: .7rem; font-weight: 800; }
138
+ .draft-summary[open] > summary::after { content: "詳細を閉じる"; }
139
+ .summary-heading { display: flex; justify-content: space-between; gap: 16px; font-size: 0.8rem; }
140
+ .summary-heading span { color: var(--muted); }
141
+ .summary-heading span[data-changed="true"] { color: #925c00; font-weight: 800; }
142
+ .draft-summary dl { display: grid; grid-template-columns: max-content minmax(0,1fr); gap: 6px 14px; margin: 12px 0 0; font-size: 0.74rem; }
143
+ .draft-summary dt { color: var(--muted); }
144
+ .draft-summary dd { overflow-wrap: anywhere; margin: 0; }
145
+
146
+ .primary-button, .secondary-button, .danger-button { border: 0; border-radius: 11px; padding: 12px 18px; font-weight: 800; cursor: pointer; }
147
+ .primary-button { color: white; background: var(--ink); }
148
+ .primary-button:hover { background: var(--green); }
149
+ .secondary-button { color: var(--ink); background: #e8ebe7; }
150
+ .danger-button { color: #8d2525; background: #f6e4e1; }
151
+ button:disabled { opacity: 0.5; cursor: wait; }
152
+
153
+ .preview-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-bottom: 9px; }
154
+ .preview-toolbar button { min-width: 34px; min-height: 34px; border: 1px solid var(--line); border-radius: 8px; color: var(--ink); background: white; cursor: pointer; }
155
+ .preview-toolbar output { min-width: 48px; text-align: center; color: var(--muted); font: 0.72rem ui-monospace, monospace; }
156
+ .preview-toolbar label { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: 0.72rem; }
157
+ .preview-toolbar label:first-of-type { margin-left: auto; }
158
+ .preview-toolbar .overlay-toggle input { accent-color: var(--green); }
159
+ .preview-toolbar select { min-height: 34px; padding: 3px 36px 3px 9px; background-position: right 12px center; }
160
+ .image-stage, .result-preview { position: relative; overflow: hidden; display: grid; place-items: center; border: 1px solid var(--line); border-radius: 15px; user-select: none; }
161
+ .image-stage, .result-preview { min-height: 0; aspect-ratio: 140 / 104; }
162
+ .preview-viewport[data-background="checker"], .comparison-source { background: linear-gradient(45deg,#ecebe5 25%,transparent 25%),linear-gradient(-45deg,#ecebe5 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#ecebe5 75%),linear-gradient(-45deg,transparent 75%,#ecebe5 75%),#faf9f4; background-position: 0 0,0 10px,10px -10px,-10px 0; background-size: 20px 20px; }
163
+ .preview-viewport[data-background="light"] { background: #fff; }
164
+ .preview-viewport[data-background="gray"] { background: #737873; }
165
+ .preview-viewport[data-background="dark"] { background: #19201c; }
166
+ .preview-viewport.is-pannable { cursor: grab; touch-action: none; }
167
+ .preview-viewport.is-panning { cursor: grabbing; }
168
+ .image-stage img, .result-preview img { display: block; width: 100%; height: 100%; object-fit: contain; transform-origin: center; will-change: transform; }
169
+ .image-stage .tile-overlay { position: absolute; inset: 0; z-index: 2; pointer-events: none; }
170
+ .image-stage p { color: var(--muted); font-size: 0.86rem; }
171
+ .fine-print { color: var(--muted); font-size: 0.76rem; line-height: 1.55; }
172
+
173
+ .job-meta { display: flex; justify-content: space-between; gap: 20px; }
174
+ .job-meta > div { min-width: 0; }
175
+ .job-view-name { overflow: hidden; margin: 0 0 4px; font-weight: 800; text-overflow: ellipsis; white-space: nowrap; }
176
+ .job-id { color: var(--muted); font: 0.8rem ui-monospace, SFMono-Regular, Menlo, monospace; }
177
+ .progress-track { overflow: hidden; height: 9px; border-radius: 999px; background: #e2e5df; }
178
+ .progress-track span { display: block; width: 0; height: 100%; background: linear-gradient(90deg,var(--cyan),var(--magenta),var(--yellow)); transition: width 220ms ease; }
179
+ .job-copy { display: flex; gap: 14px; align-items: baseline; margin: 12px 0 20px; }
180
+ .job-copy strong { font: 500 1.8rem Georgia, serif; }
181
+ .job-copy span { color: var(--muted); }
182
+ .job-actions { display: flex; gap: 10px; align-items: center; }
183
+ .back-to-job-list { display: none; margin-bottom: 12px; }
184
+ .job-detail-tabs { display: flex; gap: 5px; margin-top: 22px; border-bottom: 1px solid var(--line); }
185
+ .job-detail-tabs button { border: 0; border-bottom: 3px solid transparent; padding: 10px 13px; color: var(--muted); background: transparent; cursor: pointer; font-size: .78rem; font-weight: 800; }
186
+ .job-detail-tabs button[aria-selected="true"] { border-bottom-color: var(--green); color: var(--ink); }
187
+ .job-setting-details { display: grid; grid-template-columns: max-content minmax(0,1fr); gap: 8px 16px; font-size: .78rem; }
188
+ .job-setting-details dt { color: var(--muted); }
189
+ .job-setting-details dd { margin: 0; overflow-wrap: anywhere; }
190
+ .job-freshness { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; margin: -10px 0 16px; color: var(--muted); font-size: 0.74rem; }
191
+ #job-connection[data-connection="online"] { color: var(--green); }
192
+ #job-connection[data-connection="offline"] { color: var(--danger); font-weight: 800; }
193
+ #job-error p { margin: 0; }
194
+ .recovery-actions { display: flex; flex-wrap: wrap; gap: 14px; margin-top: 8px; }
195
+ .recovery-actions .text-button { color: var(--danger); }
196
+ .result-layout { margin-top: 26px; }
197
+ .result-tabs { display: flex; flex-wrap: wrap; gap: 7px; margin-bottom: 12px; }
198
+ .result-tabs button { border: 1px solid var(--line); border-radius: 999px; padding: 7px 11px; color: var(--ink); background: white; cursor: pointer; font-size: 0.76rem; }
199
+ .result-tabs button[aria-selected="true"] { color: white; border-color: var(--green); background: var(--green); }
200
+ .comparison-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 16px; }
201
+ .comparison-grid figure { min-width: 0; margin: 0; }
202
+ .comparison-grid figcaption { margin-bottom: 7px; color: var(--muted); font-size: 0.76rem; font-weight: 800; }
203
+ .artifact-section { margin-top: 24px; }
204
+ .artifact-heading { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
205
+ .artifact-heading > div { display: flex; flex-wrap: wrap; gap: 8px; }
206
+ .artifact-heading .secondary-button { padding: 8px 11px; font-size: .74rem; }
207
+ .tile-results { margin-top: 24px; }
208
+ .tile-result-list { display: grid; grid-template-columns: repeat(auto-fit,minmax(210px,1fr)); gap: 14px; }
209
+ .tile-result-card { min-width: 0; margin: 0; padding: 12px; border: 1px solid var(--line); border-radius: 15px; background: var(--paper); }
210
+ .tile-result-card:focus-visible, .tile-result-card.is-selected { outline: 3px solid rgba(0,134,214,.3); outline-offset: 2px; border-color: var(--cyan); }
211
+ .tile-result-card img { display: block; width: 100%; aspect-ratio: 140 / 104; object-fit: contain; border-radius: 10px; background: #f1f0eb; image-rendering: auto; }
212
+ .tile-result-card figcaption { display: grid; gap: 9px; margin-top: 9px; }
213
+ .tile-result-card strong { overflow: hidden; font: 700 0.78rem ui-monospace, monospace; text-overflow: ellipsis; white-space: nowrap; }
214
+ .tile-result-card a { flex: none; color: var(--green); font-size: 0.78rem; font-weight: 700; }
215
+ .tile-model-actions, .artifact-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; }
216
+ .tile-model-actions .bambu-open-button, .artifact-actions .bambu-open-button { padding: 7px 9px; font-size: 0.72rem; }
217
+ .artifact-status { min-height: 1.2em; margin: 8px 0 12px; }
218
+ .artifact-status[data-state="success"] { color: var(--green); }
219
+ .artifact-status[data-state="error"] { color: var(--danger); font-weight: 800; }
220
+ .tile-result-card > small { display: block; margin-top: 8px; color: var(--muted); font-size: 0.7rem; line-height: 1.5; }
221
+ .tile-result-card .tile-preview-missing { display: grid; place-items: center; width: 100%; aspect-ratio: 140 / 104; border-radius: 10px; background: #f1f0eb; color: var(--muted); font-size: 0.78rem; }
222
+ .tile-map { display: grid; gap: 5px; margin: 0 0 14px; border: 1px solid var(--line); border-radius: 12px; padding: 8px; background: #eeece4; }
223
+ .tile-map-row { display: grid; grid-auto-columns: minmax(0,1fr); grid-auto-flow: column; gap: 5px; }
224
+ .tile-map button { min-height: 48px; border: 1px solid #bfc4bd; border-radius: 7px; color: var(--ink); background: white; cursor: pointer; font: 700 0.72rem ui-monospace, monospace; }
225
+ .tile-map button.is-selected { color: white; border-color: var(--green); background: var(--green); }
226
+ .artifact-list, .job-list { list-style: none; margin: 0; padding: 0; }
227
+ .artifact-list { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 18px; }
228
+ .artifact-list li { min-width: 0; }
229
+ .artifact-list ul { list-style: none; margin: 8px 0 0; padding: 0; }
230
+ .artifact-list ul li { margin-bottom: 7px; }
231
+ .artifact-list strong { font-size: 0.78rem; }
232
+ .artifact-list a { display: block; overflow: hidden; min-width: 0; color: var(--green); font: 0.78rem ui-monospace, monospace; text-overflow: ellipsis; white-space: nowrap; }
233
+
234
+ .history-controls { display: grid; grid-template-columns: minmax(180px,2fr) repeat(4,minmax(130px,1fr)); gap: 10px; margin-bottom: 14px; }
235
+ .history-controls label { display: grid; gap: 4px; color: var(--muted); font-size: 0.72rem; }
236
+ .history-selection-toolbar { display: grid; gap: 9px; margin-bottom: 14px; border: 1px solid var(--line); border-radius: 12px; padding: 10px; background: #f8f7f1; }
237
+ .history-selection-summary { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 30px; }
238
+ .history-selection-summary span { min-width: 0; color: var(--muted); font-size: .76rem; }
239
+ .history-selection-summary .text-button { flex: none; padding: 6px 4px; }
240
+ .history-selection-actions { display: grid; grid-template-columns: minmax(0, .8fr) minmax(0, 1.2fr); gap: 8px; }
241
+ .history-selection-actions button { width: 100%; min-height: 38px; padding: 8px; font-size: .72rem; }
242
+ .job-list { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 10px; }
243
+ .job-list li { position: relative; display: grid; grid-template-columns: auto minmax(0,1fr); align-items: start; gap: 8px; min-width: 0; }
244
+ .job-list .history-select { width: 18px; height: 18px; margin-top: 13px; accent-color: var(--green); }
245
+ .job-list a { display: grid; gap: 5px; min-height: 105px; padding: 13px; border: 1px solid var(--line); border-radius: 10px; color: inherit; text-decoration: none; }
246
+ .job-list a:hover { border-color: #91a39a; }
247
+ .job-list strong, .job-list span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
248
+ .job-list strong { font-size: 0.86rem; }
249
+ .job-list span { color: var(--muted); font: 0.7rem ui-monospace, monospace; }
250
+ .job-list small, .empty-copy { color: var(--muted); }
251
+ .job-list .history-thumbnail { width: 100%; aspect-ratio: 140 / 104; object-fit: contain; border-radius: 6px; background: #f0efe9; }
252
+ .jobs-workspace .history-controls, .jobs-workspace .job-list { grid-template-columns: 1fr; }
253
+ .history-delete { grid-column: 2; margin-top: 6px; border: 0; padding: 3px 1px; color: #8d2525; background: transparent; cursor: pointer; font-size: 0.72rem; }
254
+ .load-more { display: block; margin: 18px auto 0; }
255
+ .preview-dialog { width: min(1320px, calc(100vw - 32px)); max-width: none; height: min(880px, calc(100vh - 32px)); max-height: none; border: 1px solid var(--line); border-radius: 20px; padding: 22px; color: var(--ink); background: var(--panel); box-shadow: 0 28px 90px rgba(10,20,15,.35); }
256
+ .preview-dialog::backdrop { background: rgba(16,25,21,.72); backdrop-filter: blur(4px); }
257
+ .dialog-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; }
258
+ .dialog-heading h2 { margin: 0 0 18px; font: 500 clamp(1.4rem,3vw,2rem) Georgia,serif; }
259
+ .dialog-preview-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 14px; }
260
+ .dialog-preview-grid figure { min-width: 0; margin: 0; }
261
+ .dialog-preview-grid figcaption { margin: 0 0 6px; color: var(--muted); font-size: .76rem; font-weight: 800; }
262
+ .dialog-preview-grid .result-preview { max-height: calc(100vh - 230px); }
263
+ .dialog-preview-grid.is-single { grid-template-columns: minmax(0,980px); justify-content: center; }
264
+ .job-compare-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 16px; }
265
+ .job-compare-grid figure { min-width: 0; margin: 0; }
266
+ .job-compare-grid figcaption { display: grid; gap: 3px; margin-bottom: 7px; }
267
+ .job-compare-grid figcaption strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
268
+ .job-compare-grid figcaption span { color: var(--muted); font: .72rem ui-monospace,monospace; }
269
+ .job-compare-grid .result-preview p { color: var(--muted); font-size: .8rem; }
270
+ .compare-settings { margin-top: 18px; }
271
+ .compare-settings h3 { margin-bottom: 8px; }
272
+ .table-scroll { overflow-x: auto; }
273
+ .compare-settings table { width: 100%; border-collapse: collapse; font-size: .76rem; }
274
+ .compare-settings th, .compare-settings td { padding: 8px; border-bottom: 1px solid var(--line); text-align: left; }
275
+ .compare-settings th:first-child { color: var(--muted); }
276
+ .help-dialog { width: min(560px,calc(100vw - 32px)); border: 1px solid var(--line); border-radius: 18px; padding: 24px; color: var(--ink); background: var(--panel); box-shadow: 0 28px 90px rgba(10,20,15,.35); }
277
+ .help-dialog::backdrop { background: rgba(16,25,21,.6); backdrop-filter: blur(3px); }
278
+ .help-dialog ol { display: grid; gap: 12px; padding-left: 22px; }
279
+ .help-dialog li span { display: block; margin-top: 3px; color: var(--muted); font-size: .8rem; line-height: 1.5; }
280
+ [hidden] { display: none !important; }
281
+
282
+ @media (max-width: 1439px) {
283
+ .workspace-primary { grid-template-columns: minmax(340px,.8fr) minmax(0,1.2fr); }
284
+ .jobs-workspace { grid-template-columns: minmax(290px,340px) minmax(0,1fr); }
285
+ }
286
+
287
+ @media (max-width: 900px) {
288
+ .workspace-primary { grid-template-columns: 1fr; }
289
+ .preview-panel { position: static; }
290
+ .presets-workspace { grid-template-columns: 1fr; }
291
+ .preset-list-panel { position: static; }
292
+ .jobs-workspace { grid-template-columns: 1fr; grid-template-areas: "history" "detail"; }
293
+ .jobs-workspace .history-panel { position: static; max-height: none; }
294
+ .workspace.has-job[data-view="jobs"]:not(.show-job-list) .jobs-workspace .history-panel { display: none; }
295
+ .workspace.show-job-list[data-view="jobs"] .jobs-workspace .job-panel { display: none; }
296
+ .back-to-job-list { display: inline-block; }
297
+ .jobs-workspace .job-list { grid-template-columns: repeat(2,minmax(0,1fr)); }
298
+ .job-compare-grid { grid-template-columns: 1fr; }
299
+ .history-controls { grid-template-columns: repeat(2,minmax(0,1fr)); }
300
+ .artifact-heading { align-items: flex-start; flex-direction: column; }
301
+ }
302
+
303
+ @media (max-width: 680px) {
304
+ .site-header-main { gap: 8px; padding-inline: 11px; }
305
+ .primary-navigation { padding-inline: 11px; }
306
+ .brand-block { min-width: 80px; }
307
+ .brand-block .eyebrow, .header-context .project-name, .header-context > span[aria-hidden], #help-button { display: none; }
308
+ .brand { font-size: 1.5rem; }
309
+ .header-context { margin-right: auto; }
310
+ .global-job-control { margin-left: 0; }
311
+ #global-job-link { max-width: 135px; }
312
+ #global-job-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
313
+ .workspace { width: min(100% - 22px,1800px); }
314
+ .local-badge { display: none; }
315
+ .form-grid, .quad-grid, .split-grid, .comparison-grid, .job-list, .job-compare-grid, .settings-library, .history-controls, .jobs-workspace .job-list { grid-template-columns: 1fr; }
316
+ .artifact-list { grid-template-columns: 1fr; }
317
+ .dialog-preview-grid { grid-template-columns: 1fr; }
318
+ .dialog-preview-grid .result-preview { max-height: calc((100vh - 250px) / 2); }
319
+ .draft-summary dl { grid-template-columns: 1fr; gap: 2px; }
320
+ .draft-summary dd { margin-bottom: 7px; }
321
+ .conversion-action-row { align-items: stretch; flex-direction: column; }
322
+ .conversion-action-row .primary-button { width: 100%; }
323
+ .settings-section summary small { display: none; }
324
+ .job-detail-tabs { overflow-x: auto; }
325
+ }
326
+
327
+ @media (prefers-reduced-motion: reduce) {
328
+ html { scroll-behavior: auto; }
329
+ *, *::before, *::after { transition-duration: 0.01ms !important; }
330
+ }