Files
pdm/creoparams/ugc.py
T
davideandClaude Opus 5 bc84407a6f 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>
2026-08-06 11:45:39 +02:00

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())