conversor3mf 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
File without changes
@@ -0,0 +1,111 @@
1
+ """Linha de comando: python -m conversor3mf <comando> ..."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from . import ace as modulo_ace
11
+ from . import ilhas
12
+ from .conversor import converter, planejar
13
+ from .projeto import ler_projeto
14
+ from .verificar import verificar
15
+
16
+
17
+ def _mapa(texto: str | None) -> dict[int, int]:
18
+ """'1=3,2=1' -> {1: 3, 2: 1}"""
19
+ if not texto:
20
+ return {}
21
+ mapa = {}
22
+ for par in texto.split(","):
23
+ origem, _, slot = par.partition("=")
24
+ mapa[int(origem)] = int(slot)
25
+ return mapa
26
+
27
+
28
+ def _mostrar(dados) -> None:
29
+ print(json.dumps(dados, indent=2, ensure_ascii=False))
30
+
31
+
32
+ def main(argv: list[str] | None = None) -> int:
33
+ sys.stdout.reconfigure(encoding="utf-8")
34
+ p = argparse.ArgumentParser(prog="conversor3mf", description="Converte 3MF da Bambu para a Kobra X na ordem do seu ACE.")
35
+ sub = p.add_subparsers(dest="comando", required=True)
36
+
37
+ s = sub.add_parser("inspecionar", help="mostra filamentos, cores e onde cada um é usado")
38
+ s.add_argument("arquivo")
39
+
40
+ s = sub.add_parser("planejar", help="mostra em qual slot cada cor vai sair, sem gravar nada")
41
+ s.add_argument("arquivo")
42
+ s.add_argument("--mapa", help="força slots, ex.: 1=3,2=1 (filamento do modelo = slot do ACE)")
43
+
44
+ s = sub.add_parser("converter", help="grava o 3MF convertido")
45
+ s.add_argument("arquivo")
46
+ s.add_argument("--mapa", help="força slots, ex.: 1=3,2=1")
47
+ s.add_argument("--saida", help="caminho do arquivo convertido (padrão: <nome>_KobraX.3mf)")
48
+ s.add_argument("--sem-verificar", action="store_true", help="não confere no slicer depois de gravar")
49
+
50
+ s = sub.add_parser("verificar", help="abre o arquivo no Anycubic Slicer Next e confere")
51
+ s.add_argument("arquivo")
52
+
53
+ s = sub.add_parser("ilhas", help="desenha o objeto com o número de cada região de mesma cor")
54
+ s.add_argument("arquivo")
55
+ s.add_argument("--vista", default="cima", choices=["cima", "baixo", "frente", "tras", "esquerda", "direita"])
56
+ s.add_argument("--objeto")
57
+ s.add_argument("--regiao", help="zoom em mm: x0,y0,x1,y1")
58
+ s.add_argument("--png", help="onde salvar a imagem (padrão: <nome>_ilhas_<vista>.png ao lado)")
59
+
60
+ s = sub.add_parser("repintar", help="grava um 3MF novo com ilhas em outro slot")
61
+ s.add_argument("arquivo")
62
+ s.add_argument("mudancas", help="ilha=slot separados por vírgula, ex.: 32=4,3=3")
63
+ s.add_argument("--objeto")
64
+ s.add_argument("--saida")
65
+
66
+ s = sub.add_parser("ace", help="mostra ou altera os slots do ACE")
67
+ acoes = s.add_subparsers(dest="acao", required=True)
68
+ acoes.add_parser("mostrar")
69
+ d = acoes.add_parser("definir", help="usa um projeto salvo no slicer (com o ACE sincronizado) como molde")
70
+ d.add_argument("projeto")
71
+ t = acoes.add_parser("slot", help="troca cor/tipo/nome de um slot")
72
+ t.add_argument("numero", type=int)
73
+ t.add_argument("--cor")
74
+ t.add_argument("--tipo")
75
+ t.add_argument("--nome")
76
+
77
+ a = p.parse_args(argv)
78
+ if a.comando == "inspecionar":
79
+ _mostrar(ler_projeto(a.arquivo).resumo())
80
+ elif a.comando == "planejar":
81
+ _mostrar(planejar(ler_projeto(a.arquivo), modulo_ace.carregar(), _mapa(a.mapa)).resumo())
82
+ elif a.comando == "converter":
83
+ resultado = converter(a.arquivo, a.saida, _mapa(a.mapa))
84
+ dados = resultado.resumo()
85
+ if not a.sem_verificar:
86
+ dados["verificacao"] = verificar(resultado.destino)
87
+ _mostrar(dados)
88
+ elif a.comando == "verificar":
89
+ _mostrar(verificar(a.arquivo))
90
+ elif a.comando == "ilhas":
91
+ regiao = [float(x) for x in a.regiao.split(",")] if a.regiao else None
92
+ png, info = ilhas.mapa_de_ilhas(a.arquivo, a.objeto, a.vista, regiao)
93
+ destino = Path(a.png) if a.png else Path(a.arquivo).with_name(f"{Path(a.arquivo).stem}_ilhas_{a.vista}.png")
94
+ destino.write_bytes(png)
95
+ _mostrar({"imagem": str(destino), **info})
96
+ elif a.comando == "repintar":
97
+ resultado = ilhas.repintar_ilhas(a.arquivo, _mapa(a.mudancas), a.objeto, a.saida)
98
+ resultado["verificacao"] = verificar(resultado["arquivo_repintado"])
99
+ _mostrar(resultado)
100
+ elif a.comando == "ace":
101
+ if a.acao == "mostrar":
102
+ _mostrar(modulo_ace.carregar().resumo())
103
+ elif a.acao == "definir":
104
+ _mostrar(modulo_ace.definir_molde(a.projeto).resumo())
105
+ else:
106
+ _mostrar(modulo_ace.alterar_slot(a.numero, a.cor, a.tipo, a.nome).resumo())
107
+ return 0
108
+
109
+
110
+ if __name__ == "__main__":
111
+ raise SystemExit(main())
conversor3mf/ace.py ADDED
@@ -0,0 +1,203 @@
1
+ """Molde de configuração da impressora e slots do ACE.
2
+
3
+ O molde é tirado de um projeto salvo pelo próprio Anycubic Slicer Next depois de
4
+ sincronizar o ACE: ele já traz impressora, perfis de filamento e a ordem real
5
+ dos carretéis. O arquivo `dados/meu_ace.json` guarda os slots e pode ser
6
+ editado à mão (por exemplo, para trocar a cor de um slot sem salvar outro molde).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import re
13
+ import zipfile
14
+ from dataclasses import asdict, dataclass
15
+ from pathlib import Path
16
+
17
+ from . import cores
18
+ from .config import pasta_dados, pasta_perfis
19
+
20
+ __all__ = ["Slot", "Ace", "definir_molde", "carregar", "alterar_slot", "aplicar_slots"]
21
+
22
+ ARQUIVO_ACE = "meu_ace.json"
23
+ ARQUIVO_MOLDE = "molde_project_settings.json"
24
+
25
+ # Espaçamento entre placas medido em projetos salvos pelo Anycubic Slicer Next
26
+ ESPACAMENTO_PLACAS = {"Anycubic Kobra X": (314.4, 328.8)}
27
+
28
+
29
+ @dataclass
30
+ class Slot:
31
+ slot: int
32
+ cor: str
33
+ tipo: str
34
+ nome: str
35
+ perfil: str
36
+
37
+
38
+ @dataclass
39
+ class Ace:
40
+ slots: list[Slot]
41
+ molde: dict
42
+ origem: str
43
+
44
+ @property
45
+ def impressora(self) -> str:
46
+ return self.molde.get("printer_model", "?")
47
+
48
+ def resumo(self) -> dict:
49
+ return {
50
+ "impressora": self.impressora,
51
+ "molde_tirado_de": self.origem,
52
+ "slots": [asdict(s) for s in self.slots],
53
+ }
54
+
55
+
56
+ def _chaves_de_filamento() -> set[str]:
57
+ """Todas as chaves que aparecem em perfis de filamento do slicer."""
58
+ chaves = {"filament_colour", "filament_colour_info", "filament_settings_id", "default_filament_colour"}
59
+ perfis = pasta_perfis()
60
+ for arquivo in (perfis / "filament").glob("*.json") if perfis else []:
61
+ try:
62
+ chaves |= set(json.loads(arquivo.read_text(encoding="utf8")))
63
+ except (OSError, ValueError):
64
+ continue
65
+ return chaves - {"name", "inherits", "from", "type", "setting_id", "instantiation", "version",
66
+ "compatible_printers", "compatible_printers_condition", "is_custom_defined",
67
+ "filament_id"}
68
+
69
+
70
+ def perfil_de_processo(nome: str) -> dict | None:
71
+ perfis = pasta_perfis()
72
+ caminho = perfis / "process" / f"{nome}.json" if perfis else None
73
+ if caminho is None or not caminho.exists():
74
+ return None
75
+ perfil = json.loads(caminho.read_text(encoding="utf8"))
76
+ if perfil.get("inherits"):
77
+ base = perfil_de_processo(perfil["inherits"]) or {}
78
+ perfil = {**base, **perfil}
79
+ return perfil
80
+
81
+
82
+ CHAVES_META = {"type", "from", "setting_id", "is_custom_defined", "instantiation", "version",
83
+ "inherits", "name", "compatible_printers", "compatible_printers_condition"}
84
+
85
+
86
+ def definir_molde(projeto_3mf: str | Path) -> Ace:
87
+ """Lê um projeto salvo pelo Anycubic Slicer Next e grava molde + slots em dados/."""
88
+ projeto_3mf = Path(projeto_3mf)
89
+ with zipfile.ZipFile(projeto_3mf) as arquivo:
90
+ config = json.loads(arquivo.read("Metadata/project_settings.config"))
91
+ principal = arquivo.read("3D/3dmodel.model").decode("utf8")
92
+ embutidos = [n for n in arquivo.namelist()
93
+ if re.match(r"Metadata/(machine|filament|process)_settings_\d+\.config$", n)]
94
+ if not str(config.get("printer_model", "")).startswith("Anycubic"):
95
+ raise ValueError(f"o projeto é de {config.get('printer_model')}, preciso de um salvo para a impressora Anycubic")
96
+ diferencas = config.get("different_settings_to_system") or []
97
+ if diferencas and diferencas[-1]:
98
+ raise ValueError(
99
+ "o perfil de impressora desse projeto foi modificado "
100
+ f"({diferencas[-1][:120]}). Salve um projeto usando o perfil de sistema da impressora."
101
+ )
102
+ if any("machine_settings" in n for n in embutidos):
103
+ # projetos com perfil de impressora embutido podem carregar G-code de outra máquina
104
+ raise ValueError("o projeto traz um perfil de impressora personalizado embutido; use outro projeto")
105
+
106
+ # processo volta ao perfil de fábrica; as escolhas do modelo entram na conversão
107
+ perfil = perfil_de_processo(config.get("print_settings_id", ""))
108
+ if perfil:
109
+ for chave, valor in perfil.items():
110
+ if chave not in CHAVES_META and chave in config:
111
+ config[chave] = valor
112
+ if diferencas:
113
+ diferencas[0] = ""
114
+ app = re.search(r'<metadata name="Application">([^<]*)<', principal)
115
+ config["_aplicativo"] = app.group(1) if app else f"BambuStudio-{config.get('version', '2.0.0.3')}"
116
+
117
+ n = len(config.get("filament_colour") or [])
118
+ slots = [
119
+ Slot(
120
+ slot=i + 1,
121
+ cor=cores.normalizar(config["filament_colour"][i]),
122
+ tipo=config["filament_type"][i],
123
+ nome=cores.nome_da_cor(config["filament_colour"][i]),
124
+ perfil=config["filament_settings_id"][i],
125
+ )
126
+ for i in range(n)
127
+ ]
128
+ pasta_dados().mkdir(parents=True, exist_ok=True)
129
+ (pasta_dados() / ARQUIVO_MOLDE).write_text(json.dumps(config, indent=1, ensure_ascii=False), encoding="utf8")
130
+ ace = Ace(slots=slots, molde=config, origem=str(projeto_3mf))
131
+ salvar_slots(ace)
132
+ return ace
133
+
134
+
135
+ def salvar_slots(ace: Ace) -> None:
136
+ dados = {
137
+ "_leia_me": "Slots do ACE na ordem da impressora. Pode editar cor e nome; "
138
+ "para mudar o tipo use um tipo que já exista em algum slot do molde.",
139
+ "molde_tirado_de": ace.origem,
140
+ "slots": [asdict(s) for s in ace.slots],
141
+ }
142
+ pasta_dados().mkdir(parents=True, exist_ok=True)
143
+ (pasta_dados() / ARQUIVO_ACE).write_text(json.dumps(dados, indent=2, ensure_ascii=False), encoding="utf8")
144
+
145
+
146
+ def carregar() -> Ace:
147
+ caminho_molde = pasta_dados() / ARQUIVO_MOLDE
148
+ caminho_ace = pasta_dados() / ARQUIVO_ACE
149
+ if not caminho_molde.exists() or not caminho_ace.exists():
150
+ raise FileNotFoundError(
151
+ "ainda não há molde do ACE. Salve um projeto no Anycubic Slicer Next com o ACE "
152
+ "sincronizado e rode: python -m conversor3mf ace definir <projeto.3mf>"
153
+ )
154
+ molde = json.loads(caminho_molde.read_text(encoding="utf8"))
155
+ dados = json.loads(caminho_ace.read_text(encoding="utf8"))
156
+ slots = [Slot(**{**s, "cor": cores.normalizar(s["cor"])}) for s in dados["slots"]]
157
+ tipos_no_molde = set(molde.get("filament_type") or [])
158
+ for s in slots:
159
+ if s.tipo not in tipos_no_molde:
160
+ raise ValueError(
161
+ f"slot {s.slot} está como {s.tipo}, mas o molde só tem {sorted(tipos_no_molde)}. "
162
+ "Salve um projeto no slicer com esse filamento e defina o molde de novo."
163
+ )
164
+ return Ace(slots=slots, molde=molde, origem=dados.get("molde_tirado_de", "?"))
165
+
166
+
167
+ def alterar_slot(numero: int, cor: str | None = None, tipo: str | None = None, nome: str | None = None) -> Ace:
168
+ """Atualiza um slot depois de trocar o carretel; o tipo precisa existir no molde."""
169
+ ace = carregar()
170
+ slot = next((s for s in ace.slots if s.slot == numero), None)
171
+ if slot is None:
172
+ raise ValueError(f"slot {numero} não existe")
173
+ if tipo:
174
+ tipos = sorted(set(ace.molde.get("filament_type") or []))
175
+ if tipo not in tipos:
176
+ raise ValueError(
177
+ f"o molde só tem {tipos}. Para usar {tipo}, salve um projeto no slicer com esse "
178
+ "filamento em algum slot e defina o molde de novo."
179
+ )
180
+ slot.tipo = tipo
181
+ if cor:
182
+ slot.cor = cores.normalizar(cor)
183
+ slot.nome = cores.nome_da_cor(slot.cor)
184
+ if nome:
185
+ slot.nome = nome
186
+ salvar_slots(ace)
187
+ return ace
188
+
189
+
190
+ def aplicar_slots(config: dict, ace: Ace) -> None:
191
+ """Ajusta as listas por filamento do config para refletir os slots (cor e tipo)."""
192
+ chaves = _chaves_de_filamento()
193
+ n = len(ace.slots)
194
+ tipos_molde = list(config.get("filament_type") or [])
195
+ original = {k: list(v) for k, v in config.items() if k in chaves and isinstance(v, list) and len(v) == n}
196
+ for i, slot in enumerate(ace.slots):
197
+ if i < len(tipos_molde) and tipos_molde[i] != slot.tipo:
198
+ fonte = tipos_molde.index(slot.tipo)
199
+ for chave, valores in original.items():
200
+ config[chave][i] = valores[fonte]
201
+ config["filament_colour"][i] = slot.cor
202
+ if "filament_colour_info" in config and i < len(config["filament_colour_info"]):
203
+ config["filament_colour_info"][i] = slot.cor
conversor3mf/config.py ADDED
@@ -0,0 +1,106 @@
1
+ """Onde ficam os dados do usuário e o Anycubic Slicer Next.
2
+
3
+ Variáveis de ambiente (opcionais):
4
+ - CONVERSOR3MF_DADOS: pasta do molde e dos slots do ACE
5
+ - CONVERSOR3MF_SLICER: pasta de instalação do Anycubic Slicer Next (ou o executável)
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ __all__ = ["pasta_dados", "pasta_slicer", "executavel_slicer", "pasta_perfis"]
15
+
16
+ NOME_APP = "conversor3mf"
17
+
18
+
19
+ def pasta_dados() -> Path:
20
+ if os.environ.get("CONVERSOR3MF_DADOS"):
21
+ return Path(os.environ["CONVERSOR3MF_DADOS"])
22
+ if sys.platform == "win32":
23
+ return Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming")) / NOME_APP
24
+ if sys.platform == "darwin":
25
+ return Path.home() / "Library" / "Application Support" / NOME_APP
26
+ return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / NOME_APP
27
+
28
+
29
+ def _do_registro() -> list[Path]:
30
+ if sys.platform != "win32":
31
+ return []
32
+ import winreg
33
+
34
+ achados = []
35
+ chaves = [
36
+ (winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"),
37
+ (winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall"),
38
+ (winreg.HKEY_CURRENT_USER, r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"),
39
+ ]
40
+ for raiz, caminho in chaves:
41
+ try:
42
+ pai = winreg.OpenKey(raiz, caminho)
43
+ except OSError:
44
+ continue
45
+ with pai:
46
+ for i in range(winreg.QueryInfoKey(pai)[0]):
47
+ try:
48
+ with winreg.OpenKey(pai, winreg.EnumKey(pai, i)) as filho:
49
+ nome = str(winreg.QueryValueEx(filho, "DisplayName")[0])
50
+ if "anycubicslicernext" not in nome.replace(" ", "").lower():
51
+ continue
52
+ for valor in ("DisplayIcon", "UninstallString", "InstallLocation"):
53
+ try:
54
+ texto = str(winreg.QueryValueEx(filho, valor)[0]).strip('" ').split(",")[0]
55
+ except OSError:
56
+ continue
57
+ if texto:
58
+ p = Path(texto)
59
+ achados.append(p.parent if p.suffix.lower() == ".exe" else p)
60
+ except OSError:
61
+ continue
62
+ return achados
63
+
64
+
65
+ def pasta_slicer() -> Path | None:
66
+ """Pasta de instalação do Anycubic Slicer Next, ou None se não encontrar."""
67
+ candidatos: list[Path] = []
68
+ escolhido = os.environ.get("CONVERSOR3MF_SLICER")
69
+ if escolhido is not None and escolhido.strip().lower() in ("", "nenhum", "none"):
70
+ return None # usado nos testes: finge que o slicer não está instalado
71
+ if escolhido:
72
+ p = Path(escolhido)
73
+ candidatos.append(p.parent if p.is_file() else p)
74
+ candidatos += _do_registro()
75
+ if sys.platform == "win32":
76
+ for base in (os.environ.get("ProgramFiles"), os.environ.get("ProgramFiles(x86)"), os.environ.get("LOCALAPPDATA")):
77
+ if base:
78
+ candidatos += [Path(base) / "AnycubicSlicerNext", Path(base) / "Programs" / "AnycubicSlicerNext"]
79
+ elif sys.platform == "darwin":
80
+ candidatos.append(Path("/Applications/AnycubicSlicerNext.app"))
81
+ for c in candidatos:
82
+ if executavel_slicer(c) is not None:
83
+ return c
84
+ return None
85
+
86
+
87
+ def executavel_slicer(pasta: Path | None = None) -> Path | None:
88
+ pasta = pasta if pasta is not None else pasta_slicer()
89
+ if pasta is None:
90
+ return None
91
+ for p in (pasta / "AnycubicSlicerNext.exe", pasta / "Contents" / "MacOS" / "AnycubicSlicerNext"):
92
+ if p.is_file():
93
+ return p
94
+ return None
95
+
96
+
97
+ def pasta_perfis() -> Path | None:
98
+ """Pasta com os perfis de sistema da Anycubic (processo, filamento, impressora)."""
99
+ pasta = pasta_slicer()
100
+ if pasta is None:
101
+ return None
102
+ for p in (pasta / "resources" / "profiles" / "Anycubic",
103
+ pasta / "Contents" / "Resources" / "profiles" / "Anycubic"):
104
+ if p.is_dir():
105
+ return p
106
+ return None