Prototipo CLI in Python che estrae parametri e proprieta' dai file .prt senza avviare Creo e senza SDK commerciali. Solo libreria standard. I file nativi Creo sono container con header ASCII e un indice delle sezioni (UGC_TOC); la maggior parte delle sezioni non e' compressa e la tabella dei parametri e' leggibile direttamente. Ogni parametro compare in due rappresentazioni indipendenti (tabella estesa in LargeText e copia neutra in NeuPrtSld). Il lettore le confronta entrambe, e ne ricava: - confidence: 0.99 quando le due copie concordano, valori inferiori se il dato compare una volta sola o se divergono (status "conflict"); - owner: distingue i parametri del modello da quelli delle feature, che vivono solo nella copia neutra. I parametri numerici (PESO, DENSITA) usano una codifica a lunghezza variabile non ancora risolta: vengono riportati come "encoded_not_decoded" conservando i byte grezzi, senza inventare valori. Validato su file PART e PART/SHEETMETAL scritti da Creo 9.0.3.0. Assiemi, family table e versioni precedenti non sono ancora stati verificati. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
186 lines
5.7 KiB
Python
186 lines
5.7 KiB
Python
"""Lettura del container UGC dei file nativi Creo (.prt, .asm, .drw).
|
|
|
|
Un file nativo Creo si apre con un header ASCII in chiaro, seguito da un
|
|
indice delle sezioni (UGC_TOC) e dal corpo binario. Solo alcune sezioni sono
|
|
compresse: la tabella dei parametri non lo e', ed e' quello che sfruttiamo.
|
|
|
|
Struttura osservata su Creo 9.0.3.0:
|
|
|
|
#UGC:2 PART/SHEETMETAL 2038 1760 805 1 1 15 4000 2022414 000002d7 \\
|
|
#- VERS 0 0 \\
|
|
#- CMNM 00e9258400200.prt \\
|
|
#-END_OF_UGC_HEADER
|
|
#Creo TM 9.0 (c) 2026 by PTC Inc. All Rights Reserved. 9.0.3.0
|
|
#UGC_TOC 2 32 81 17###...
|
|
ND:0:Model_L05_PX:1 ab4 440 557 2038 a 1 b715 85d3###...
|
|
BasicData 2c3b 15767 1575c 2038 9 -1 e3ea 48f0###...
|
|
...
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
|
|
HEADER_END = b"#-END_OF_UGC_HEADER"
|
|
TOC_MARKER = b"#UGC_TOC"
|
|
MAGIC = b"#UGC:"
|
|
|
|
# Quanto leggiamo per cercare header e TOC. L'indice sta sempre nei primi KB.
|
|
_HEAD_WINDOW = 64 * 1024
|
|
|
|
_BANNER_RE = re.compile(r"^#(Creo|Pro/ENGINEER).*", re.IGNORECASE)
|
|
_VERSION_RE = re.compile(r"(\d+(?:\.\d+){1,3})\s*$")
|
|
|
|
|
|
@dataclass
|
|
class Section:
|
|
"""Una voce dell'indice UGC_TOC.
|
|
|
|
`alt_size` e' il terzo campo numerico. Per le sezioni compresse e' maggiore
|
|
di `size` (dimensione decompressa); per quelle non compresse e' leggermente
|
|
minore. Non lo interpretiamo oltre: ci serve solo come indizio di
|
|
compressione.
|
|
"""
|
|
|
|
name: str
|
|
offset: int
|
|
size: int
|
|
alt_size: int
|
|
extra: tuple = ()
|
|
|
|
@property
|
|
def end(self) -> int:
|
|
return self.offset + self.size
|
|
|
|
@property
|
|
def looks_compressed(self) -> bool:
|
|
return self.alt_size > self.size
|
|
|
|
def contains(self, pos: int) -> bool:
|
|
return self.offset <= pos < self.end
|
|
|
|
|
|
@dataclass
|
|
class Container:
|
|
"""Un file nativo Creo aperto e con header/TOC interpretati."""
|
|
|
|
data: bytes
|
|
kind: str = "" # PART, PART/SHEETMETAL, DRAWING, ASSEMBLY...
|
|
creo_version: str = "" # es. "9.0.3.0"
|
|
banner: str = "" # riga di copyright completa
|
|
common_name: str = "" # nome originale del modello (campo CMNM)
|
|
header: dict = field(default_factory=dict)
|
|
sections: list = field(default_factory=list)
|
|
|
|
@property
|
|
def base_kind(self) -> str:
|
|
return self.kind.split("/", 1)[0]
|
|
|
|
@property
|
|
def subtype(self) -> str:
|
|
parts = self.kind.split("/", 1)
|
|
return parts[1] if len(parts) > 1 else ""
|
|
|
|
def section_at(self, pos: int) -> str:
|
|
for section in self.sections:
|
|
if section.contains(pos):
|
|
return section.name
|
|
return ""
|
|
|
|
|
|
class NotACreoFile(ValueError):
|
|
"""Il file non ha un header UGC riconoscibile."""
|
|
|
|
|
|
def _decode_cmnm(raw: str) -> str:
|
|
"""Il campo CMNM e' prefissato da 3 cifre esadecimali con la lunghezza.
|
|
|
|
Esempio: "00e9258400200.prt" -> 0x00e = 14 = len("9258400200.prt").
|
|
Se il prefisso non torna, restituiamo il valore grezzo.
|
|
"""
|
|
if len(raw) > 3:
|
|
try:
|
|
declared = int(raw[:3], 16)
|
|
except ValueError:
|
|
return raw
|
|
if declared == len(raw) - 3:
|
|
return raw[3:]
|
|
return raw
|
|
|
|
|
|
def _parse_toc_line(line: str) -> Section | None:
|
|
# Le righe del TOC sono paddate a destra con '#'.
|
|
line = line.rstrip("#").strip()
|
|
if not line:
|
|
return None
|
|
parts = line.split()
|
|
if len(parts) < 4:
|
|
return None
|
|
try:
|
|
offset = int(parts[1], 16)
|
|
size = int(parts[2], 16)
|
|
alt_size = int(parts[3], 16)
|
|
except ValueError:
|
|
return None
|
|
return Section(parts[0], offset, size, alt_size, tuple(parts[4:]))
|
|
|
|
|
|
def parse(data: bytes) -> Container:
|
|
"""Interpreta header e indice. Non tocca il corpo binario."""
|
|
if not data.startswith(MAGIC):
|
|
raise NotACreoFile("header UGC assente (i primi byte non sono '#UGC:')")
|
|
|
|
head_end = data.find(HEADER_END)
|
|
if head_end < 0:
|
|
raise NotACreoFile("marcatore di fine header UGC non trovato")
|
|
|
|
container = Container(data=data)
|
|
window = data[: max(head_end + _HEAD_WINDOW, head_end)].decode(
|
|
"latin-1", errors="replace"
|
|
)
|
|
lines = window.split("\n")
|
|
|
|
in_toc = False
|
|
for index, line in enumerate(lines):
|
|
stripped = line.rstrip().rstrip("\\").rstrip()
|
|
|
|
if index == 0:
|
|
# "#UGC:2 PART/SHEETMETAL 2038 1760 ..."
|
|
tokens = stripped.split()
|
|
container.kind = tokens[1] if len(tokens) > 1 else ""
|
|
continue
|
|
|
|
if in_toc:
|
|
if stripped.startswith("#"):
|
|
break # fine dell'indice, inizia il corpo
|
|
section = _parse_toc_line(stripped)
|
|
if section:
|
|
container.sections.append(section)
|
|
# NEXT_TOC_ENTRY non punta a un secondo indice ASCII: il corpo
|
|
# prosegue con blocchi marcati inline. Ci fermiamo qui.
|
|
if section.name == "NEXT_TOC_ENTRY":
|
|
break
|
|
continue
|
|
|
|
if stripped.startswith("#- "):
|
|
key, _, value = stripped[3:].partition(" ")
|
|
value = value.strip()
|
|
container.header[key] = value
|
|
if key == "CMNM":
|
|
container.common_name = _decode_cmnm(value)
|
|
elif stripped.startswith(TOC_MARKER.decode()):
|
|
in_toc = True
|
|
elif _BANNER_RE.match(stripped):
|
|
container.banner = stripped.lstrip("#").strip()
|
|
match = _VERSION_RE.search(container.banner)
|
|
if match:
|
|
container.creo_version = match.group(1)
|
|
|
|
return container
|
|
|
|
|
|
def read(path) -> Container:
|
|
with open(path, "rb") as handle:
|
|
return parse(handle.read())
|