Lettore di metadati per file nativi Creo Parametric
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>
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
"""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())
|
||||
Reference in New Issue
Block a user