Merge pull request 'Colors and Filament Sync' (#1) from gcode-color-fix into ACE2
Reviewed-on: https://gitea.gangoke.app/gangoke/KX-Bridge-Release/pulls/1
This commit is contained in:
@@ -212,33 +212,91 @@ def _extract_thumbnail(data: bytes) -> str:
|
||||
|
||||
|
||||
def _extract_filament_info(data: bytes) -> list[dict]:
|
||||
"""Liest filament_colour + filament_type aus GCode-Header (OrcaSlicer/PrusaSlicer).
|
||||
"""Liest Filament-Farben/Materialien inkl. Tool-Reihenfolge aus Orca/Prusa-GCode.
|
||||
|
||||
Gibt Liste von {color_hex, material} pro Slot zurück, leer wenn nicht gefunden.
|
||||
Liest nur die ersten 8KB (Header-Bereich).
|
||||
"""
|
||||
Gibt Liste von {slot_index, color_hex, material} in Tool-/Paint-Reihenfolge
|
||||
(T0, T1, ...) zurück.
|
||||
Sucht sowohl am Anfang als auch am Ende der Datei, da Orca große
|
||||
Thumbnail-Blöcke einfügen kann und Metadaten dann im Tail stehen.
|
||||
"""
|
||||
try:
|
||||
head = data[:131072]
|
||||
tail = data[-131072:] if len(data) > 131072 else b""
|
||||
header = (head + b"\n" + tail).decode("utf-8", errors="ignore")
|
||||
colors, materials = [], []
|
||||
paint_count_hint = 0
|
||||
tool_filament_order = []
|
||||
for line in header.splitlines():
|
||||
if re.match(r"^\s*;\s*filament_colour\s*=", line):
|
||||
val = line.split("=", 1)[-1].strip()
|
||||
colors = [c.strip().lstrip("#") for c in val.split(";") if c.strip()]
|
||||
elif re.match(r"^\s*;\s*filament_multi_colour\s*=", line) and not colors:
|
||||
val = line.split("=", 1)[-1].strip()
|
||||
colors = [c.strip().lstrip("#") for c in val.split(";") if c.strip()]
|
||||
elif re.match(r"^\s*;\s*filament_type\s*=", line):
|
||||
val = line.split("=", 1)[-1].strip()
|
||||
parts = [m.strip() for m in re.split(r"[;,]", val) if m.strip()]
|
||||
materials = parts
|
||||
paint_count_hint = max(paint_count_hint, len(parts))
|
||||
elif re.match(r"^\s*;\s*filament_density\s*:", line):
|
||||
val = line.split(":", 1)[-1].strip()
|
||||
parts = [x.strip() for x in re.split(r"[;,]", val) if x.strip()]
|
||||
paint_count_hint = max(paint_count_hint, len(parts))
|
||||
elif re.match(r"^\s*;\s*filament_diameter\s*:", line):
|
||||
val = line.split(":", 1)[-1].strip()
|
||||
parts = [x.strip() for x in re.split(r"[;,]", val) if x.strip()]
|
||||
paint_count_hint = max(paint_count_hint, len(parts))
|
||||
elif re.match(r"^\s*;\s*filament\s*:", line):
|
||||
raw = line.split(":", 1)[-1]
|
||||
parsed = []
|
||||
for p in [x.strip() for x in raw.split(",") if x.strip()]:
|
||||
try:
|
||||
parsed.append(int(p))
|
||||
except Exception:
|
||||
pass
|
||||
if parsed:
|
||||
tool_filament_order = parsed
|
||||
total_paints = max(len(colors), len(materials), paint_count_hint)
|
||||
if tool_filament_order:
|
||||
total_paints = max(total_paints, max(tool_filament_order))
|
||||
if total_paints <= 0:
|
||||
return []
|
||||
|
||||
# Keep full paint list visible; mark paints referenced by Orca tool order as used.
|
||||
if len(colors) < total_paints:
|
||||
colors.extend(["FFFFFF"] * (total_paints - len(colors)))
|
||||
if len(materials) < total_paints:
|
||||
materials.extend(["PLA"] * (total_paints - len(materials)))
|
||||
# Prefer actual tool-change commands from the GCode body.
|
||||
# This avoids forwarding paints that are present in metadata but never used.
|
||||
used_paints_zero_based = set()
|
||||
try:
|
||||
header = data[:8192].decode("utf-8", errors="ignore")
|
||||
colors, materials = [], []
|
||||
for line in header.splitlines():
|
||||
if line.startswith("; filament_colour"):
|
||||
val = line.split("=", 1)[-1].strip()
|
||||
colors = [c.strip().lstrip("#") for c in val.split(";") if c.strip()]
|
||||
elif line.startswith("; filament_type"):
|
||||
val = line.split("=", 1)[-1].strip()
|
||||
materials = [m.strip() for m in val.split(";") if m.strip()]
|
||||
if not colors:
|
||||
return []
|
||||
result = []
|
||||
for i, hex_color in enumerate(colors):
|
||||
result.append({
|
||||
"slot_index": i,
|
||||
"color_hex": "#" + hex_color.upper() if hex_color else "#FFFFFF",
|
||||
"material": materials[i] if i < len(materials) else "PLA",
|
||||
})
|
||||
return result
|
||||
for m in re.finditer(br"(?m)^[ \t]*T([0-9]+)\b", data):
|
||||
used_paints_zero_based.add(int(m.group(1)))
|
||||
except Exception:
|
||||
return []
|
||||
used_paints_zero_based = set()
|
||||
|
||||
# Fallback for slicers that only provide paint usage in header metadata.
|
||||
used_paints_from_header = set()
|
||||
for n in tool_filament_order:
|
||||
try:
|
||||
# Orca/Prusa filament: list is typically 1-based.
|
||||
used_paints_from_header.add(max(0, int(n) - 1))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
result = []
|
||||
for i in range(total_paints):
|
||||
hex_color = colors[i] if i < len(colors) else "FFFFFF"
|
||||
result.append({
|
||||
"slot_index": i,
|
||||
"color_hex": "#" + hex_color.upper() if hex_color else "#FFFFFF",
|
||||
"material": materials[i] if i < len(materials) else "PLA",
|
||||
"is_used": (i in used_paints_zero_based) if used_paints_zero_based else ((i in used_paints_from_header) if used_paints_from_header else True),
|
||||
})
|
||||
return result
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
class GCodeStore:
|
||||
@@ -351,6 +409,15 @@ class GCodeStore:
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def update_file_filaments(self, file_id: str, gcode_filaments: list | None) -> None:
|
||||
"""Aktualisiert geparste GCode-Filamente für einen bestehenden DB-Eintrag."""
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
"UPDATE gcode_files SET gcode_filaments=? WHERE id=?",
|
||||
(json.dumps(gcode_filaments) if gcode_filaments else None, file_id),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def delete_file(self, file_id: str) -> bool:
|
||||
row = self.get_file(file_id)
|
||||
if not row:
|
||||
@@ -718,17 +785,19 @@ class KobraXBridge:
|
||||
return global_slots, global_loaded
|
||||
|
||||
if mode == "ace_direct":
|
||||
for ace in ace_boxes:
|
||||
ace_id = ace["id"]
|
||||
base = ace_id * 4
|
||||
for local_idx, s in enumerate(ace.get("slots") or []):
|
||||
s = dict(s)
|
||||
s["global_index"] = base + local_idx
|
||||
s["box_id"] = ace_id
|
||||
global_slots.append(s)
|
||||
ace_loaded = ace.get("loaded_slot", -1)
|
||||
if ace_loaded >= 0:
|
||||
global_loaded = base + ace_loaded
|
||||
# ace_direct exposes exactly 4 channels total.
|
||||
# If firmware reports multiple ACE boxes, keep only the first one.
|
||||
if ace_boxes:
|
||||
ace = ace_boxes[0]
|
||||
ace_id = ace["id"]
|
||||
for local_idx, s in enumerate((ace.get("slots") or [])[:4]):
|
||||
s = dict(s)
|
||||
s["global_index"] = local_idx
|
||||
s["box_id"] = ace_id
|
||||
global_slots.append(s)
|
||||
ace_loaded = ace.get("loaded_slot", -1)
|
||||
if 0 <= ace_loaded < 4:
|
||||
global_loaded = ace_loaded
|
||||
return global_slots, global_loaded
|
||||
|
||||
# ace_hub
|
||||
@@ -770,45 +839,180 @@ class KobraXBridge:
|
||||
offset = global_index - 3
|
||||
return offset // 4, offset % 4
|
||||
|
||||
def _slot_to_print_ams_index(self, global_index: int) -> int:
|
||||
"""Convert UI/global slot index to printer print/start ams_index.
|
||||
|
||||
In ace_hub mode, print/start uses global channel numbering where
|
||||
toolhead channels occupy 1..3 and ACE0 starts at index 4.
|
||||
"""
|
||||
idx = int(global_index)
|
||||
if self._filament_mode == "ace_hub":
|
||||
box_id, local_slot = self._global_to_box_slot(idx)
|
||||
if box_id >= 0:
|
||||
return 4 + box_id * 4 + int(local_slot)
|
||||
return idx
|
||||
return idx
|
||||
|
||||
def _slot_usable_for_print(self, global_index: int) -> bool:
|
||||
"""Whether a global slot can be used for current filament mode."""
|
||||
slot = next((s for s in self._ams_slots if int(s.get("global_index", -1)) == int(global_index)), None)
|
||||
if not slot:
|
||||
return False
|
||||
if int(slot.get("status", 0)) != 5:
|
||||
return False
|
||||
|
||||
box_id = int(slot.get("box_id", -1))
|
||||
if self._filament_mode == "ace_hub":
|
||||
# In hub mode, toolhead channels (0..2) and ACE channels are both printable.
|
||||
return box_id == -1 or box_id >= 0
|
||||
if self._filament_mode == "ace_direct":
|
||||
return box_id >= 0
|
||||
return box_id == -1
|
||||
|
||||
def _loaded_slots_for_print(self) -> list[tuple[int, dict]]:
|
||||
"""Loaded slots filtered for current filament mode."""
|
||||
loaded = [
|
||||
(int(s.get("global_index", i)), s)
|
||||
for i, s in enumerate(self._ams_slots)
|
||||
if s.get("status") == 5 and self._slot_usable_for_print(int(s.get("global_index", i)))
|
||||
]
|
||||
return loaded
|
||||
|
||||
def _select_loaded_slots_for_print(self, warn_on_empty_default: bool = False) -> list[tuple[int, dict]]:
|
||||
"""Return loaded slots, honoring default_ams_slot when configured."""
|
||||
default_slot = getattr(self._args, "default_ams_slot", "auto")
|
||||
all_loaded = self._loaded_slots_for_print()
|
||||
if default_slot == "auto":
|
||||
return all_loaded
|
||||
|
||||
try:
|
||||
slot_idx = int(default_slot)
|
||||
except ValueError:
|
||||
return all_loaded
|
||||
|
||||
selected = [(i, s) for i, s in all_loaded if i == slot_idx]
|
||||
if selected:
|
||||
return selected
|
||||
|
||||
if warn_on_empty_default:
|
||||
log.warning(f"Standard-Slot {slot_idx} ist leer – fallback auf Auto")
|
||||
return all_loaded
|
||||
|
||||
@staticmethod
|
||||
def _slot_color_rgba(slot: dict) -> list[int]:
|
||||
color = slot.get("color", [255, 255, 255])
|
||||
if isinstance(color, list) and len(color) >= 3:
|
||||
return [int(color[0]), int(color[1]), int(color[2]), 255]
|
||||
return [255, 255, 255, 255]
|
||||
|
||||
def _build_auto_ams_box_mapping(
|
||||
self,
|
||||
warn_on_empty_default: bool = False,
|
||||
loaded_slots: list[tuple[int, dict]] | None = None,
|
||||
) -> list[dict]:
|
||||
"""Build print mapping from currently loaded slots (no explicit dialog assignments)."""
|
||||
loaded = loaded_slots
|
||||
if loaded is None:
|
||||
loaded = self._select_loaded_slots_for_print(warn_on_empty_default=warn_on_empty_default)
|
||||
return [
|
||||
{
|
||||
"paint_index": pidx,
|
||||
"ams_index": self._slot_to_print_ams_index(gidx),
|
||||
"paint_color": [255, 255, 255, 255],
|
||||
"ams_color": self._slot_color_rgba(s),
|
||||
"material_type": s.get("type", "PLA"),
|
||||
}
|
||||
for pidx, (gidx, s) in enumerate(loaded)
|
||||
]
|
||||
|
||||
def _build_assigned_ams_box_mapping(self, assignments: list) -> tuple[list[dict], int, int]:
|
||||
"""Build print mapping from UI filament assignments.
|
||||
|
||||
Returns (mapping, unused_count, invalid_count).
|
||||
"""
|
||||
slot_by_global_index = {
|
||||
int(s.get("global_index", i)): s
|
||||
for i, s in enumerate(self._ams_slots)
|
||||
}
|
||||
ams_box_mapping: list[dict] = []
|
||||
unused_count = 0
|
||||
invalid_count = 0
|
||||
|
||||
for i, a in enumerate(assignments):
|
||||
try:
|
||||
if a.get("is_used") is False:
|
||||
unused_count += 1
|
||||
continue
|
||||
global_slot = int(a["slot_index"])
|
||||
except (ValueError, TypeError, KeyError):
|
||||
invalid_count += 1
|
||||
continue
|
||||
|
||||
if global_slot < 0:
|
||||
unused_count += 1
|
||||
continue
|
||||
if not self._slot_usable_for_print(global_slot):
|
||||
invalid_count += 1
|
||||
continue
|
||||
|
||||
slot = slot_by_global_index.get(global_slot, {})
|
||||
ams_box_mapping.append({
|
||||
# Preserve slicer paint indices (can be sparse when paint 0 is unused).
|
||||
"paint_index": a.get("paint_index", i),
|
||||
"ams_index": self._slot_to_print_ams_index(global_slot),
|
||||
"paint_color": a.get("paint_color", [255, 255, 255, 255]),
|
||||
"ams_color": self._slot_color_rgba(slot),
|
||||
"material_type": slot.get("type", a.get("material", "PLA")),
|
||||
})
|
||||
|
||||
return ams_box_mapping, unused_count, invalid_count
|
||||
|
||||
def _box_local_to_global(self, box_id: int, local_slot: int, boxes: list) -> int:
|
||||
"""Convert (box_id, local slot) to global slot index for current topology."""
|
||||
if box_id == -1:
|
||||
return local_slot
|
||||
if self._filament_mode == "ace_direct":
|
||||
return box_id * 4 + local_slot
|
||||
return local_slot
|
||||
return 3 + box_id * 4 + local_slot
|
||||
|
||||
def _slot_activity_map(self, boxes: list, global_loaded: int = -1) -> dict:
|
||||
"""Build {global_slot_index: loading|unloading} from feed_status data."""
|
||||
activity: dict = {}
|
||||
for box in boxes:
|
||||
fs = box.get("feed_status") or {}
|
||||
current_status = int(fs.get("current_status", -1))
|
||||
local_slot = int(fs.get("slot_index", -1))
|
||||
feed_type = int(fs.get("type", -1))
|
||||
if current_status in (-1, 10, 11) or local_slot < 0:
|
||||
continue
|
||||
box_slots = box.get("slots") or []
|
||||
if local_slot >= len(box_slots) or (box_slots[local_slot] or {}).get("status") != 5:
|
||||
continue
|
||||
if feed_type == 1:
|
||||
act = "loading"
|
||||
elif feed_type == 2:
|
||||
act = "unloading"
|
||||
else:
|
||||
continue
|
||||
global_slot = self._box_local_to_global(int(box.get("id", -1)), local_slot, boxes)
|
||||
if feed_type == 1 and self._pending_load_slot >= 0 and global_slot != self._pending_load_slot:
|
||||
# Ignore transient firmware-reported loading slots that differ from the requested target.
|
||||
if global_loaded >= 0 and global_loaded != self._pending_load_slot:
|
||||
activity[global_loaded] = "unloading"
|
||||
continue
|
||||
if feed_type == 1 and global_loaded >= 0 and global_slot != global_loaded:
|
||||
# During a slot swap the firmware reports the target slot immediately,
|
||||
# while the previously loaded slot is still being unloaded first.
|
||||
activity[global_loaded] = "unloading"
|
||||
activity[global_slot] = act
|
||||
return activity
|
||||
"""Build {global_slot_index: loading|unloading} from feed_status data."""
|
||||
activity: dict = {}
|
||||
primary_ace_id = -1
|
||||
if self._filament_mode == "ace_direct":
|
||||
ace_ids = sorted(int(b.get("id", -1)) for b in boxes if int(b.get("id", -1)) >= 0)
|
||||
if ace_ids:
|
||||
primary_ace_id = ace_ids[0]
|
||||
for box in boxes:
|
||||
if self._filament_mode == "ace_direct" and primary_ace_id >= 0 and int(box.get("id", -1)) != primary_ace_id:
|
||||
continue
|
||||
fs = box.get("feed_status") or {}
|
||||
current_status = int(fs.get("current_status", -1))
|
||||
local_slot = int(fs.get("slot_index", -1))
|
||||
feed_type = int(fs.get("type", -1))
|
||||
if current_status in (-1, 10, 11) or local_slot < 0:
|
||||
continue
|
||||
box_slots = box.get("slots") or []
|
||||
if local_slot >= len(box_slots) or (box_slots[local_slot] or {}).get("status") != 5:
|
||||
continue
|
||||
if feed_type == 1:
|
||||
act = "loading"
|
||||
elif feed_type == 2:
|
||||
act = "unloading"
|
||||
else:
|
||||
continue
|
||||
global_slot = self._box_local_to_global(int(box.get("id", -1)), local_slot, boxes)
|
||||
if feed_type == 1 and self._pending_load_slot >= 0 and global_slot != self._pending_load_slot:
|
||||
# Ignore transient firmware-reported loading slots that differ from the requested target.
|
||||
if global_loaded >= 0 and global_loaded != self._pending_load_slot:
|
||||
activity[global_loaded] = "unloading"
|
||||
continue
|
||||
if feed_type == 1 and global_loaded >= 0 and global_slot != global_loaded:
|
||||
# During a slot swap the firmware reports the target slot immediately,
|
||||
# while the previously loaded slot is still being unloaded first.
|
||||
activity[global_loaded] = "unloading"
|
||||
activity[global_slot] = act
|
||||
return activity
|
||||
|
||||
def _on_multicolor_box(self, payload: dict):
|
||||
data = payload.get("data") or {}
|
||||
@@ -962,63 +1166,57 @@ class KobraXBridge:
|
||||
}
|
||||
|
||||
def _build_lane_data(self) -> dict:
|
||||
"""Baut BBL-AMS-JSON für OrcaSlicer DevFilaSystemParser::ParseV1_0."""
|
||||
slots = self._ams_slots
|
||||
total = len(slots)
|
||||
if total == 0:
|
||||
return {"ams": [], "ams_exist_bits": "0", "tray_exist_bits": "0"}
|
||||
"""Build lane_data for filament sync from loaded, printable slots only.
|
||||
|
||||
ams_count = (total + 3) // 4
|
||||
ams_exist_bits = 0
|
||||
tray_exist_bits = 0
|
||||
ams_array = []
|
||||
Slots are compacted in sync order (installed slots first) so Orca/Happy
|
||||
Hare does not infer empty gaps between tray ids.
|
||||
"""
|
||||
loaded_slots = self._loaded_slots_for_print()
|
||||
if not loaded_slots:
|
||||
return {"ams": [], "ams_exist_bits": "0", "tray_exist_bits": "0"}
|
||||
|
||||
for ams_id in range(ams_count):
|
||||
ams_exist_bits |= (1 << ams_id)
|
||||
tray_array = []
|
||||
max_slot = min(3, total - ams_id * 4 - 1)
|
||||
for slot_id in range(max_slot + 1):
|
||||
slot_index = ams_id * 4 + slot_id
|
||||
slot = slots[slot_index] if slot_index < total else {}
|
||||
occupied = slot.get("status") == 5
|
||||
ams_buckets: dict[int, list[tuple[int, dict]]] = {}
|
||||
tray_exist_bits = 0
|
||||
|
||||
if occupied:
|
||||
tray_exist_bits |= (1 << slot_index)
|
||||
color_raw = slot.get("color", [255, 255, 255])
|
||||
if isinstance(color_raw, list) and len(color_raw) >= 3:
|
||||
color_hex = "{:02X}{:02X}{:02X}FF".format(
|
||||
int(color_raw[0]), int(color_raw[1]), int(color_raw[2])
|
||||
)
|
||||
elif isinstance(color_raw, str) and len(color_raw) >= 6:
|
||||
color_hex = color_raw[:6].upper() + "FF"
|
||||
else:
|
||||
color_hex = "FFFFFFFF"
|
||||
material = slot.get("type", "PLA").upper()
|
||||
tray_info_idx = self._TRAY_INFO_IDX.get(material, "OGFL99")
|
||||
tray_array.append({
|
||||
"id": str(slot_id),
|
||||
"tag_uid": "0000000000000000",
|
||||
"tray_info_idx": tray_info_idx,
|
||||
"tray_type": material,
|
||||
"tray_color": color_hex,
|
||||
})
|
||||
else:
|
||||
tray_array.append({
|
||||
"id": str(slot_id),
|
||||
"tag_uid": "0000000000000000",
|
||||
"tray_info_idx": "",
|
||||
"tray_type": "",
|
||||
"tray_color": "00000000",
|
||||
"tray_slot_placeholder": "1",
|
||||
})
|
||||
for sync_index, (_global_index, slot) in enumerate(sorted(loaded_slots, key=lambda item: item[0])):
|
||||
ams_id = sync_index // 4
|
||||
slot_id = sync_index % 4
|
||||
tray_exist_bits |= (1 << sync_index)
|
||||
ams_buckets.setdefault(ams_id, []).append((slot_id, slot))
|
||||
|
||||
ams_array.append({"id": str(ams_id), "info": "0002", "tray": tray_array})
|
||||
ams_exist_bits = 0
|
||||
ams_array = []
|
||||
for ams_id in sorted(ams_buckets.keys()):
|
||||
ams_exist_bits |= (1 << ams_id)
|
||||
tray_array = []
|
||||
for slot_id, slot in sorted(ams_buckets[ams_id], key=lambda item: item[0]):
|
||||
color_raw = slot.get("color", [255, 255, 255])
|
||||
if isinstance(color_raw, list) and len(color_raw) >= 3:
|
||||
color_hex = "{:02X}{:02X}{:02X}FF".format(
|
||||
int(color_raw[0]), int(color_raw[1]), int(color_raw[2])
|
||||
)
|
||||
elif isinstance(color_raw, str) and len(color_raw) >= 6:
|
||||
color_hex = color_raw[:6].upper() + "FF"
|
||||
else:
|
||||
color_hex = "FFFFFFFF"
|
||||
|
||||
return {
|
||||
"ams": ams_array,
|
||||
"ams_exist_bits": format(ams_exist_bits, "X"),
|
||||
"tray_exist_bits": format(tray_exist_bits, "X"),
|
||||
}
|
||||
material = slot.get("type", "PLA").upper()
|
||||
tray_info_idx = self._TRAY_INFO_IDX.get(material, "OGFL99")
|
||||
tray_array.append({
|
||||
"id": str(slot_id),
|
||||
"tag_uid": "0000000000000000",
|
||||
"tray_info_idx": tray_info_idx,
|
||||
"tray_type": material,
|
||||
"tray_color": color_hex,
|
||||
})
|
||||
|
||||
ams_array.append({"id": str(ams_id), "info": "0002", "tray": tray_array})
|
||||
|
||||
return {
|
||||
"ams": ams_array,
|
||||
"ams_exist_bits": format(ams_exist_bits, "X"),
|
||||
"tray_exist_bits": format(tray_exist_bits, "X"),
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# WebSocket push
|
||||
@@ -1042,40 +1240,39 @@ class KobraXBridge:
|
||||
self.ws_clients -= dead
|
||||
|
||||
def _build_mmu_object(self) -> dict:
|
||||
slots = self._ams_slots
|
||||
if not slots:
|
||||
return {}
|
||||
_TEMP = {"PLA": 210, "PETG": 230, "ABS": 240, "ASA": 250,
|
||||
"TPU": 220, "PA": 260, "PC": 270, "HIPS": 220}
|
||||
num_gates = len(slots)
|
||||
gate_status, gate_material, gate_color, gate_temperature, gate_color_rgb = [], [], [], [], []
|
||||
for slot in slots:
|
||||
occupied = slot.get("status") == 5
|
||||
gate_status.append(1 if occupied else 0)
|
||||
material = slot.get("type", "PLA").upper() if occupied else ""
|
||||
gate_material.append(material)
|
||||
c = slot.get("color", [0, 0, 0])
|
||||
if occupied:
|
||||
gate_color.append("#{:02X}{:02X}{:02X}".format(*c[:3]))
|
||||
gate_color_rgb.append([round(c[0]/255, 3), round(c[1]/255, 3), round(c[2]/255, 3)])
|
||||
else:
|
||||
gate_color.append("")
|
||||
gate_color_rgb.append([0.0, 0.0, 0.0])
|
||||
gate_temperature.append(_TEMP.get(material, 210) if occupied else 0)
|
||||
return {
|
||||
"num_gates": num_gates,
|
||||
"enabled": True,
|
||||
"gate_status": gate_status,
|
||||
"gate_material": gate_material,
|
||||
"gate_color": gate_color,
|
||||
"gate_temperature": gate_temperature,
|
||||
"gate_color_rgb": gate_color_rgb,
|
||||
"gate_filament_name": [""] * num_gates,
|
||||
"gate_spool_id": [-1] * num_gates,
|
||||
"ttg_map": list(range(num_gates)),
|
||||
"tool": self._ams_loaded_slot,
|
||||
"gate": self._ams_loaded_slot,
|
||||
}
|
||||
loaded_slots = sorted(self._loaded_slots_for_print(), key=lambda item: item[0])
|
||||
if not loaded_slots:
|
||||
return {}
|
||||
|
||||
_TEMP = {"PLA": 210, "PETG": 230, "ABS": 240, "ASA": 250,
|
||||
"TPU": 220, "PA": 260, "PC": 270, "HIPS": 220}
|
||||
num_gates = len(loaded_slots)
|
||||
gate_status, gate_material, gate_color, gate_temperature, gate_color_rgb = [], [], [], [], []
|
||||
for _global_index, slot in loaded_slots:
|
||||
gate_status.append(1)
|
||||
material = slot.get("type", "PLA").upper()
|
||||
gate_material.append(material)
|
||||
c = slot.get("color", [0, 0, 0])
|
||||
gate_color.append("#{:02X}{:02X}{:02X}".format(*c[:3]))
|
||||
gate_color_rgb.append([round(c[0]/255, 3), round(c[1]/255, 3), round(c[2]/255, 3)])
|
||||
gate_temperature.append(_TEMP.get(material, 210))
|
||||
|
||||
loaded_index_map = {global_index: idx for idx, (global_index, _) in enumerate(loaded_slots)}
|
||||
active_gate = loaded_index_map.get(int(self._ams_loaded_slot), -1)
|
||||
return {
|
||||
"num_gates": num_gates,
|
||||
"enabled": True,
|
||||
"gate_status": gate_status,
|
||||
"gate_material": gate_material,
|
||||
"gate_color": gate_color,
|
||||
"gate_temperature": gate_temperature,
|
||||
"gate_color_rgb": gate_color_rgb,
|
||||
"gate_filament_name": [""] * num_gates,
|
||||
"gate_spool_id": [-1] * num_gates,
|
||||
"ttg_map": list(range(num_gates)),
|
||||
"tool": active_gate,
|
||||
"gate": active_gate,
|
||||
}
|
||||
|
||||
def _build_printer_objects(self) -> dict:
|
||||
s = self._state
|
||||
@@ -1137,6 +1334,30 @@ class KobraXBridge:
|
||||
|
||||
async def handle_kx_files(self, request):
|
||||
files = self._store.list_files()
|
||||
# Legacy-Einträge ohne gespeicherte Filament-Metadaten nachziehen,
|
||||
# damit Dialog links die GCode-Farben statt AMS-Slots zeigt.
|
||||
for f in files:
|
||||
needs_refresh = not f.get("gcode_filaments")
|
||||
if not needs_refresh:
|
||||
try:
|
||||
cached = f.get("gcode_filaments")
|
||||
parsed_cached = cached if isinstance(cached, list) else json.loads(cached)
|
||||
needs_refresh = any("is_used" not in item for item in (parsed_cached or []))
|
||||
except Exception:
|
||||
needs_refresh = True
|
||||
if not needs_refresh:
|
||||
continue
|
||||
path = f.get("path") or ""
|
||||
if not path or not os.path.isfile(path):
|
||||
continue
|
||||
try:
|
||||
with open(path, "rb") as fh:
|
||||
parsed_filaments = _extract_filament_info(fh.read())
|
||||
if parsed_filaments:
|
||||
f["gcode_filaments"] = json.dumps(parsed_filaments)
|
||||
self._store.update_file_filaments(f["id"], parsed_filaments)
|
||||
except Exception:
|
||||
pass
|
||||
# Letzten Job-Status + Dauer pro Datei ergänzen
|
||||
jobs = self._store.list_jobs(limit=500)
|
||||
last_job: dict = {}
|
||||
@@ -1158,8 +1379,9 @@ class KobraXBridge:
|
||||
async def handle_kx_filament_slots(self, request):
|
||||
slots = []
|
||||
for i, s in enumerate(self._ams_slots):
|
||||
gidx = int(s.get("global_index", i))
|
||||
slots.append({
|
||||
"slot_index": i,
|
||||
"slot_index": gidx,
|
||||
"material": s.get("type", ""),
|
||||
"color_hex": "#{:02X}{:02X}{:02X}".format(*s.get("color", [0,0,0])[:3]),
|
||||
"status": "loaded" if s.get("status") == 5 else "empty",
|
||||
@@ -1307,38 +1529,16 @@ class KobraXBridge:
|
||||
excluded_objects = []
|
||||
|
||||
if assignments:
|
||||
ams_box_mapping = [
|
||||
{
|
||||
"paint_index": a.get("paint_index", i),
|
||||
"ams_index": a["slot_index"],
|
||||
"paint_color": a.get("paint_color", [255, 255, 255, 255]),
|
||||
"ams_color": a.get("ams_color", [255, 255, 255, 255]),
|
||||
"material_type": a.get("material", "PLA"),
|
||||
}
|
||||
for i, a in enumerate(assignments)
|
||||
]
|
||||
ams_box_mapping, unused_count, invalid_count = self._build_assigned_ams_box_mapping(assignments)
|
||||
if unused_count:
|
||||
log.debug(f"Skipped {unused_count} unused filament assignment(s) for mode={self._filament_mode}")
|
||||
if invalid_count:
|
||||
log.warning(f"Ignored {invalid_count} unusable filament assignment(s) for mode={self._filament_mode}")
|
||||
if not ams_box_mapping:
|
||||
return self._json_cors({"error": "no usable filament assignments for current filament mode"}, status=400)
|
||||
else:
|
||||
# Kein Dialog → alle belegten Slots wie bei normalem Upload-Druck
|
||||
default_slot = getattr(self._args, "default_ams_slot", "auto")
|
||||
all_loaded = [(i, s) for i, s in enumerate(self._ams_slots) if s.get("status") == 5]
|
||||
if default_slot != "auto":
|
||||
try:
|
||||
slot_idx = int(default_slot)
|
||||
loaded = [(i, s) for i, s in all_loaded if i == slot_idx] or all_loaded
|
||||
except ValueError:
|
||||
loaded = all_loaded
|
||||
else:
|
||||
loaded = all_loaded
|
||||
ams_box_mapping = [
|
||||
{
|
||||
"paint_index": i,
|
||||
"ams_index": i,
|
||||
"paint_color": [255, 255, 255, 255],
|
||||
"ams_color": [255, 255, 255, 255],
|
||||
"material_type": s.get("type", "PLA"),
|
||||
}
|
||||
for i, s in loaded
|
||||
]
|
||||
ams_box_mapping = self._build_auto_ams_box_mapping()
|
||||
|
||||
use_ams = len(ams_box_mapping) > 0
|
||||
auto_leveling = getattr(self._args, "auto_leveling", 1)
|
||||
@@ -1448,12 +1648,14 @@ class KobraXBridge:
|
||||
|
||||
async def handle_objects_query(self, request):
|
||||
objects = self._build_printer_objects()
|
||||
# filter by requested objects if specified
|
||||
requested = dict(request.rel_url.query)
|
||||
if requested:
|
||||
filtered = {k: objects[k] for k in requested if k in objects}
|
||||
else:
|
||||
filtered = objects
|
||||
requested = []
|
||||
query = request.rel_url.query
|
||||
if "objects" in query:
|
||||
requested = [x.strip() for x in str(query.get("objects", "")).split(",") if x.strip()]
|
||||
elif query:
|
||||
requested = [k for k in query.keys() if k]
|
||||
|
||||
filtered = {k: objects[k] for k in requested if k in objects} if requested else objects
|
||||
return web.json_response({"result": {"status": filtered, "eventtime": time.time()}})
|
||||
|
||||
async def handle_objects_list(self, request):
|
||||
@@ -1595,31 +1797,10 @@ class KobraXBridge:
|
||||
|
||||
def _start_print(self, filename: str, url: str = "", md5: str = "", filesize: int = 0):
|
||||
self._state["file_ready"] = ""
|
||||
default_slot = getattr(self._args, "default_ams_slot", "auto")
|
||||
all_loaded = [(i, s) for i, s in enumerate(self._ams_slots) if s.get("status") == 5]
|
||||
if default_slot != "auto":
|
||||
try:
|
||||
slot_idx = int(default_slot)
|
||||
loaded = [(i, s) for i, s in all_loaded if i == slot_idx]
|
||||
if not loaded:
|
||||
log.warning(f"Standard-Slot {slot_idx} ist leer – fallback auf Auto")
|
||||
loaded = all_loaded
|
||||
except ValueError:
|
||||
loaded = all_loaded
|
||||
else:
|
||||
loaded = all_loaded
|
||||
loaded = self._select_loaded_slots_for_print(warn_on_empty_default=True)
|
||||
use_ams = len(loaded) > 0
|
||||
ams_box_mapping = [
|
||||
{
|
||||
"paint_index": i,
|
||||
"ams_index": i,
|
||||
"paint_color": [255, 255, 255, 255],
|
||||
"ams_color": [255, 255, 255, 255],
|
||||
"material_type": s.get("type", "PLA"),
|
||||
}
|
||||
for i, s in loaded
|
||||
]
|
||||
log.info(f"AMS-Slots: {len(loaded)}/{len(self._ams_slots)} belegt → {[i for i,_ in loaded]}")
|
||||
ams_box_mapping = self._build_auto_ams_box_mapping(loaded_slots=loaded)
|
||||
log.debug(f"AMS-Slots: {len(loaded)}/{len(self._ams_slots)} belegt → {[i for i, _ in loaded]}")
|
||||
auto_leveling = getattr(self._args, "auto_leveling", 1)
|
||||
payload = {
|
||||
"taskid": "-1",
|
||||
@@ -1645,7 +1826,7 @@ class KobraXBridge:
|
||||
"model_objects_skip_parts": [],
|
||||
},
|
||||
}
|
||||
log.info(f"print/start → {filename} url={url} ams={len(self._ams_slots)} slots")
|
||||
log.info(f"print/start → {filename} url={url} ams={len(ams_box_mapping)} slots mode={self._filament_mode}")
|
||||
result = self.client.publish("print", "start", payload, timeout=15.0)
|
||||
if result:
|
||||
log.info(f"Druckstart bestätigt: state={result.get('state')}")
|
||||
@@ -1672,38 +1853,16 @@ class KobraXBridge:
|
||||
if not isinstance(excluded_objects, list):
|
||||
excluded_objects = []
|
||||
if filament_assignments is not None:
|
||||
ams_box_mapping = [
|
||||
{
|
||||
"paint_index": a.get("paint_index", i),
|
||||
"ams_index": a["slot_index"],
|
||||
"paint_color": a.get("paint_color", [255, 255, 255, 255]),
|
||||
"ams_color": a.get("ams_color", [255, 255, 255, 255]),
|
||||
"material_type": a.get("material", "PLA"),
|
||||
}
|
||||
for i, a in enumerate(filament_assignments)
|
||||
]
|
||||
ams_box_mapping, unused_count, invalid_count = self._build_assigned_ams_box_mapping(filament_assignments)
|
||||
if unused_count:
|
||||
log.debug(f"Skipped {unused_count} unused filament assignment(s) for mode={self._filament_mode}")
|
||||
if invalid_count:
|
||||
log.warning(f"Ignored {invalid_count} unusable filament assignment(s) for mode={self._filament_mode}")
|
||||
if not ams_box_mapping:
|
||||
return web.json_response({"error": "no usable filament assignments for current filament mode"}, status=400)
|
||||
else:
|
||||
# AMS-Mapping aus gecachtem State — leere Slots (status != 5) überspringen
|
||||
default_slot = getattr(self._args, "default_ams_slot", "auto")
|
||||
all_loaded = [(i, s) for i, s in enumerate(self._ams_slots) if s.get("status") == 5]
|
||||
if default_slot != "auto":
|
||||
try:
|
||||
slot_idx = int(default_slot)
|
||||
loaded = [(i, s) for i, s in all_loaded if i == slot_idx] or all_loaded
|
||||
except ValueError:
|
||||
loaded = all_loaded
|
||||
else:
|
||||
loaded = all_loaded
|
||||
ams_box_mapping = [
|
||||
{
|
||||
"paint_index": i,
|
||||
"ams_index": i,
|
||||
"paint_color": [255, 255, 255, 255],
|
||||
"ams_color": [255, 255, 255, 255],
|
||||
"material_type": s.get("type", "PLA"),
|
||||
}
|
||||
for i, s in loaded
|
||||
]
|
||||
ams_box_mapping = self._build_auto_ams_box_mapping()
|
||||
|
||||
use_ams = len(ams_box_mapping) > 0
|
||||
auto_leveling = getattr(self._args, "auto_leveling", 1)
|
||||
@@ -1736,6 +1895,11 @@ class KobraXBridge:
|
||||
},
|
||||
}
|
||||
|
||||
log.info(
|
||||
f"print/start api=1 mode={self._filament_mode} "
|
||||
f"ams={len(ams_box_mapping)} slots assignments={filament_assignments is not None}"
|
||||
)
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
result = await loop.run_in_executor(
|
||||
None, lambda: self.client.publish("print", "start", payload, timeout=15.0)
|
||||
@@ -3283,9 +3447,6 @@ function applyState(){
|
||||
var activity=(slot.activity||'');
|
||||
var pct=empty?T.ams_empty:(slot.consumables_percent!=null?slot.consumables_percent+'%':'–');
|
||||
var slotLabel='Slot '+(globalIdx+1);
|
||||
if(bid>=0){
|
||||
slotLabel='ACE '+(bid+1)+'.'+(slot.index!=null?slot.index+1:(i-2));
|
||||
}
|
||||
html+='<div class="ams-slot'+(active?' active':'')+(loaded?' loaded':'')+(activity?' '+activity:'')+(empty?' empty':'')
|
||||
+'" style="--slot-color:'+col+';opacity:'+(empty?0.4:1)+';cursor:pointer" onclick="openSlotEdit('+i+')">'
|
||||
+'<div class="slot-circle" style="background:'+col+'"></div>'
|
||||
@@ -4101,14 +4262,27 @@ var _filamentDialogMode='store'; // 'store' oder 'banner'
|
||||
|
||||
var _gcodeFilaments=[];
|
||||
|
||||
function _setGcodeFilamentsFromFileObj(fileObj){
|
||||
try{
|
||||
if(fileObj&&Array.isArray(fileObj.gcode_filaments)){
|
||||
_gcodeFilaments=fileObj.gcode_filaments;
|
||||
}else if(fileObj&&typeof fileObj.gcode_filaments==='string'&&fileObj.gcode_filaments){
|
||||
_gcodeFilaments=JSON.parse(fileObj.gcode_filaments);
|
||||
}else{
|
||||
_gcodeFilaments=[];
|
||||
}
|
||||
}catch(e){
|
||||
_gcodeFilaments=[];
|
||||
}
|
||||
}
|
||||
|
||||
function storePrint(fileId, filename){
|
||||
_storeFileId=fileId;
|
||||
_storeFilename=filename;
|
||||
_filamentDialogMode='store';
|
||||
// GCode-Filamente aus Store-Datei holen (für Vorschau im Dialog)
|
||||
var fileObj=storeFiles.find(function(f){return f.id===fileId;});
|
||||
try{ _gcodeFilaments=fileObj&&fileObj.gcode_filaments?JSON.parse(fileObj.gcode_filaments):[]; }
|
||||
catch(e){ _gcodeFilaments=[]; }
|
||||
_setGcodeFilamentsFromFileObj(fileObj);
|
||||
fetch(_apiUrl('/kx/filament/slots')).then(function(r){return r.json()}).then(function(d){
|
||||
openFilamentDialog(d.result||[]);
|
||||
}).catch(function(){openFilamentDialog([]);});
|
||||
@@ -4117,9 +4291,36 @@ function storePrint(fileId, filename){
|
||||
function startReadyFileWithSlots(){
|
||||
_filamentDialogMode='banner';
|
||||
_storeFilename=S.file_ready||'';
|
||||
fetch(_apiUrl('/kx/filament/slots')).then(function(r){return r.json()}).then(function(d){
|
||||
openFilamentDialog(d.result||[]);
|
||||
}).catch(function(){openFilamentDialog([]);});
|
||||
// Banner must never reuse stale store-file context.
|
||||
_storeFileId=null;
|
||||
_gcodeFilaments=[];
|
||||
|
||||
function openWithSlots(){
|
||||
fetch(_apiUrl('/kx/filament/slots')).then(function(r){return r.json()}).then(function(d){
|
||||
openFilamentDialog(d.result||[]);
|
||||
}).catch(function(){openFilamentDialog([]);});
|
||||
}
|
||||
|
||||
var fileObj=(storeFiles||[]).find(function(f){return f.filename===_storeFilename;});
|
||||
if(fileObj){
|
||||
_storeFileId=fileObj.id;
|
||||
_setGcodeFilamentsFromFileObj(fileObj);
|
||||
openWithSlots();
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: refresh file list, then resolve current file by filename.
|
||||
fetch(_apiUrl('/kx/files')).then(function(r){return r.json()}).then(function(d){
|
||||
storeFiles=d.result||[];
|
||||
var refreshed=(storeFiles||[]).find(function(f){return f.filename===_storeFilename;});
|
||||
if(refreshed){
|
||||
_storeFileId=refreshed.id;
|
||||
_setGcodeFilamentsFromFileObj(refreshed);
|
||||
}
|
||||
openWithSlots();
|
||||
}).catch(function(){
|
||||
openWithSlots();
|
||||
});
|
||||
}
|
||||
|
||||
var _amsSlots=[];
|
||||
@@ -4137,6 +4338,15 @@ function _contrastText(hex){
|
||||
var y=(r*299 + g*587 + b*114)/1000;
|
||||
return y>=140?'#111':'#fff';
|
||||
}
|
||||
function _normalizeMaterialKey(material){
|
||||
var key=(material||'').toUpperCase().replace(/[^A-Z0-9+]/g,'');
|
||||
// Orca often uses PLA for PLA+, while AMS may report PLA+.
|
||||
if(key==='PLA+'||key==='PLAPLUS') return 'PLA';
|
||||
return key;
|
||||
}
|
||||
function _materialsCompatible(a,b){
|
||||
return _normalizeMaterialKey(a)===_normalizeMaterialKey(b);
|
||||
}
|
||||
function _updateSlotMarker(sel){
|
||||
var opt=sel.options[sel.selectedIndex];
|
||||
var color=opt&&opt.dataset.color?opt.dataset.color:'#888';
|
||||
@@ -4151,7 +4361,9 @@ function _updateSlotMarker(sel){
|
||||
}
|
||||
|
||||
function openFilamentDialog(slots){
|
||||
_amsSlots=slots.filter(function(s){return s.status==='loaded';});
|
||||
_amsSlots=slots
|
||||
.filter(function(s){return s.status==='loaded';})
|
||||
.sort(function(a,b){return (a.slot_index||0)-(b.slot_index||0);});
|
||||
var dlg=document.getElementById('filament-dialog');
|
||||
var title=document.getElementById('fd-title');
|
||||
var body=document.getElementById('fd-slots');
|
||||
@@ -4161,7 +4373,7 @@ function openFilamentDialog(slots){
|
||||
_printObjectsSvg='';
|
||||
var objSection=document.getElementById('fd-objects-section');
|
||||
if(objSection)objSection.style.display='none';
|
||||
if(_storeFileId){
|
||||
if(_filamentDialogMode==='store'&&_storeFileId){
|
||||
fetch(_apiUrl('/kx/files/'+encodeURIComponent(_storeFileId)+'/objects'))
|
||||
.then(function(r){return r.json()})
|
||||
.then(function(d){
|
||||
@@ -4180,30 +4392,79 @@ function openFilamentDialog(slots){
|
||||
return {slot_index:i,color_hex:s.color_hex,material:s.material};
|
||||
});
|
||||
|
||||
// Default mapping strategy:
|
||||
// 1) keep order where possible (row i -> nearest compatible slot i)
|
||||
// 2) keep defaults unique while compatible slots are available
|
||||
// 3) use color proximity as tie-breaker
|
||||
function _hexToRgb(hex){
|
||||
var c=(hex||'').replace('#','');
|
||||
if(c.length===3)c=c[0]+c[0]+c[1]+c[1]+c[2]+c[2];
|
||||
if(c.length<6)return [255,255,255];
|
||||
return [parseInt(c.slice(0,2),16),parseInt(c.slice(2,4),16),parseInt(c.slice(4,6),16)];
|
||||
}
|
||||
function _colorDist(a,b){
|
||||
var ar=_hexToRgb(a), br=_hexToRgb(b);
|
||||
var dr=ar[0]-br[0], dg=ar[1]-br[1], db=ar[2]-br[2];
|
||||
return (dr*dr + dg*dg + db*db);
|
||||
}
|
||||
var defaultSlotByPaint={};
|
||||
var usedDefaultSlot={};
|
||||
channels.forEach(function(gc,i){
|
||||
var compatible=_amsSlots.filter(function(s){
|
||||
return _materialsCompatible(gc.material, s.material);
|
||||
});
|
||||
if(!compatible.length){
|
||||
defaultSlotByPaint[i]=-1;
|
||||
return;
|
||||
}
|
||||
|
||||
var ranked=compatible.slice().sort(function(a,b){
|
||||
var da=Math.abs((a.slot_index||0)-i), db=Math.abs((b.slot_index||0)-i);
|
||||
if(da!==db)return da-db;
|
||||
var ca=_colorDist(gc.color_hex, a.color_hex), cb=_colorDist(gc.color_hex, b.color_hex);
|
||||
if(ca!==cb)return ca-cb;
|
||||
return (a.slot_index||0)-(b.slot_index||0);
|
||||
});
|
||||
|
||||
var chosen=ranked.find(function(s){return !usedDefaultSlot[s.slot_index];}) || ranked[0];
|
||||
defaultSlotByPaint[i]=chosen?chosen.slot_index:-1;
|
||||
if(chosen) usedDefaultSlot[chosen.slot_index]=1;
|
||||
});
|
||||
|
||||
if(!_amsSlots.length){
|
||||
body.innerHTML='<p style="color:var(--txt2);font-size:13px;text-align:center;padding:16px 0">Keine belegten AMS-Slots.<br>Druck trotzdem starten?</p>';
|
||||
} else {
|
||||
body.innerHTML=channels.map(function(gc,i){
|
||||
// Passende Slots: gleicher Materialtyp
|
||||
var compatible=_amsSlots.filter(function(s){return s.material.toUpperCase()===gc.material.toUpperCase();});
|
||||
if(!compatible.length) compatible=_amsSlots; // Fallback: alle
|
||||
// Standard-Auswahl: Slot mit gleichem Index oder erster kompatibler
|
||||
var defaultSlot=compatible.find(function(s){return s.slot_index===gc.slot_index;})||compatible[0];
|
||||
var isUsed=(gc&&gc.is_used!==false);
|
||||
// Only allow material-compatible slots.
|
||||
var compatible=_amsSlots.filter(function(s){
|
||||
return _materialsCompatible(gc.material, s.material);
|
||||
});
|
||||
|
||||
var defaultSlotIndex=(defaultSlotByPaint.hasOwnProperty(i)?defaultSlotByPaint[i]:-1);
|
||||
var defaultSlot=compatible.find(function(s){return s.slot_index===defaultSlotIndex;})||null;
|
||||
var opts=compatible.map(function(s){
|
||||
var sel=(s.slot_index===defaultSlot.slot_index)?'selected':'';
|
||||
var sel=(defaultSlot&&s.slot_index===defaultSlot.slot_index)?'selected':'';
|
||||
return '<option value="'+s.slot_index+'" data-color="'+s.color_hex+'" data-material="'+s.material+'" '+sel+'>'+
|
||||
'● Slot '+(s.slot_index+1)+' · '+s.material+'</option>';
|
||||
}).join('');
|
||||
if(!compatible.length){
|
||||
opts='<option value="-1" data-color="#888888" data-material="" selected>⚠ No matching material</option>';
|
||||
}
|
||||
// Kanal-Box (links): farbige Box mit Nummer + auto Kontrast-Text
|
||||
var txt=_contrastText(gc.color_hex);
|
||||
var slotColor=defaultSlot?defaultSlot.color_hex:'#888';
|
||||
var slotTxt=_contrastText(slotColor);
|
||||
var usedBadge=isUsed
|
||||
? '<span style="font-size:10px;color:var(--ok);font-weight:700;min-width:32px">USED</span>'
|
||||
: '<span style="font-size:10px;color:var(--txt2);font-weight:700;min-width:32px;opacity:.75">USED</span>';
|
||||
return '<div style="display:flex;align-items:center;gap:8px;padding:8px;border-radius:6px;background:var(--raised);border:1px solid var(--border)">'+
|
||||
'<span style="display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:6px;background:'+gc.color_hex+';color:'+txt+';font-weight:700;font-size:13px;border:1px solid var(--border);flex-shrink:0">'+(i+1)+'</span>'+
|
||||
'<span style="font-size:11px;color:var(--txt2);min-width:36px">'+gc.material+'</span>'+
|
||||
usedBadge+
|
||||
'<span style="font-size:16px;color:var(--txt2)">→</span>'+
|
||||
'<span class="fd-slot-marker" data-for-paint="'+i+'" style="display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:5px;background:'+slotColor+';color:'+slotTxt+';font-weight:700;font-size:12px;border:1px solid var(--border);flex-shrink:0">'+(defaultSlot?defaultSlot.slot_index+1:'?')+'</span>'+
|
||||
'<select data-paint="'+i+'" data-paint-color="'+gc.color_hex+'" onchange="_updateSlotMarker(this)" style="flex:1;min-width:0;padding:4px 6px;border-radius:6px;border:1px solid var(--border);background:var(--raised);color:var(--txt);font-size:12px">'+
|
||||
'<select data-paint="'+i+'" data-paint-color="'+gc.color_hex+'" data-is-used="'+(isUsed?'1':'0')+'" data-has-compatible="'+(compatible.length?'1':'0')+'" '+(compatible.length?'':'disabled')+' onchange="_updateSlotMarker(this)" style="flex:1;min-width:0;padding:4px 6px;border-radius:6px;border:1px solid var(--border);background:var(--raised);color:var(--txt);font-size:12px">'+
|
||||
opts+'</select>'+
|
||||
'</div>';
|
||||
}).join('');
|
||||
@@ -4219,11 +4480,18 @@ function closeFilamentDialog(){
|
||||
function confirmFilamentPrint(){
|
||||
var selects=document.querySelectorAll('#fd-slots select');
|
||||
var assignments=[];
|
||||
var missingCompatible=0;
|
||||
selects.forEach(function(sel){
|
||||
var paintIdx=parseInt(sel.dataset.paint);
|
||||
var paintColor=sel.dataset.paintColor;
|
||||
var isUsed=(sel.dataset.isUsed==='1');
|
||||
var hasCompatible=(sel.dataset.hasCompatible==='1');
|
||||
var opt=sel.options[sel.selectedIndex];
|
||||
var amsIdx=parseInt(opt.value);
|
||||
var amsIdx=parseInt(opt&&opt.value);
|
||||
if(!hasCompatible || Number.isNaN(amsIdx) || amsIdx < 0){
|
||||
if(isUsed) missingCompatible += 1;
|
||||
amsIdx = -1;
|
||||
}
|
||||
var amsSlot=_amsSlots.find(function(s){return s.slot_index===amsIdx;})||{};
|
||||
// Farbe als [R,G,B,255]
|
||||
function hexToRgba(h){
|
||||
@@ -4233,12 +4501,17 @@ function confirmFilamentPrint(){
|
||||
}
|
||||
assignments.push({
|
||||
paint_index: paintIdx,
|
||||
is_used: isUsed,
|
||||
slot_index: amsIdx,
|
||||
material: opt.dataset.material||'PLA',
|
||||
paint_color: hexToRgba(paintColor||'#ffffff'),
|
||||
ams_color: hexToRgba(amsSlot.color_hex||'#ffffff'),
|
||||
});
|
||||
});
|
||||
if(missingCompatible>0){
|
||||
clog('Cannot start print: '+missingCompatible+' used paint(s) have no matching material slot','msg-err');
|
||||
return;
|
||||
}
|
||||
// Pre-Print Skip: Namen der abgehakten Objekte sammeln
|
||||
var excludedObjects=_printObjects.filter(function(o){return o.skip;}).map(function(o){return o.name;});
|
||||
closeFilamentDialog();
|
||||
|
||||
Reference in New Issue
Block a user