first working mapped color print

This commit is contained in:
Gangoke
2026-05-18 23:43:27 -10:00
parent d21c7c408c
commit dee3858533
2 changed files with 341 additions and 155 deletions

View File

@@ -1,50 +0,0 @@
// JS: Filament-Dialog für Store-Print (GCode-Filamente + Slots)
var _filamentDialogMode = 'store'; // 'store' oder 'banner'
var _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 = [];
}
fetch(_apiUrl('/kx/filament/slots'))
.then(function (r) {
return r.json();
})
.then(function (d) {
openFilamentDialog(_gcodeFilaments, d.result || []);
})
.catch(function () {
openFilamentDialog(_gcodeFilaments, []);
});
}
function openFilamentDialog(gcodeFilaments, slots) {
// Update the left side of the UI with gcodeFilaments
updateLeftSideUI(gcodeFilaments);
// Update the right side of the UI with available slots
updateRightSideUI(slots);
}
function updateLeftSideUI(gcodeFilaments) {
// Logic to populate the left side of the UI with gcodeFilaments
console.log('Updating left side with GCode filaments:', gcodeFilaments);
// Add your UI update logic here
}
function updateRightSideUI(slots) {
// Logic to populate the right side of the UI with available slots
console.log('Updating right side with available slots:', slots);
// Add your UI update logic here
}

View File

@@ -212,9 +212,10 @@ 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.
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.
"""
@@ -223,6 +224,8 @@ def _extract_filament_info(data: bytes) -> list[dict]:
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()
@@ -232,15 +235,64 @@ def _extract_filament_info(data: bytes) -> list[dict]:
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()
materials = [m.strip() for m in val.split(";") if m.strip()]
if not colors:
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:
for m in re.finditer(br"(?m)^[ \t]*T([0-9]+)\b", data):
used_paints_zero_based.add(int(m.group(1)))
except Exception:
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, hex_color in enumerate(colors):
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:
@@ -733,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
@@ -785,45 +839,91 @@ 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, print channels come from ACE units only.
return 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
]
return loaded
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 {}
@@ -1155,7 +1255,15 @@ class KobraXBridge:
# Legacy-Einträge ohne gespeicherte Filament-Metadaten nachziehen,
# damit Dialog links die GCode-Farben statt AMS-Slots zeigt.
for f in files:
if f.get("gcode_filaments"):
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):
@@ -1189,8 +1297,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",
@@ -1338,20 +1447,45 @@ class KobraXBridge:
excluded_objects = []
if assignments:
ams_box_mapping = [
{
"paint_index": a.get("paint_index", i),
"ams_index": a["slot_index"],
ams_box_mapping = []
dropped = 0
for i, a in enumerate(assignments):
try:
if a.get("is_used") is False:
dropped += 1
continue
global_slot = int(a["slot_index"])
except (ValueError, TypeError, KeyError):
dropped += 1
continue
# Skip unused paints (slot_index < 0 means no assignment)
if global_slot < 0:
dropped += 1
continue
if not self._slot_usable_for_print(global_slot):
dropped += 1
continue
slot = next((s for s in self._ams_slots if int(s.get("global_index", -1)) == global_slot), {})
slot_color = slot.get("color", [255, 255, 255])
if not (isinstance(slot_color, list) and len(slot_color) >= 3):
slot_color = [255, 255, 255]
slot_rgba = [int(slot_color[0]), int(slot_color[1]), int(slot_color[2]), 255]
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": a.get("ams_color", [255, 255, 255, 255]),
"material_type": a.get("material", "PLA"),
}
for i, a in enumerate(assignments)
]
"ams_color": slot_rgba,
"material_type": slot.get("type", a.get("material", "PLA")),
})
if dropped:
log.warning(f"Ignored {dropped} unused or unusable filament assignments 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]
all_loaded = self._loaded_slots_for_print()
if default_slot != "auto":
try:
slot_idx = int(default_slot)
@@ -1362,13 +1496,13 @@ class KobraXBridge:
loaded = all_loaded
ams_box_mapping = [
{
"paint_index": i,
"ams_index": i,
"paint_index": pidx,
"ams_index": self._slot_to_print_ams_index(gidx),
"paint_color": [255, 255, 255, 255],
"ams_color": [255, 255, 255, 255],
"ams_color": [int(c[0]), int(c[1]), int(c[2]), 255] if (isinstance(c := s.get("color", [255, 255, 255]), list) and len(c) >= 3) else [255, 255, 255, 255],
"material_type": s.get("type", "PLA"),
}
for i, s in loaded
for pidx, (gidx, s) in enumerate(loaded)
]
use_ams = len(ams_box_mapping) > 0
@@ -1404,6 +1538,12 @@ class KobraXBridge:
},
}
log.info(
f"print mapping mode={self._filament_mode} "
f"assignments={assignments if assignments else 'auto'} "
f"ams_box_mapping={ams_box_mapping}"
)
log.info(f"KX-Store Druckstart: {filename} ams={len(ams_box_mapping)} slots assignments={bool(assignments)} excluded={len(excluded_objects)}")
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
@@ -1627,7 +1767,7 @@ 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]
all_loaded = self._loaded_slots_for_print()
if default_slot != "auto":
try:
slot_idx = int(default_slot)
@@ -1642,13 +1782,13 @@ class KobraXBridge:
use_ams = len(loaded) > 0
ams_box_mapping = [
{
"paint_index": i,
"ams_index": i,
"paint_index": pidx,
"ams_index": self._slot_to_print_ams_index(gidx),
"paint_color": [255, 255, 255, 255],
"ams_color": [255, 255, 255, 255],
"ams_color": [int(c[0]), int(c[1]), int(c[2]), 255] if (isinstance(c := s.get("color", [255, 255, 255]), list) and len(c) >= 3) else [255, 255, 255, 255],
"material_type": s.get("type", "PLA"),
}
for i, s in loaded
for pidx, (gidx, s) in enumerate(loaded)
]
log.info(f"AMS-Slots: {len(loaded)}/{len(self._ams_slots)} belegt → {[i for i,_ in loaded]}")
auto_leveling = getattr(self._args, "auto_leveling", 1)
@@ -1676,6 +1816,10 @@ class KobraXBridge:
"model_objects_skip_parts": [],
},
}
log.info(
f"print mapping mode={self._filament_mode} auto_start=1 "
f"ams_box_mapping={ams_box_mapping}"
)
log.info(f"print/start → {filename} url={url} ams={len(self._ams_slots)} slots")
result = self.client.publish("print", "start", payload, timeout=15.0)
if result:
@@ -1703,20 +1847,44 @@ 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"],
ams_box_mapping = []
dropped = 0
for i, a in enumerate(filament_assignments):
if a.get("is_used") is False:
dropped += 1
continue
try:
global_slot = int(a["slot_index"])
except (ValueError, TypeError, KeyError):
dropped += 1
continue
# Skip unused paints (slot_index < 0 means no assignment)
if global_slot < 0:
dropped += 1
continue
if not self._slot_usable_for_print(global_slot):
dropped += 1
continue
slot = next((s for s in self._ams_slots if int(s.get("global_index", -1)) == global_slot), {})
slot_color = slot.get("color", [255, 255, 255])
if not (isinstance(slot_color, list) and len(slot_color) >= 3):
slot_color = [255, 255, 255]
slot_rgba = [int(slot_color[0]), int(slot_color[1]), int(slot_color[2]), 255]
ams_box_mapping.append({
"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": a.get("ams_color", [255, 255, 255, 255]),
"material_type": a.get("material", "PLA"),
}
for i, a in enumerate(filament_assignments)
]
"ams_color": slot_rgba,
"material_type": slot.get("type", a.get("material", "PLA")),
})
if dropped:
log.warning(f"Ignored {dropped} unused or unusable filament assignments 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]
all_loaded = self._loaded_slots_for_print()
if default_slot != "auto":
try:
slot_idx = int(default_slot)
@@ -1727,13 +1895,13 @@ class KobraXBridge:
loaded = all_loaded
ams_box_mapping = [
{
"paint_index": i,
"ams_index": i,
"paint_index": pidx,
"ams_index": self._slot_to_print_ams_index(gidx),
"paint_color": [255, 255, 255, 255],
"ams_color": [255, 255, 255, 255],
"ams_color": [int(c[0]), int(c[1]), int(c[2]), 255] if (isinstance(c := s.get("color", [255, 255, 255]), list) and len(c) >= 3) else [255, 255, 255, 255],
"material_type": s.get("type", "PLA"),
}
for i, s in loaded
for pidx, (gidx, s) in enumerate(loaded)
]
use_ams = len(ams_box_mapping) > 0
@@ -1767,6 +1935,12 @@ class KobraXBridge:
},
}
log.info(
f"print mapping mode={self._filament_mode} print_start_api=1 "
f"assignments={filament_assignments if filament_assignments is not None else 'auto'} "
f"ams_box_mapping={ams_box_mapping}"
)
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
None, lambda: self.client.publish("print", "start", payload, timeout=15.0)
@@ -3314,9 +3488,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>'
@@ -4132,12 +4303,7 @@ var _filamentDialogMode='store'; // 'store' oder 'banner'
var _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;});
function _setGcodeFilamentsFromFileObj(fileObj){
try{
if(fileObj&&Array.isArray(fileObj.gcode_filaments)){
_gcodeFilaments=fileObj.gcode_filaments;
@@ -4146,8 +4312,18 @@ function storePrint(fileId, filename){
}else{
_gcodeFilaments=[];
}
}catch(e){
_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;});
_setGcodeFilamentsFromFileObj(fileObj);
fetch(_apiUrl('/kx/filament/slots')).then(function(r){return r.json()}).then(function(d){
openFilamentDialog(d.result||[]);
}).catch(function(){openFilamentDialog([]);});
@@ -4156,9 +4332,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=[];
@@ -4176,6 +4379,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';
@@ -4190,7 +4402,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');
@@ -4200,7 +4414,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){
@@ -4223,26 +4437,36 @@ function openFilamentDialog(slots){
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);
});
// Default mapping by ordinal row position into compatible loaded slots.
var defaultSlot=compatible.length?compatible[Math.min(i, compatible.length-1)]: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('');
@@ -4258,11 +4482,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){
@@ -4272,12 +4503,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();