Files
pdm/creoparams/extract.py
T
davideandClaude Opus 5 b59380fcab Anteprime, decompressione LZW e contenuto dei disegni
Aggiunge tre capacita' al lettore, tutte senza dipendenze esterne.

Anteprima incorporata (preview.py)
    Ogni file contiene una miniatura JPEG dell'ultimo salvataggio: il
    render del pezzo nei modelli, l'immagine della tavola nei disegni.
    Non e' referenziata dall'indice, quindi va cercata per firma: ogni
    candidato viene validato percorrendone i segmenti JPEG, cosi' i byte
    che iniziano per FFD8FF per caso non passano. Sui file campione il
    risultato e' esattamente una immagine per file.

Decompressione compress(1) (lzw.py)
    Creo comprime alcuni blocchi nel vecchio formato .Z. Rispetto a un
    LZW generico va riprodotto l'allineamento a gruppi di otto codici:
    senza, la decompressione produce qualche centinaio di byte corretti
    e poi diverge silenziosamente.

Contenuto dei disegni (drawing.py)
    Un .drw non ha la tabella parametri del modello ma contiene la
    rappresentazione vettoriale della tavola. Ne estraiamo i testi:
    cartiglio, note, tolleranze, quote. Le tavole diventano cosi'
    ricercabili per contenuto. Nel disegno i valori compaiono gia'
    risolti come testo, compreso il peso, mentre nel .prt sono ancora
    in codifica binaria.

I blocchi oltre l'indice sono ora interpretati da ugc.iter_blocks, che
decomprime quando serve: lo stesso blocco puo' essere memorizzato
compresso in un file e in chiaro in un altro, quindi la scelta va fatta
guardando i byte e non il nome. Cercare soltanto gli stream compressi
faceva perdere i testi su un terzo dei disegni.

Sulla codifica dei numeri: sono double IEEE big-endian con i byte bassi
omessi, preceduti da un byte-tag. Lo dimostrano i disegni, dove size_x e
size_y valgono 420,0 x 297,0 (un A3 esatto). La mappatura del byte-tag
resta pero' incerta, quindi PESO e DENSITA continuano a essere riportati
come encoded_not_decoded: un valore plausibile ma sbagliato sarebbe
peggio di un valore assente.

La geometria 3D resta fuori portata, e ora e' verificato: in 200 KB di
blocchi geometrici non compare una sola costante 1.0 in nessuna codifica
in virgola mobile. Quei blocchi sono compressi con un algoritmo che non
espone firme note.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 14:51:39 +02:00

182 lines
6.5 KiB
Python

"""Costruzione del record di metadati a partire da un file nativo Creo."""
from __future__ import annotations
import hashlib
import os
import re
from collections import defaultdict
from datetime import datetime, timezone
from . import drawing, params, preview, ugc
SCHEMA_VERSION = 1
METHOD = "ugc_native_reader"
#: Prefissi dei parametri generati da Creo (impostazioni lamiera, materiale di
#: sistema, ...). Restano nell'output ma marcati come "system", per non
#: annegare i parametri aziendali nel rumore.
SYSTEM_PREFIXES = ("PTC_", "SMT_", "PRO_", "__")
#: Nome file Creo con numero di versione: "9258400201.prt.12".
_NAME_RE = re.compile(r"^(?P<stem>.+?)\.(?P<ext>prt|asm|drw|frm|lay)(?:\.(?P<ver>\d+))?$", re.I)
def split_filename(filename: str) -> tuple[str, str, int | None]:
""""9258400201.prt.12" -> ("9258400201", "prt", 12)."""
match = _NAME_RE.match(filename)
if not match:
stem, _, ext = filename.rpartition(".")
return (stem or filename), ext.lower(), None
version = match.group("ver")
return match.group("stem"), match.group("ext").lower(), int(version) if version else None
def _confidence(values: set, occurrences: int, sections: set) -> float:
if len(values) > 1:
return 0.40
if len(sections) >= 2:
return 0.99
if occurrences >= 2:
return 0.95
return 0.80
def _aggregate(occurrences: list) -> dict:
"""Raggruppa le comparse per nome e assegna stato e confidenza."""
grouped = defaultdict(list)
for occurrence in occurrences:
grouped[occurrence.name].append(occurrence)
result = {}
for name, group in grouped.items():
kind = group[0].kind
sections = {o.section for o in group if o.section}
# Solo i parametri del modello compaiono nella tabella estesa; quelli
# che vivono unicamente nella copia neutra appartengono alle feature
# (fori, lavorazioni), non al modello.
owner = "model" if any(o.form == "extended" for o in group) else "feature"
entry = {
"type": kind,
"owner": owner,
"scope": "system" if name.startswith(SYSTEM_PREFIXES) else "user",
"occurrences": len(group),
"sections": sorted(sections),
}
if kind == "real":
raws = sorted({o.raw.hex() for o in group if o.raw})
expressions = sorted({o.expression for o in group if o.expression})
entry.update(
value=None,
status="encoded_not_decoded",
confidence=None,
raw=raws[0] if len(raws) == 1 else raws,
)
if expressions:
# Il valore non e' costante: lo produce una relazione.
entry["driven_by"] = expressions[0] if len(expressions) == 1 else expressions
else:
values = {o.value for o in group}
entry["confidence"] = _confidence(values, len(group), sections)
if len(values) == 1:
entry.update(value=group[0].value, status="found")
else:
entry.update(
value=None,
status="conflict",
candidates=sorted(values),
)
result[name] = entry
return dict(sorted(result.items()))
def extract(path: str, expected: "list[str] | None" = None) -> dict:
"""Estrae i metadati da un singolo file. Solleva ugc.NotACreoFile se il
file non e' un nativo Creo riconoscibile."""
with open(path, "rb") as handle:
data = handle.read()
container = ugc.parse(data)
thumbnail = preview.find(data)
occurrences = params.iter_occurrences(data)
for occurrence in occurrences:
occurrence.section = container.section_at(occurrence.offset)
filename = os.path.basename(path)
stem, ext, version = split_filename(filename)
parameters = _aggregate(occurrences)
# I disegni non contengono la tabella parametri del modello: segnalarne
# l'assenza come "parametro mancante" sarebbe fuorviante.
holds_parameters = container.base_kind in ("PART", "ASSEMBLY")
if holds_parameters:
for name in expected or []:
parameters.setdefault(
name, {"value": None, "status": "parameter_not_present", "type": None}
)
record = {
"schema_version": SCHEMA_VERSION,
"source_file": os.path.abspath(path),
"file": {
"name": filename,
"size": len(data),
"sha256": hashlib.sha256(data).hexdigest(),
"modified": datetime.fromtimestamp(
os.path.getmtime(path), timezone.utc
).isoformat(),
"creo_version_number": version,
},
"model": {
"name": container.common_name.rsplit(".", 1)[0] or stem,
"file_name": container.common_name or f"{stem}.{ext}",
"kind": container.base_kind,
"subtype": container.subtype,
"creo_version": container.creo_version,
"banner": container.banner,
},
"extraction": {
"method": METHOD,
"extracted_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"sections": len(container.sections),
"parameters_found": len(occurrences),
"holds_model_parameters": holds_parameters,
},
# Metadati dell'anteprima: i byte dell'immagine restano fuori dal
# record, si ottengono con read_preview().
"preview": thumbnail.to_dict() if thumbnail else {"present": False},
"parameters": parameters,
}
# Un disegno non ha parametri di modello, ma ha il contenuto della tavola.
if container.base_kind == "DRAWING":
record["drawing"] = drawing.read(data)
return record
def read_preview(path: str):
"""L'anteprima incorporata nel file, o None se assente.
>>> shot = read_preview("9258400201.prt.12")
>>> shot.save("anteprima.jpg")
"""
with open(path, "rb") as handle:
return preview.find(handle.read())
def latest_versions(paths: "list[str]") -> "list[str]":
"""Tra piu' versioni dello stesso modello tiene solo la piu' recente."""
best: dict = {}
for path in paths:
stem, ext, version = split_filename(os.path.basename(path))
key = (os.path.dirname(os.path.abspath(path)), stem, ext)
current = best.get(key)
if current is None or (version or -1) > (current[0] or -1):
best[key] = (version, path)
return sorted(path for _, path in best.values())