sldprt2xt 1.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.
sldprt2xt/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ # Copyright (C) 2026 Emilien-Etadam
3
+
4
+ """Extraire la géométrie Parasolid d'un fichier SolidWorks, sans SolidWorks.
5
+
6
+ from sldprt2xt import to_x_t
7
+
8
+ to_x_t("piece.SLDPRT") # écrit piece.x_t à côté
9
+ to_x_t("piece.SLDPRT", "sorties/") # ailleurs
10
+ """
11
+
12
+ from .convert import ConversionError, bodies_in, to_x_t
13
+ from .schemas import SchemasNotFound, find_folder
14
+
15
+ __version__ = "1.1.0"
16
+ __all__ = [
17
+ "ConversionError",
18
+ "SchemasNotFound",
19
+ "__version__",
20
+ "bodies_in",
21
+ "find_folder",
22
+ "to_x_t",
23
+ ]
sldprt2xt/builtin.py ADDED
@@ -0,0 +1,121 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ # Copyright (C) 2026 Emilien-Etadam
3
+
4
+ """Les schémas que le paquet porte en lui — l'autonomie.
5
+
6
+ Trois sources, du plus sûr au plus souple :
7
+
8
+ - :func:`base_schema` — le schéma de base 13006, bâti depuis
9
+ :mod:`sldprt2xt.schema_facts`. Suffit à décoder n'importe quel transmit :
10
+ le fichier épisse lui-même la disposition de chaque type de nœud.
11
+ - :func:`envelope_for` — les dispositions ASSEMBLY/INSTANCE/TRANSFORM de la
12
+ version demandée, si cette version est dans la table. C'est ce que
13
+ l'écriture multi-corps exige et que rien dans une partition ne porte.
14
+ - :func:`envelope_from_x_t` — les mêmes dispositions, apprises d'un ``.x_t``
15
+ donneur exporté par le même SolidWorks : un export multi-corps greffe ces
16
+ dispositions dans son propre texte. C'est la sortie pour une version plus
17
+ récente que la table.
18
+
19
+ Un fichier de schéma déposé prime toujours sur tout ceci — voir
20
+ :mod:`sldprt2xt.convert`.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from functools import cache
26
+ from pathlib import Path
27
+
28
+ from .schema_facts import BASE_KEY, BASE_NODES, ENVELOPE_BY_VERSION, ENVELOPE_FAMILIES
29
+ from .xt.schema import FieldSpec, NodeSpec, Schema
30
+
31
+ #: Les trois types de l'enveloppe, dans l'ordre des familles de la table.
32
+ _ASSEMBLY, _INSTANCE, _TRANSFORM = 10, 11, 100
33
+ _ENVELOPE_NAMES = {_ASSEMBLY: "ASSEMBLY", _INSTANCE: "INSTANCE", _TRANSFORM: "TRANSFORM"}
34
+
35
+
36
+ def _fields(rows) -> tuple[FieldSpec, ...]:
37
+ return tuple(
38
+ FieldSpec(name=name, type=kind, transmitted=sent, ptr_class=at, n_elts=n)
39
+ for name, kind, sent, at, n in rows
40
+ )
41
+
42
+
43
+ @cache
44
+ def base_schema() -> Schema:
45
+ """Le schéma de base 13006, sans fichier."""
46
+ nodes = {
47
+ node_type: NodeSpec(
48
+ node_type=node_type,
49
+ name=name,
50
+ description="",
51
+ transmitted=sent,
52
+ variable=variable,
53
+ fields=_fields(rows),
54
+ )
55
+ for node_type, (name, sent, variable, rows) in BASE_NODES.items()
56
+ }
57
+ return Schema(key=BASE_KEY, modeller_version=None, nodes=nodes)
58
+
59
+
60
+ @cache
61
+ def envelope_for(version: str | int) -> Schema | None:
62
+ """Les dispositions d'enveloppe de *version*, ou ``None`` si inconnue.
63
+
64
+ Le schéma rendu ne porte que les trois types de l'enveloppe : c'est tout
65
+ ce que l'écriture demande, et le recoupement de ``verify`` ne s'applique
66
+ alors qu'à eux — exactement ceux que nous venons d'écrire.
67
+ """
68
+ try:
69
+ family = ENVELOPE_BY_VERSION[int(version)]
70
+ except (KeyError, ValueError):
71
+ return None
72
+ return _envelope(f"SCH_{version}", ENVELOPE_FAMILIES[family])
73
+
74
+
75
+ def envelope_from_x_t(path: str | Path) -> Schema:
76
+ """Les dispositions d'enveloppe apprises d'un ``.x_t`` donneur.
77
+
78
+ Le donneur doit être un export SolidWorks portant une enveloppe — un
79
+ multi-corps en porte toujours une. Ses greffes déclarent les dispositions
80
+ exactes que sa version écrit ; on les lit au lieu d'exiger la table.
81
+ """
82
+ from .parasolid import TransmitError, read_text_transmit
83
+ from .xt import text as xt_text
84
+
85
+ data = Path(path).read_bytes()
86
+ transmit = read_text_transmit(data)
87
+ body = transmit.data[transmit.body_offset :].decode("latin-1")
88
+ layouts: dict[int, list[FieldSpec]] = {}
89
+ xt_text.parse_nodes(body, base_schema(), layouts=layouts)
90
+
91
+ missing = [t for t in (_ASSEMBLY, _INSTANCE, _TRANSFORM) if t not in layouts]
92
+ if missing:
93
+ raise TransmitError(
94
+ f"{Path(path).name} : pas d'enveloppe ASSEMBLY dans ce donneur — "
95
+ "prenez un .x_t multi-corps exporté par le même SolidWorks"
96
+ )
97
+ return _envelope(
98
+ f"SCH_{transmit.version_schema}",
99
+ tuple(
100
+ tuple(
101
+ (f.name, f.type, f.transmitted, f.ptr_class, f.n_elts)
102
+ for f in layouts[t]
103
+ )
104
+ for t in (_ASSEMBLY, _INSTANCE, _TRANSFORM)
105
+ ),
106
+ )
107
+
108
+
109
+ def _envelope(key: str, family) -> Schema:
110
+ nodes = {
111
+ node_type: NodeSpec(
112
+ node_type=node_type,
113
+ name=_ENVELOPE_NAMES[node_type],
114
+ description="",
115
+ transmitted=True,
116
+ variable=False,
117
+ fields=_fields(rows),
118
+ )
119
+ for node_type, rows in zip((_ASSEMBLY, _INSTANCE, _TRANSFORM), family, strict=True)
120
+ }
121
+ return Schema(key=key, modeller_version=None, nodes=nodes)
sldprt2xt/carve.py ADDED
@@ -0,0 +1,176 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ # Copyright (C) 2026 Emilien-Etadam
3
+
4
+ """Find and inflate compressed regions inside a container blob.
5
+
6
+ Two mechanisms are known:
7
+
8
+ *wrapped sections*
9
+ A 16-byte magic followed by an uncompressed size, a compressed size and
10
+ a zlib member. This is what sits inside ``__ZLB`` compound streams and
11
+ what splits a ``Config-N-Partition`` stream into its transmit sections.
12
+
13
+ *bare zlib members*
14
+ Everything else. We scan for zlib headers and keep whatever inflates to
15
+ a non-trivial length. This is a discovery aid, deliberately noisy: it is
16
+ for looking at an unfamiliar stream, not for production extraction.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import struct
22
+ import zlib
23
+ from dataclasses import dataclass
24
+
25
+ from .container import SECTION_MAGIC
26
+
27
+ ZLIB_HEADERS = (b"\x78\x01", b"\x78\x9c", b"\x78\xda", b"\x78\x5e")
28
+
29
+
30
+ @dataclass
31
+ class Region:
32
+ """One inflated region of a blob."""
33
+
34
+ #: ``"section"`` (magic-framed) or ``"zlib"`` (bare scan hit).
35
+ kind: str
36
+ #: Offset of the region's frame within the containing blob.
37
+ offset: int
38
+ data: bytes
39
+ #: Fields read from the frame, for the record.
40
+ header: dict[str, int]
41
+
42
+ @property
43
+ def size(self) -> int:
44
+ return len(self.data)
45
+
46
+
47
+ def carve_sections(blob: bytes) -> list[Region]:
48
+ """Inflate every magic-framed section found in ``blob``.
49
+
50
+ A section is a length-prefixed run of independently compressed members::
51
+
52
+ -0x04 u32 LE section length, counted from the magic
53
+ +0x00 16 B SECTION_MAGIC
54
+ +0x10 member, member, …, then a (0, 0) header to close the run
55
+
56
+ and each member is::
57
+
58
+ u32 LE uncompressed size
59
+ u32 LE compressed size
60
+ zlib member of exactly that many bytes
61
+
62
+ **Members must all be inflated and concatenated.** A small part fits in
63
+ one, which is why a single-member reading appears to work; a large one is
64
+ split into many, and stopping at the first truncates the payload without
65
+ any error — the exact failure this project cannot afford.
66
+ """
67
+ regions: list[Region] = []
68
+ pos = blob.find(SECTION_MAGIC)
69
+ while pos != -1:
70
+ at = pos + len(SECTION_MAGIC)
71
+
72
+ # The length prefix bounds the member run. Without it — a magic at the
73
+ # very start of a blob — fall back to the end of the blob and let the
74
+ # member headers say where to stop.
75
+ end = len(blob)
76
+ if pos >= 4:
77
+ declared = struct.unpack_from("<I", blob, pos - 4)[0]
78
+ if 0 < declared <= len(blob) - pos + 4:
79
+ end = min(end, pos - 4 + 4 + declared)
80
+
81
+ parts: list[bytes] = []
82
+ members = 0
83
+ while at + 8 <= end:
84
+ uncomp_sz, comp_sz = struct.unpack_from("<II", blob, at)
85
+ if uncomp_sz == 0 and comp_sz == 0:
86
+ at += 8
87
+ break
88
+ if comp_sz == 0 or at + 8 + comp_sz > end:
89
+ break
90
+ try:
91
+ raw = zlib.decompress(blob[at + 8 : at + 8 + comp_sz])
92
+ except zlib.error:
93
+ break
94
+ if len(raw) != uncomp_sz:
95
+ break
96
+ parts.append(raw)
97
+ members += 1
98
+ at += 8 + comp_sz
99
+
100
+ if parts:
101
+ regions.append(
102
+ Region(
103
+ kind="section",
104
+ offset=pos,
105
+ data=b"".join(parts),
106
+ header={
107
+ "members": members,
108
+ "uncompressed": sum(len(p) for p in parts),
109
+ "consumed": at - pos,
110
+ },
111
+ )
112
+ )
113
+ pos = blob.find(SECTION_MAGIC, at)
114
+ continue
115
+ pos = blob.find(SECTION_MAGIC, pos + 1)
116
+ return regions
117
+
118
+
119
+ def carve_zlib(blob: bytes, min_size: int = 64, limit: int = 64) -> list[Region]:
120
+ """Scan for bare zlib members and inflate them.
121
+
122
+ Returns at most ``limit`` regions of at least ``min_size`` inflated bytes.
123
+ Overlapping hits are suppressed: a member found inside an already-inflated
124
+ span is skipped.
125
+ """
126
+ regions: list[Region] = []
127
+ consumed_until = 0
128
+ pos = 0
129
+ while pos < len(blob) and len(regions) < limit:
130
+ nxt = min(
131
+ (p for p in (blob.find(h, pos) for h in ZLIB_HEADERS) if p != -1),
132
+ default=-1,
133
+ )
134
+ if nxt == -1:
135
+ break
136
+ if nxt < consumed_until:
137
+ pos = nxt + 1
138
+ continue
139
+ obj = zlib.decompressobj()
140
+ try:
141
+ raw = obj.decompress(blob[nxt:])
142
+ except zlib.error:
143
+ raw = b""
144
+ if len(raw) >= min_size:
145
+ used = len(blob) - nxt - len(obj.unused_data)
146
+ regions.append(
147
+ Region(
148
+ kind="zlib",
149
+ offset=nxt,
150
+ data=raw,
151
+ header={
152
+ "actual_uncompressed": len(raw),
153
+ "actual_compressed": used,
154
+ },
155
+ )
156
+ )
157
+ consumed_until = nxt + used
158
+ pos = consumed_until
159
+ else:
160
+ pos = nxt + 1
161
+ return regions
162
+
163
+
164
+ def carve(blob: bytes, *, scan_bare: bool = True) -> list[Region]:
165
+ """Inflate framed sections, then bare zlib members outside them."""
166
+ regions = carve_sections(blob)
167
+ if not scan_bare:
168
+ return regions
169
+ covered = [
170
+ (r.offset, r.offset + r.header.get("consumed", 0)) for r in regions
171
+ ]
172
+ for region in carve_zlib(blob):
173
+ if any(start <= region.offset < end for start, end in covered):
174
+ continue
175
+ regions.append(region)
176
+ return sorted(regions, key=lambda r: r.offset)
sldprt2xt/cli.py ADDED
@@ -0,0 +1,140 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ # Copyright (C) 2026 Emilien-Etadam
3
+
4
+ """La ligne de commande."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import glob as globbing
10
+ import sys
11
+ import time
12
+ from pathlib import Path
13
+
14
+ from .convert import ConversionError, to_x_t
15
+ from .schemas import SchemasNotFound, find_folder
16
+
17
+ USAGE = """\
18
+ sldprt2xt piece.SLDPRT le .x_t se pose a cote
19
+ sldprt2xt *.SLDPRT -o sorties/ plusieurs fichiers
20
+ sldprt2xt dossier/ -o sorties/ tout un dossier, sous-dossiers compris"""
21
+
22
+ #: SolidWorks pose un verrou ``~$piece.SLDPRT`` à côté de toute pièce ouverte.
23
+ #: Ce n'est pas une pièce, et un balayage qui le ramasse fait échouer le lot.
24
+ LOCK_PREFIX = "~$"
25
+
26
+
27
+ def parts_under(paths: list[Path]) -> list[tuple[Path, Path]]:
28
+ """Les pièces désignées, chacune avec son nom de sortie relatif.
29
+
30
+ Un dossier vaut tout ce qu'il contient, sous-dossiers compris — et la
31
+ hiérarchie suit : deux pièces de même nom dans deux sous-dossiers gardent
32
+ deux sorties. Les jokers sont développés ici parce que cmd et PowerShell
33
+ ne le font pas, et les verrous SolidWorks sont écartés des balayages ; un
34
+ fichier nommé explicitement passe tel quel, c'est lui le chef.
35
+ """
36
+ found: list[tuple[Path, Path]] = []
37
+ for path, named_explicitly in _expanded(paths):
38
+ if path.is_dir():
39
+ for part in sorted(path.rglob("*")):
40
+ if part.suffix.lower() != ".sldprt":
41
+ continue
42
+ if part.name.startswith(LOCK_PREFIX):
43
+ continue
44
+ found.append((part, part.relative_to(path).with_suffix(".x_t")))
45
+ elif named_explicitly or not path.name.startswith(LOCK_PREFIX):
46
+ found.append((path, Path(path.stem + ".x_t")))
47
+ return found
48
+
49
+
50
+ def _expanded(paths: list[Path]) -> list[tuple[Path, bool]]:
51
+ """Chaque argument, joker développé — avec qui fut nommé tel quel.
52
+
53
+ Un motif qui matche est un balayage (ses verrous s'écartent) ; un chemin
54
+ sans joker, ou un motif qui ne matche rien, reste l'argument de
55
+ l'utilisateur et sera traité — ou refusé — sous son propre nom.
56
+ """
57
+ out: list[tuple[Path, bool]] = []
58
+ for path in paths:
59
+ raw = str(path)
60
+ if any(mark in raw for mark in "*?[") and not path.exists():
61
+ matches = sorted(Path(found) for found in globbing.glob(raw))
62
+ if matches:
63
+ out.extend((match, False) for match in matches)
64
+ continue
65
+ out.append((path, True))
66
+ return out
67
+
68
+
69
+ def main(argv: list[str] | None = None) -> int:
70
+ parser = argparse.ArgumentParser(
71
+ prog="sldprt2xt",
72
+ description="Extraire la geometrie Parasolid d'un fichier SolidWorks.",
73
+ epilog=USAGE,
74
+ formatter_class=argparse.RawDescriptionHelpFormatter,
75
+ )
76
+ parser.add_argument("paths", nargs="+", type=Path, metavar="FICHIER")
77
+ parser.add_argument(
78
+ "-o", "--out", type=Path, metavar="DOSSIER", help="ou ecrire les .x_t"
79
+ )
80
+ parser.add_argument(
81
+ "--schemas", type=Path, metavar="DOSSIER", help="dossier des schemas Parasolid"
82
+ )
83
+ parser.add_argument(
84
+ "--donor",
85
+ type=Path,
86
+ metavar="FICHIER.x_t",
87
+ help="un .x_t multi-corps exporte par le meme SolidWorks, si la "
88
+ "version du fichier est plus recente que les tables integrees",
89
+ )
90
+ parser.add_argument(
91
+ "-q", "--quiet", action="store_true", help="ne rien dire si tout va bien"
92
+ )
93
+ args = parser.parse_args(argv)
94
+
95
+ try:
96
+ folder = find_folder(args.schemas)
97
+ except SchemasNotFound as missing:
98
+ print(missing, file=sys.stderr)
99
+ return 2
100
+
101
+ parts = parts_under(args.paths)
102
+ if not parts:
103
+ print("aucun fichier .SLDPRT la-dedans", file=sys.stderr)
104
+ return 1
105
+
106
+ done = failed = 0
107
+ written: dict[Path, Path] = {}
108
+ for part, relative in parts:
109
+ destination = args.out / relative if args.out else part.with_suffix(".x_t")
110
+ if destination in written:
111
+ print(
112
+ f"{part} : meme destination que {written[destination]} "
113
+ f"({destination}) — non ecrit",
114
+ file=sys.stderr,
115
+ )
116
+ failed += 1
117
+ continue
118
+ started = time.monotonic()
119
+ try:
120
+ result = to_x_t(part, destination, schemas=folder, donor=args.donor)
121
+ except ConversionError as failure:
122
+ print(f"{part.name} : {failure}", file=sys.stderr)
123
+ failed += 1
124
+ except Exception as failure: # un fichier en echec n'arrete pas le lot
125
+ print(f"{part.name} : {type(failure).__name__}: {failure}", file=sys.stderr)
126
+ failed += 1
127
+ else:
128
+ done += 1
129
+ written[destination] = part
130
+ if not args.quiet:
131
+ size = result.stat().st_size / 1e6
132
+ print(f"{result} {size:.2f} Mo ({time.monotonic() - started:.1f} s)")
133
+
134
+ if len(parts) > 1 and not args.quiet:
135
+ print(f"\n{done} converti(s), {failed} en echec")
136
+ return 1 if failed else 0
137
+
138
+
139
+ if __name__ == "__main__":
140
+ raise SystemExit(main())