adding prusaslicer base code
This commit is contained in:
parent
d19fa0044a
commit
131c676a38
11 changed files with 4619 additions and 1 deletions
24
infra/csv_handling.py
Normal file
24
infra/csv_handling.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
from functools import lru_cache
|
||||
|
||||
import csv
|
||||
import os
|
||||
|
||||
@lru_cache(maxsize=128)
|
||||
def _load_csv(filename: str, mtime: float, encoding: str | None = None) -> tuple[tuple[str, ...], ...]:
|
||||
with open(filename, 'r', newline='', encoding=encoding) as f:
|
||||
reader = csv.reader(f)
|
||||
return tuple(tuple(row) for row in reader)
|
||||
|
||||
def parse_csv_to_tuples(filename: str) -> list[tuple[str, ...]]:
|
||||
current_mtime = os.path.getmtime(filename)
|
||||
data: tuple[tuple[str, ...], ...] = _load_csv(filename, current_mtime)
|
||||
return sorted(data, key=lambda x: x[1])
|
||||
|
||||
def parse_csv_to_dict(filename: str) -> dict[str, list[str]]:
|
||||
current_mtime = os.path.getmtime(filename)
|
||||
data: tuple[tuple[str, ...], ...] = _load_csv(filename, current_mtime, encoding='utf-8-sig')
|
||||
result = {}
|
||||
for row in data:
|
||||
if row:
|
||||
result[row[0]] = list(row[1:])
|
||||
return result
|
||||
13
infra/json_handling.py
Normal file
13
infra/json_handling.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import os, json
|
||||
|
||||
def dict_from_json(path):
|
||||
with open(path, 'r') as file:
|
||||
return json.load(file)
|
||||
|
||||
def dump_dict_to_json(dictionary, path):
|
||||
folder = os.path.dirname(path)
|
||||
if folder and not os.path.exists(folder):
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
|
||||
with open(path, 'w') as file:
|
||||
json.dump(dictionary, file, indent=2)
|
||||
|
|
@ -216,7 +216,7 @@ class CrealityBackend(PrinterHttpBackend):
|
|||
|
||||
@with_api_state('STARTING')
|
||||
def start_print(self, gcode: Path, name: str) -> None:
|
||||
from ..infra.ftp import ftp_upload, ftp_wipe, ftp_get_filesize
|
||||
from ..infra.ftp_handling import ftp_upload, ftp_wipe, ftp_get_filesize
|
||||
|
||||
file_size = gcode.stat().st_size
|
||||
|
||||
|
|
|
|||
42
infra/prusaslicer_profiles/README.md
Normal file
42
infra/prusaslicer_profiles/README.md
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# PrusaSlicer Profiles Module
|
||||
|
||||
This module handles the parsing, management, and evaluation of PrusaSlicer configuration profiles (`.ini` files). It supports profile inheritance, compatibility evaluation using PrusaSlicer's expression language, and generating merged configurations for slicing.
|
||||
|
||||
## Core Components
|
||||
|
||||
### 1. `profiles.py`
|
||||
- **`Profile` Class**: Represents a single PrusaSlicer profile (Printer, Filament, or Print).
|
||||
- Stores configuration as a dictionary (`conf_dict`).
|
||||
- Handles inheritance logic via `generate_inherited_confs`.
|
||||
- Supports compatibility checks via `evaluate_compatibility`.
|
||||
- **`generate_conf` Function**: Recursively merges inherited profiles into a single configuration dictionary.
|
||||
|
||||
### 2. `profile_cache.py`
|
||||
- **`LocalCache` Class**: Manages a collection of profiles loaded from disk.
|
||||
- Scans directories for `.ini` files and tracks changes using file metadata.
|
||||
- Categorizes profiles into `printers`, `print`, and `filament`.
|
||||
- `evaluate_compatibility`: Checks which filament and print profiles are compatible with specific printer profiles.
|
||||
- `generate_conf_writer`: Creates a `ConfigWriter` by merging a printer, print, and one or more filament profiles (multi-extruder support).
|
||||
- **`ConfigWriter` Class**: Helper to write the merged configuration to a file (e.g., for passing to a slicer or embedding in a 3MF).
|
||||
- **Utility Functions**: `ini_to_dict` and `ini_content_to_dict` use `ConfigParser` to parse PrusaSlicer's INI format.
|
||||
|
||||
### 3. `expression_parser.py`
|
||||
- Implements a recursive descent parser for PrusaSlicer's expression language (used in `compatible_printers_condition` and similar fields).
|
||||
- **AST Nodes**: `ExprNode`, `LiteralNode`, `VarNode`, `IndexNode`, `UnaryOpNode`, `BinaryOpNode`.
|
||||
- **`Parser` Class**: Tokenizes and parses expression strings into an AST that can be evaluated against a configuration dictionary.
|
||||
|
||||
### 4. `prusaslicer_fields.py`
|
||||
- Loads metadata about PrusaSlicer configuration fields from `prusaslicer_fields.json` and `prusaslicer_modifier_fields.csv`.
|
||||
- Provides `search_in_db` and `search_in_mod_db` for looking up field definitions, types, and labels.
|
||||
|
||||
## Data Files
|
||||
- `prusaslicer_fields.json`: Exhaustive database of PrusaSlicer settings, including types, default values, and labels.
|
||||
- `prusaslicer_modifier_fields.csv`: List of fields that are valid as modifiers.
|
||||
|
||||
## Typical Workflow
|
||||
1. Initialize `LocalCache`.
|
||||
2. Call `cache.load([paths_to_profiles])`.
|
||||
3. Select a printer, print, and filament profile(s).
|
||||
4. (Optional) Evaluate compatibility using `cache.evaluate_compatibility`.
|
||||
5. Generate a merged config using `cache.generate_conf_writer`.
|
||||
6. Use `ConfigWriter` to output the final configuration for slicing.
|
||||
335
infra/prusaslicer_profiles/expression_parser.py
Normal file
335
infra/prusaslicer_profiles/expression_parser.py
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
from __future__ import annotations
|
||||
import operator
|
||||
from typing import TYPE_CHECKING, TypeAlias
|
||||
if TYPE_CHECKING:
|
||||
Token: TypeAlias = tuple[str, str]
|
||||
|
||||
import re
|
||||
# === AST Node Definitions ===
|
||||
|
||||
_ops = {
|
||||
"==": (str, operator.eq),
|
||||
"!=": (str, operator.ne),
|
||||
"<": (float, operator.lt),
|
||||
">": (float, operator.gt),
|
||||
"<=": (float, operator.le),
|
||||
">=": (float, operator.ge),
|
||||
}
|
||||
|
||||
class ExprNode:
|
||||
def eval(self, context: dict[str, str]) -> str | float | re.Pattern[str] | bool:
|
||||
raise NotImplementedError("Must implement eval in subclass")
|
||||
|
||||
class LiteralNode(ExprNode):
|
||||
def __init__(self, value: str | re.Pattern[str]) -> None:
|
||||
self.value: str | re.Pattern[str] = value
|
||||
|
||||
def eval(self, context: dict[str, str]) -> str | float | re.Pattern[str] | bool:
|
||||
return self.value
|
||||
|
||||
class VarNode(ExprNode):
|
||||
def __init__(self, name: str) -> None:
|
||||
self.name: str = name
|
||||
|
||||
def eval(self, context: dict[str, str]) -> str | float | re.Pattern[str] | bool:
|
||||
return context.get(self.name, '0')
|
||||
|
||||
|
||||
class IndexNode(ExprNode):
|
||||
def __init__(self, name: str, index: int) -> None:
|
||||
self.name: str = name
|
||||
self.index: int = index
|
||||
|
||||
def eval(self, context: dict[str, str]) -> str | float | re.Pattern[str] | bool:
|
||||
val = context.get(self.name, '0')
|
||||
val = val.split(',')
|
||||
if self.index > len(val) -1: return '0'
|
||||
return val[self.index]
|
||||
|
||||
|
||||
class UnaryOpNode(ExprNode):
|
||||
def __init__(self, op: str, child: ExprNode) -> None:
|
||||
self.op = op
|
||||
self.child = child
|
||||
|
||||
def eval(self, context: dict[str, str]) -> str | float | re.Pattern[str] | bool:
|
||||
cval = self.child.eval(context)
|
||||
if isinstance(cval, re.Pattern):
|
||||
raise TypeError("Unary Operator cannot evaluate type re.Pattern")
|
||||
if self.op == "!":
|
||||
return not float(cval)
|
||||
raise RuntimeError(f"Unknown unary operator {self.op}")
|
||||
|
||||
class BinaryOpNode(ExprNode):
|
||||
def __init__(self, left: ExprNode, op: str, right: ExprNode) -> None:
|
||||
self.left = left
|
||||
self.op = op
|
||||
self.right = right
|
||||
|
||||
def eval(self, context: dict[str, str]) -> str | float | re.Pattern[str] | bool:
|
||||
if self.op in ["and", "&&"]:
|
||||
if not self.left.eval(context): return False
|
||||
return self.right.eval(context)
|
||||
|
||||
if self.op == "or":
|
||||
if self.left.eval(context): return True
|
||||
return self.right.eval(context)
|
||||
|
||||
# For everything else, we evaluate both sides
|
||||
lv: str | float | re.Pattern[str] | bool = self.left.eval(context)
|
||||
rv: str | float | re.Pattern[str] | bool = self.right.eval(context)
|
||||
|
||||
caster, func = _ops.get(self.op, (None, None))
|
||||
|
||||
if caster and func:
|
||||
if isinstance(lv, re.Pattern) or isinstance(rv, re.Pattern):
|
||||
raise RuntimeError(f"Operands of {self.op} must not be regex patterns")
|
||||
return func(caster(lv), caster(rv))
|
||||
|
||||
if self.op == "=~":
|
||||
if not isinstance(rv, re.Pattern):
|
||||
raise TypeError("Right operand of =~ must be a regex pattern")
|
||||
return rv.search(str(lv)) is not None
|
||||
|
||||
if self.op == "!~":
|
||||
if not isinstance(rv, re.Pattern):
|
||||
raise TypeError("Right operand of !~ must be a regex pattern")
|
||||
return rv.search(str(lv)) is None
|
||||
|
||||
raise RuntimeError(f"Unknown binary operator {self.op}")
|
||||
|
||||
# === Parser & Tokenizer ===
|
||||
|
||||
class Parser:
|
||||
def __init__(self, text: str):
|
||||
self.text = text
|
||||
self.tokens: list[Token] = self._tokenize(text)
|
||||
self.i = 0 # index into tokens
|
||||
|
||||
def _tokenize(self, text: str) -> list[Token]:
|
||||
tokens: list[Token] = []
|
||||
i = 0
|
||||
length: int = len(text)
|
||||
|
||||
while i < length:
|
||||
c = text[i]
|
||||
|
||||
# Skip whitespace
|
||||
if c.isspace():
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Parentheses / Brackets
|
||||
if c == "(":
|
||||
tokens.append(("LPAREN", c)); i += 1; continue
|
||||
if c == ")":
|
||||
tokens.append(("RPAREN", c)); i += 1; continue
|
||||
if c == "[":
|
||||
tokens.append(("LBRACK", c)); i += 1; continue
|
||||
if c == "]":
|
||||
tokens.append(("RBRACK", c)); i += 1; continue
|
||||
|
||||
# Multi‐char operators: check longest first
|
||||
# Order matters: check 3‐character operators? We only have 2‐char: ==, !=, >=, <=, =~, !~, and the single‐char '!'
|
||||
if text.startswith("==", i):
|
||||
tokens.append(("OP", "==")); i += 2; continue
|
||||
if text.startswith("!=", i):
|
||||
tokens.append(("OP", "!=")); i += 2; continue
|
||||
if text.startswith(">=", i):
|
||||
tokens.append(("OP", ">=")); i += 2; continue
|
||||
if text.startswith("<=", i):
|
||||
tokens.append(("OP", "<=")); i += 2; continue
|
||||
if text.startswith("=~", i):
|
||||
tokens.append(("OP", "=~")); i += 2; continue
|
||||
if text.startswith("!~", i):
|
||||
tokens.append(("OP", "!~")); i += 2; continue
|
||||
if text.startswith("&&", i):
|
||||
tokens.append(("OP", "and")); i += 2; continue
|
||||
if text.startswith("||", i):
|
||||
tokens.append(("OP", "or")); i += 2; continue
|
||||
|
||||
# Single‐char '>' or '<'
|
||||
if c == ">":
|
||||
tokens.append(("OP", ">")); i += 1; continue
|
||||
if c == "<":
|
||||
tokens.append(("OP", "<")); i += 1; continue
|
||||
|
||||
# Single‐char '!'
|
||||
if c == "!":
|
||||
tokens.append(("OP", "!")); i += 1; continue
|
||||
|
||||
# Regex literal: starts with '/'
|
||||
if c == "/":
|
||||
# Read until the next unescaped '/'
|
||||
j = i + 1
|
||||
while j < length:
|
||||
if text[j] == "/" and text[j-1] != "\\":
|
||||
break
|
||||
j += 1
|
||||
if j >= length:
|
||||
raise SyntaxError("Unterminated regex literal")
|
||||
pattern_body = text[i+1:j]
|
||||
tokens.append(("REGEX", pattern_body))
|
||||
i = j + 1
|
||||
continue
|
||||
|
||||
# Quoted string literal: " … " or ' … '
|
||||
if c in ("'", '"'):
|
||||
quote_char = c
|
||||
j = i + 1
|
||||
while j < length:
|
||||
if text[j] == quote_char and text[j-1] != "\\":
|
||||
break
|
||||
j += 1
|
||||
if j >= length:
|
||||
raise SyntaxError("Unterminated string literal")
|
||||
str_body = text[i+1: j]
|
||||
# We do not interpret escape sequences here; we store raw content
|
||||
tokens.append(("STRING", str_body))
|
||||
i = j + 1
|
||||
continue
|
||||
|
||||
# Number: [0-9]+(\.[0-9]+)?
|
||||
if c.isdigit():
|
||||
j = i
|
||||
while j < length and (text[j].isdigit() or text[j] == "."):
|
||||
j += 1
|
||||
num_text = text[i:j]
|
||||
tokens.append(("NUMBER", num_text))
|
||||
i = j
|
||||
continue
|
||||
|
||||
# Identifier or keyword (and/or)
|
||||
if c.isalpha() or c == "_":
|
||||
j = i
|
||||
while j < length and (text[j].isalnum() or text[j] == "_"):
|
||||
j += 1
|
||||
ident = text[i:j]
|
||||
if ident == "and":
|
||||
tokens.append(("OP", "and"))
|
||||
elif ident == "or":
|
||||
tokens.append(("OP", "or"))
|
||||
else:
|
||||
tokens.append(("IDENT", ident))
|
||||
i = j
|
||||
continue
|
||||
|
||||
# If we get here, it's an unexpected character
|
||||
raise SyntaxError(f"Unexpected character: {c}")
|
||||
|
||||
return tokens
|
||||
|
||||
def _peek(self) -> Token | None:
|
||||
if self.i < len(self.tokens):
|
||||
return self.tokens[self.i]
|
||||
return None
|
||||
|
||||
def _advance(self) -> Token:
|
||||
if self.i >= len(self.tokens):
|
||||
raise SyntaxError("Unexpected end of input")
|
||||
tok = self.tokens[self.i]
|
||||
self.i += 1
|
||||
return tok
|
||||
|
||||
def _expect(self, ttype: str, value: str | None = None) -> Token:
|
||||
tok = self._peek()
|
||||
if tok is None:
|
||||
raise SyntaxError(f"Expected {ttype} but found end of input")
|
||||
if tok[0] != ttype or (value is not None and tok[1] != value):
|
||||
raise SyntaxError(f"Expected {ttype} {value!r} but found {tok}")
|
||||
return self._advance()
|
||||
|
||||
def parse(self) -> ExprNode:
|
||||
node = self._parse_or()
|
||||
if self._peek() is not None:
|
||||
raise SyntaxError(f"Unexpected token after end: {self._peek()}")
|
||||
return node
|
||||
|
||||
def _parse_or(self) -> ExprNode:
|
||||
left = self._parse_and()
|
||||
while True:
|
||||
tok = self._peek()
|
||||
if tok is not None and tok[0] == "OP" and tok[1] == "or":
|
||||
self._advance()
|
||||
right = self._parse_and()
|
||||
left = BinaryOpNode(left, "or", right)
|
||||
else:
|
||||
break
|
||||
return left
|
||||
|
||||
def _parse_and(self) -> ExprNode:
|
||||
left = self._parse_not()
|
||||
while True:
|
||||
tok = self._peek()
|
||||
if tok is not None and tok[0] == "OP" and tok[1] == "and":
|
||||
self._advance()
|
||||
right = self._parse_not()
|
||||
left = BinaryOpNode(left, "and", right)
|
||||
else:
|
||||
break
|
||||
return left
|
||||
|
||||
def _parse_not(self) -> ExprNode:
|
||||
tok = self._peek()
|
||||
# If we see a lone '!' (not followed by '=' or '~'), it is unary NOT
|
||||
if tok is not None and tok[0] == "OP" and tok[1] == "!":
|
||||
# Make sure it's not part of '!=' or '!~' (those would have been tokenized as OP("!=") or OP("!~"))
|
||||
self._advance()
|
||||
child = self._parse_not()
|
||||
return UnaryOpNode("!", child)
|
||||
else:
|
||||
return self._parse_comp()
|
||||
|
||||
def _parse_comp(self) -> ExprNode:
|
||||
left = self._parse_atom()
|
||||
tok = self._peek()
|
||||
if tok is not None and tok[0] == "OP" and tok[1] in ("==", "!=", "<", ">", "<=", ">=", "=~", "!~"):
|
||||
op = tok[1]
|
||||
self._advance()
|
||||
right = self._parse_atom()
|
||||
return BinaryOpNode(left, op, right)
|
||||
return left
|
||||
|
||||
def _parse_atom(self) -> ExprNode:
|
||||
tok = self._peek()
|
||||
if tok is None:
|
||||
raise SyntaxError("Unexpected end of input in atom")
|
||||
|
||||
ttype, val = tok
|
||||
|
||||
if ttype in ["STRING", "NUMBER"]:
|
||||
self._advance()
|
||||
return LiteralNode(val)
|
||||
|
||||
if ttype == "REGEX":
|
||||
self._advance()
|
||||
|
||||
try:
|
||||
pat = re.compile(val)
|
||||
except re.error as e:
|
||||
raise SyntaxError(f"Invalid regex /{val}/: {e}")
|
||||
|
||||
return LiteralNode(pat)
|
||||
|
||||
if ttype == "IDENT":
|
||||
# Could be a variable or keyword—but "and"/"or" handled earlier
|
||||
name = val
|
||||
self._advance()
|
||||
# Check for indexing: IDENT '[' NUMBER ']'
|
||||
nxt = self._peek()
|
||||
if nxt is not None and nxt[0] == "LBRACK":
|
||||
self._advance() # consume '['
|
||||
idx_tok = self._expect("NUMBER")
|
||||
idx = int(idx_tok[1])
|
||||
self._expect("RBRACK")
|
||||
return IndexNode(name, idx)
|
||||
else:
|
||||
return VarNode(name)
|
||||
|
||||
if ttype == "LPAREN":
|
||||
self._advance()
|
||||
node = self._parse_or()
|
||||
self._expect("RPAREN")
|
||||
return node
|
||||
|
||||
raise SyntaxError(f"Unexpected token in atom: {tok}")
|
||||
251
infra/prusaslicer_profiles/profile_cache.py
Normal file
251
infra/prusaslicer_profiles/profile_cache.py
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .profiles import Profile
|
||||
|
||||
class LocalCache:
|
||||
profiles: dict[str, Profile] = {}
|
||||
|
||||
files_metadata: dict[str, Any] = {}
|
||||
|
||||
@property
|
||||
def display_profiles(self) -> dict[str, Profile]:
|
||||
return {k: profile for k, profile in self.profiles.items() if '*' not in profile.id}
|
||||
|
||||
@property
|
||||
def printers_profiles(self) -> dict[str, Profile]:
|
||||
return {k: profile for k, profile in self.display_profiles.items() if profile.category == 'printer'}
|
||||
|
||||
@property
|
||||
def print_profiles(self) -> dict[str, Profile]:
|
||||
return {k: profile for k, profile in self.display_profiles.items() if profile.category == 'print'}
|
||||
|
||||
@property
|
||||
def filament_profiles(self) -> dict[str, Profile]:
|
||||
return {k: profile for k, profile in self.display_profiles.items() if profile.category == 'filament'}
|
||||
|
||||
def load(self, dirs: list[Path | str]) -> tuple[dict[str, tuple[Any, Any]], dict[str, Any], dict[str, Any]]:
|
||||
|
||||
old = self.files_metadata.copy()
|
||||
self._fetch_files_metadata(dirs)
|
||||
new = self.files_metadata
|
||||
|
||||
changed = {k: (old[k], new[k]) for k in old.keys() & new.keys() if old[k] != new[k]}
|
||||
added = {k: new[k] for k in new.keys() - old.keys()}
|
||||
deleted = {k: old[k] for k in old.keys() - new.keys()}
|
||||
|
||||
if len(changed | added | deleted) == 0: return {}, {}, {}
|
||||
|
||||
for deleted_file in deleted:
|
||||
keys_to_remove = [key for key, val in self.profiles.items() if str(val.path) == deleted_file]
|
||||
for key in keys_to_remove:
|
||||
self.profiles.pop(key, None)
|
||||
|
||||
for file_path in (changed | added):
|
||||
self._process_ini_to_cache_dict(file_path)
|
||||
|
||||
self.files_metadata = new
|
||||
|
||||
for k, profile in self.display_profiles.items():
|
||||
profile.generate_inherited_confs(self.profiles)
|
||||
|
||||
return changed, added, deleted
|
||||
|
||||
@property
|
||||
def vendors(self):
|
||||
return sorted({ p.vendor for p in self.filament_profiles.values() })
|
||||
|
||||
def evaluate_compatibility(self, enabled_printers: set[str], enabled_vendors: set[str]):
|
||||
for k, profile in self.printers_profiles.items():
|
||||
if k not in enabled_printers: continue
|
||||
# if profile.compatible_profiles: continue
|
||||
enabled_vendors.add("")
|
||||
enabled_filament_profiles = {k: p for k, p in self.filament_profiles.items() if p.vendor in enabled_vendors | {''}}
|
||||
profile.evaluate_compatibility({k: pp.compatibility_expression for k, pp in (enabled_filament_profiles | self.print_profiles).items()})
|
||||
|
||||
def _fetch_files_metadata(self, dirs: list[Path | str]):
|
||||
self.files_metadata = {}
|
||||
# Iterate over all provided directories
|
||||
for directory in dirs:
|
||||
sanitized_path = self._sanitize_directory(directory)
|
||||
if not sanitized_path:
|
||||
continue
|
||||
|
||||
# Use os.walk with followlinks=True to ensure linked folders are processed
|
||||
for root, _, files in os.walk(sanitized_path, followlinks=True):
|
||||
for file in files:
|
||||
if file.endswith('.ini'):
|
||||
file_path = Path(root) / file
|
||||
try:
|
||||
last_modified = file_path.stat().st_mtime
|
||||
self.files_metadata[str(file_path)] = last_modified
|
||||
except OSError as e:
|
||||
print(f"Error reading file {file_path}: {e}")
|
||||
continue
|
||||
|
||||
def _sanitize_directory(self, dir_str: Path | str) -> Path | None:
|
||||
if not dir_str:
|
||||
return None
|
||||
|
||||
sanitized = Path(os.path.expanduser(dir_str)).resolve()
|
||||
|
||||
if not sanitized.is_dir():
|
||||
print(f"Path is not a valid directory: {dir_str}")
|
||||
return None
|
||||
|
||||
return sanitized
|
||||
|
||||
def _process_ini_to_cache_dict(self, path: str):
|
||||
# Convert ConfigParser content into a dictionary
|
||||
has_header, ini_dict = ini_to_dict(path)
|
||||
|
||||
# Flatten the dictionary for profiles and add to self.config_headers
|
||||
for key, conf_dict in ini_dict.items():
|
||||
if ":" in key:
|
||||
self.profiles[key] = Profile(
|
||||
key,
|
||||
key.split(':')[0] if len(key.split(':')) > 1 else '',
|
||||
Path(path),
|
||||
has_header,
|
||||
conf_dict
|
||||
)
|
||||
|
||||
return
|
||||
|
||||
def generate_conf_writer(self, printer_profile: str, filament_profile: list[str], print_profile: str, overrides: dict[str, dict[str, str]]) -> 'ConfigWriter':
|
||||
from .prusaslicer_fields import search_db
|
||||
|
||||
conf = {}
|
||||
|
||||
# add printer and print profile
|
||||
conf.update(self.profiles[printer_profile].all_conf_dict)
|
||||
conf.update(self.profiles[print_profile].all_conf_dict)
|
||||
|
||||
# add filament profiles per extruder
|
||||
filament_merged_conf: dict[str, str] = {}
|
||||
filament_confs: list[dict[str, str]] = [self.profiles[profile].all_conf_dict for profile in filament_profile]
|
||||
common_keys: set[str] = set().union(*filament_confs)
|
||||
common_keys.discard('cooling_slowdown_logic')
|
||||
|
||||
for key in common_keys:
|
||||
key_props: dict[str, Any] | None = search_db.get(key, None)
|
||||
|
||||
if not key_props: print(f'Key {key} unrecognized! Check your configuration'); continue
|
||||
|
||||
default_value: str = key_props.get('default', '')
|
||||
key_type: str = key_props['type']
|
||||
separator = ',' if key_type in ['coPercents', 'coFloats', 'coFloatsOrPercents', 'coInts', 'coIntsNullable', 'coBools', 'coPoints'] else ';'
|
||||
|
||||
values: list[str] = [
|
||||
d.get(key, default_value)
|
||||
for d in filament_confs
|
||||
]
|
||||
|
||||
joined: str = separator.join(values)
|
||||
filament_merged_conf[key] = joined
|
||||
|
||||
conf.update(filament_merged_conf)
|
||||
|
||||
# overwrite with overrides
|
||||
conf.update({k: o['value'] for k, o in overrides.items()})
|
||||
|
||||
# add profile names
|
||||
conf.update({
|
||||
'printer_settings_id': printer_profile.split(":")[1],
|
||||
'filament_settings_id': ";".join(p.split(":")[1] for p in filament_profile),
|
||||
'print_settings_id': print_profile.split(":")[1],
|
||||
})
|
||||
|
||||
return ConfigWriter(conf)
|
||||
|
||||
class ConfigWriter:
|
||||
def __init__(self, conf: dict[str, str | list[str]]) -> None:
|
||||
self.config_dict: dict[str, str | list[str]] = conf
|
||||
self.temp_dir = tempfile.gettempdir()
|
||||
|
||||
def write_ini_3mf(self, config_local_path: Path):
|
||||
with open(config_local_path, 'w') as file:
|
||||
for key, val in dict(sorted(self.config_dict.items())).items():
|
||||
file.write(f"; {key} = {val}\n")
|
||||
|
||||
def get(self, key: str, default: Any = None) -> str | list[str]:
|
||||
return self.config_dict[key]
|
||||
|
||||
@property
|
||||
def checksum(self) -> int:
|
||||
import json, zlib
|
||||
data = json.dumps(self.config_dict, sort_keys=True).encode("utf-8")
|
||||
return zlib.crc32(data) & 0xFFFFFFFF
|
||||
|
||||
def generate_conf(profiles: dict[str, Any], id: str) -> dict[str, str]:
|
||||
if not (profile := profiles.get(id)): return {}
|
||||
if not profile.conf_dict: return {}
|
||||
conf_current = profiles[id].conf_dict # Copy to avoid modifying the original config
|
||||
if conf_current.get('inherits', False):
|
||||
curr_category = id.split(":")[0]
|
||||
inherited_ids = [curr_category + ":" + inherit_id.strip() for inherit_id in conf_current['inherits'].split(';')] # Split on semicolon for multiple inheritance
|
||||
merged_conf = {}
|
||||
for inherit_id in inherited_ids:
|
||||
if inherit_id in profiles:
|
||||
inherited_conf = generate_conf(profiles, inherit_id) # Recursive call for each inherited config
|
||||
merged_conf.update(inherited_conf) # Merge each inherited config
|
||||
merged_conf.update(conf_current) # Update with current config values (overriding inherited)
|
||||
conf_current = merged_conf
|
||||
conf_current.pop('inherits', None)
|
||||
conf_current.pop('renamed_from', None)
|
||||
return conf_current
|
||||
|
||||
import re
|
||||
from configparser import ConfigParser, MissingSectionHeaderError
|
||||
|
||||
def ini_to_dict(path: str) -> tuple[bool, dict[str, dict[str, str]]]:
|
||||
# Read the file content from the path
|
||||
with open(path, 'r') as file:
|
||||
content = file.read()
|
||||
|
||||
config = ConfigParser(interpolation=None)
|
||||
try:
|
||||
# Attempt to parse the content
|
||||
config.read_string(content)
|
||||
has_header = True
|
||||
|
||||
except MissingSectionHeaderError:
|
||||
# Determine category based on specific IDs in the content
|
||||
if re.search(r'^filament_settings_id', content, re.MULTILINE):
|
||||
cat = 'filament'
|
||||
elif re.search(r'^print_settings_id', content, re.MULTILINE):
|
||||
cat = 'print'
|
||||
elif re.search(r'^printer_settings_id', content, re.MULTILINE):
|
||||
cat = 'printer'
|
||||
else:
|
||||
raise ValueError(f"Unable to determine category for the INI file: {path}")
|
||||
|
||||
# Extract the filename without extension
|
||||
name = os.path.splitext(os.path.basename(path))[0]
|
||||
# Create a default section with the determined category and name
|
||||
default_section = f"[{cat}:{name}]\n" + content
|
||||
config.read_string(default_section)
|
||||
has_header = False
|
||||
|
||||
# Convert ConfigParser content into a dictionary
|
||||
return has_header, {
|
||||
section: dict(sorted(config.items(section)))
|
||||
for section in sorted(config.sections())
|
||||
}
|
||||
|
||||
def ini_content_to_dict(path: str) -> dict[str, str]:
|
||||
# Read the file content from the path
|
||||
with open(path, 'r') as file:
|
||||
content = file.read()
|
||||
|
||||
config = ConfigParser(interpolation=None)
|
||||
|
||||
default_section = f"[default:default]\n" + content
|
||||
config.read_string(default_section)
|
||||
|
||||
return dict(sorted(config.items(config.sections()[0])))
|
||||
55
infra/prusaslicer_profiles/profiles.py
Normal file
55
infra/prusaslicer_profiles/profiles.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .expression_parser import Parser, ExprNode
|
||||
|
||||
class Profile():
|
||||
def __init__(self, key: str, category: str, path: Path, has_header: bool, conf_dict: dict[str, Any]):
|
||||
self.id: str = key.split(':')[1] if len(key.split(':')) > 1 else key
|
||||
self.key: str = key
|
||||
self.category: str = category
|
||||
self.vendor: str = ''
|
||||
self.path: Path = path
|
||||
self.has_header: bool = has_header
|
||||
self.conf_dict: dict[str, Any] = conf_dict
|
||||
self.all_conf_dict: dict[str, Any] = {}
|
||||
self.compatible_profiles: list[str] = []
|
||||
self.compatibility_expression: ExprNode | None = None
|
||||
|
||||
def evaluate_compatibility(self, compats: dict[str, ExprNode | None]):
|
||||
self.compatible_profiles = []
|
||||
if self.category.startswith('printer:'): return
|
||||
for key, compat in compats.items():
|
||||
if not compat: self.compatible_profiles.append(key); continue
|
||||
if compat.eval(self.all_conf_dict): self.compatible_profiles.append(key)
|
||||
pass
|
||||
print(f'Evaled {self.id}')
|
||||
|
||||
def generate_inherited_confs(self, all_confs_dict: dict[str, Any] = {}):
|
||||
self.all_conf_dict = generate_conf(all_confs_dict, self.key)
|
||||
if self.category == 'printer': self.all_conf_dict['num_extruders'] = str(len(self.all_conf_dict['nozzle_diameter'].split(',')))
|
||||
self.vendor = self.all_conf_dict.get('filament_vendor', '')
|
||||
|
||||
if exp := self.all_conf_dict.get('compatible_printers_condition'):
|
||||
try:
|
||||
self.compatibility_expression = Parser(exp).parse()
|
||||
except:
|
||||
print(f'Expression parsing failed: {exp}')
|
||||
|
||||
def generate_conf(profiles: dict[str, Any], id: str) -> dict[str, str]:
|
||||
if not (profile := profiles.get(id)): return {}
|
||||
if not profile.conf_dict: return {}
|
||||
conf_current = profiles[id].conf_dict # Copy to avoid modifying the original config
|
||||
if conf_current.get('inherits', False):
|
||||
curr_category = id.split(":")[0]
|
||||
inherited_ids = [curr_category + ":" + inherit_id.strip() for inherit_id in conf_current['inherits'].split(';')] # Split on semicolon for multiple inheritance
|
||||
merged_conf = {}
|
||||
for inherit_id in inherited_ids:
|
||||
if inherit_id in profiles:
|
||||
inherited_conf = generate_conf(profiles, inherit_id) # Recursive call for each inherited config
|
||||
merged_conf.update(inherited_conf) # Merge each inherited config
|
||||
merged_conf.update(conf_current) # Update with current config values (overriding inherited)
|
||||
conf_current = merged_conf
|
||||
conf_current.pop('inherits', None)
|
||||
conf_current.pop('renamed_from', None)
|
||||
return conf_current
|
||||
3817
infra/prusaslicer_profiles/prusaslicer_fields.json
Normal file
3817
infra/prusaslicer_profiles/prusaslicer_fields.json
Normal file
File diff suppressed because it is too large
Load diff
29
infra/prusaslicer_profiles/prusaslicer_fields.py
Normal file
29
infra/prusaslicer_profiles/prusaslicer_fields.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
if TYPE_CHECKING:
|
||||
from typing import Any
|
||||
|
||||
import os
|
||||
from functools import lru_cache
|
||||
|
||||
from ..json_handling import dict_from_json
|
||||
from ..csv_handling import parse_csv_to_dict
|
||||
|
||||
search_db_path: str = os.path.join('prusaslicer_fields.json')
|
||||
search_db: dict[str, dict[str, Any]] = dict_from_json(search_db_path)
|
||||
|
||||
search_db_mod_path: str = os.path.join('prusaslicer_modifier_fields.csv')
|
||||
search_db_mod: dict[str, list[str]] = parse_csv_to_dict(search_db_mod_path)
|
||||
|
||||
@lru_cache(maxsize=128)
|
||||
def search_in_db(term) -> dict[str, dict[str, Any]]:
|
||||
words: Any = term.lower().split()
|
||||
return {k: v for k, v in search_db.items() if all([word in k+" "+v.get('label','') for word in words])}
|
||||
|
||||
@lru_cache(maxsize=128)
|
||||
def search_in_mod_db(term) -> dict[str, dict[str, Any]]:
|
||||
words = term.lower().split()
|
||||
filtered_search_db = {k: v for k, v in search_db.items() if k in search_db_mod}
|
||||
return {k: v for k, v in filtered_search_db.items() if all([word in k+" "+v.get('label','') for word in words])}
|
||||
|
||||
pass
|
||||
52
infra/prusaslicer_profiles/prusaslicer_modifier_fields.csv
Normal file
52
infra/prusaslicer_profiles/prusaslicer_modifier_fields.csv
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
bottom_fill_pattern
|
||||
bottom_solid_layers
|
||||
bottom_solid_min_thickness
|
||||
bridge_angle
|
||||
bridge_flow_ratio
|
||||
bridge_speed
|
||||
enable_dynamic_overhang_speeds
|
||||
external_perimeter_extrusion_width
|
||||
external_perimeter_speed
|
||||
external_perimeters_first
|
||||
extra_perimeters
|
||||
extra_perimeters_on_overhangs
|
||||
fill_angle
|
||||
fill_density
|
||||
fill_pattern
|
||||
fuzzy_skin
|
||||
fuzzy_skin_point_dist
|
||||
fuzzy_skin_thickness
|
||||
gap_fill_enabled
|
||||
gap_fill_speed
|
||||
infill_anchor
|
||||
infill_anchor_max
|
||||
infill_every_layers
|
||||
infill_extrusion_width
|
||||
infill_overlap
|
||||
infill_speed
|
||||
ironing
|
||||
ironing_flowrate
|
||||
ironing_spacing
|
||||
ironing_speed
|
||||
ironing_type
|
||||
only_one_perimeter_first_layer
|
||||
overhang_speed_0
|
||||
overhang_speed_1
|
||||
overhang_speed_2
|
||||
overhang_speed_3
|
||||
overhangs
|
||||
perimeter_extrusion_width
|
||||
perimeter_speed
|
||||
perimeters
|
||||
small_perimeter_speed
|
||||
solid_infill_below_area
|
||||
solid_infill_every_layers
|
||||
solid_infill_extrusion_width
|
||||
solid_infill_speed
|
||||
thin_walls
|
||||
top_fill_pattern
|
||||
top_infill_extrusion_width
|
||||
top_one_perimeter_type
|
||||
top_solid_infill_speed
|
||||
top_solid_layers
|
||||
top_solid_min_thickness
|
||||
|
Loading…
Add table
Reference in a new issue