brickedit 5.0.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.
brickedit/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """BrickEdit is a python package for working with the .BRV and .BRM file formats, belonging to the game Brick Rigs.
2
+ It can read, write, and manipulate the contents of .BRV and .BRM files."""
3
+ from .vec import *
4
+ from .var import *
5
+ from .var import BRICKEDIT_VERSION_FULL as __version__
6
+ from .id import *
7
+ from .brick import *
8
+ from .brv import *
9
+ from .brm import *
10
+ from . import p
11
+ from . import bt
12
+ from . import vhelper
brickedit/brick.py ADDED
@@ -0,0 +1,146 @@
1
+ from copy import deepcopy
2
+ from typing import Optional, Self, Callable
3
+ from collections.abc import Hashable
4
+
5
+ from . import bt
6
+ from .id import ID as _ID
7
+ from .exceptions import BrickError
8
+ from .vec import Vec3
9
+
10
+
11
+ class Brick:
12
+
13
+ __slots__ = ('_meta', 'ref', 'pos', 'rot', 'ppatch')
14
+
15
+ def __init__(self,
16
+ ref: _ID,
17
+ meta: bt.BrickMeta,
18
+ pos: Optional[Vec3] = None,
19
+ rot: Optional[Vec3] = None,
20
+ ppatch: Optional[dict[str, Hashable]] = None
21
+ ):
22
+ self.ref = ref
23
+ self._meta = meta
24
+ self.pos = pos if pos is not None else Vec3(0, 0, 0)
25
+ self.rot = rot if rot is not None else Vec3(0, 0, 0)
26
+ self.ppatch: dict[str, Hashable] = {} if ppatch is None else ppatch
27
+
28
+ def meta(self) -> bt.BrickMeta:
29
+ """Returns the BrickMeta of this brick."""
30
+ return self._meta
31
+
32
+ def get_property(self, p: str) -> Hashable:
33
+ """Gets a property of the brick. If it has been modified, returns the modified value.
34
+ Otherwise, returns a deepcopy of the default value from the BrickMeta.
35
+
36
+ Args:
37
+ p (str): The name of the property to get.
38
+
39
+ Returns:
40
+ object: The value of the property.
41
+ """
42
+ # If the property key exists in the patch, return its stored value
43
+ # (including explicit None). Otherwise return a deepcopy of the
44
+ # default value from the BrickMeta.
45
+ if p in self.ppatch:
46
+ return self.ppatch[p]
47
+ if p not in self._meta.p:
48
+ raise BrickError(f"Property '{p}' does not exist on brick type '{self._meta.name()}'")
49
+ pobj = self._meta.p.get(p)
50
+ return deepcopy(pobj)
51
+
52
+ def set_property(self, p: str, v: Hashable) -> Self:
53
+ """Sets a property of the brick.
54
+
55
+ Args:
56
+ p (str): The name of the property to set.
57
+ v (object): The value to set the property to.
58
+
59
+ Returns:
60
+ Self: The Brick instance.
61
+ """
62
+ self.ppatch[p] = v
63
+ return self
64
+
65
+ def edit_property(self, p: str, lf: Callable[[Hashable], Hashable]) -> Self:
66
+ """
67
+ Edits a property of a brick using a lambda function.
68
+ BrickEdit counts None properties as not set -> ignored, goes to default.
69
+ You may use this to reset a property, however Brick.reset_property is usually preferred.
70
+
71
+ Args:
72
+ p (str): The name of the property to edit.
73
+ lf (Callable[[Hashable], Hashable]): A lambda function that takes the current property
74
+ value and returns the new property value.
75
+
76
+ Returns:
77
+ Self: The Brick instance.
78
+ """
79
+ self.ppatch[p] = lf(self.get_property(p))
80
+ return self
81
+
82
+ def reset_property(self, p: str) -> Self:
83
+ """Resets a property of the brick to its default value.
84
+
85
+ Args:
86
+ p (str): The name of the property to reset.
87
+
88
+ Returns:
89
+ Self: The Brick instance.
90
+ """
91
+ if p in self.ppatch:
92
+ del self.ppatch[p]
93
+ return self
94
+
95
+ def get_all_properties(self) -> dict[str, Hashable]:
96
+ """Returns a dictionary of all properties of the brick, including modified and default values.
97
+
98
+ Returns:
99
+ dict[str, Hashable]: A dictionary of all properties of the brick.
100
+ """
101
+ props = {}
102
+ for p in self._meta.p.keys():
103
+ props[p] = self.get_property(p)
104
+ return props
105
+
106
+ def __repr__(self) -> str:
107
+ return f'Brick({self.ref}, {self._meta.name()}, {self.pos!r}, {self.rot!r}, {self.ppatch})'
108
+
109
+ def __format__(self, spec) -> str:
110
+ """Format the Brick instance. Each character adds a "flag" that affects the output. Order does not matter.
111
+
112
+ 'f' for full: also display properties set to default values.
113
+ 'h' for human: represents as a human-readable list instead of the programmer-readable dict.
114
+ 'r' for repr: represent values using repr() instead of using str().
115
+
116
+ Args:
117
+ spec (str): The format specifier.
118
+
119
+ Example:
120
+ f'{b:fh}' → Complete human readable format that could be displayed to the user
121
+
122
+ Returns:
123
+ str: The formatted Brick instance.
124
+ """
125
+ # ppatch formatting
126
+ displayed_properties = self.get_all_properties() if 'f' in spec else self.ppatch
127
+ display = repr if 'r' in spec else str
128
+
129
+ valid = {'f', 'h', 'r'}
130
+ invalid = set(spec) - valid
131
+ if invalid:
132
+ raise ValueError(f"Unknown format specifier(s): {''.join(sorted(invalid))}. Valid flags: {''.join(sorted(valid))}")
133
+
134
+ if 'h' in spec:
135
+ return (f"Identifier:"
136
+ f"\n Ref → {display(self.ref.id)}"
137
+ f"\n Weld group → {display(self.ref.weld)}"
138
+ f"\n Editor group → {display(self.ref.editor)}"
139
+ f"\nInternal name → {display(self.meta().name())}"
140
+ f"\nPosition → {display(self.pos)}"
141
+ f"\nRotation → {display(self.rot)}"
142
+ f"\nProperties:" +
143
+ ''.join(f"\n {i+1:02d}. {display(k)} → {display(v)}" for i, (k, v) in enumerate(displayed_properties.items()))
144
+ )
145
+ else:
146
+ return f"Brick({display(self.ref)}, {display(self.meta().name())}, {display(self.pos)}, {display(self.rot)}, {display(displayed_properties)})"
brickedit/brm.py ADDED
@@ -0,0 +1,336 @@
1
+ import io
2
+ import struct
3
+ from typing import Optional, Any
4
+
5
+ from .vec import Vec3 as _Vec3
6
+ from .brv import BRVFile
7
+ from .p import TextMeta as _UserTextSerialization
8
+ from .vhelper.time import net_ticks_now as _net_ticks_now
9
+ from dataclasses import dataclass
10
+
11
+
12
+ _ENCODE_2DIGITS = tuple((i % 10) | ((i // 10) << 4) for i in range(100))
13
+
14
+ def encode_author(author: int) -> int:
15
+ result = 0
16
+ shift = 0
17
+ while author:
18
+ author, byte_value = divmod(author, 100)
19
+ result |= _ENCODE_2DIGITS[byte_value] << shift
20
+ shift += 8
21
+ return result
22
+
23
+
24
+ def decode_author(buf: bytes | bytearray | memoryview) -> int:
25
+ result = 0
26
+ mul = 1
27
+ for b in buf:
28
+ lo = b & 0x0F
29
+ hi = b >> 4
30
+
31
+ result += lo * mul
32
+ mul *= 10
33
+
34
+ # Avoid branch misprediction penalty
35
+ result += hi * mul
36
+ mul *= 10
37
+ return result
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class BRMDeserializationConfig:
42
+ version: bool = False
43
+ name: bool = False
44
+ description: bool = False
45
+ brick_count: bool = False
46
+ size: bool = False
47
+ weight: bool = False
48
+ price: bool = False
49
+ author: bool = False
50
+ creation_time: bool = False
51
+ last_update_time: bool = False
52
+ visibility: bool = False
53
+ tags: bool = False
54
+
55
+ _length: int = 0
56
+
57
+ def __post_init__(self):
58
+ object.__setattr__(self, "_length", (
59
+ self.version << 0 |
60
+ self.name << 1 |
61
+ self.description << 2 |
62
+ self.brick_count << 3 |
63
+ self.size << 4 |
64
+ self.weight << 5 |
65
+ self.price << 6 |
66
+ self.author << 7 |
67
+ self.creation_time << 8 |
68
+ self.last_update_time << 9 |
69
+ self.visibility << 10 |
70
+ self.tags << 11
71
+ ).bit_length())
72
+
73
+ def length(self) -> int:
74
+ return self._length
75
+
76
+
77
+
78
+ class BRMFile:
79
+
80
+ def __init__(self, version: int, brv: Optional[BRVFile] = None):
81
+ self.version = version
82
+ self.brv = brv
83
+
84
+ def serialize(
85
+ self,
86
+ file_name: Optional[str] = None,
87
+ description: str = '',
88
+ brick_count: Optional[int] = None,
89
+ size: _Vec3 = _Vec3(0, 0, 0),
90
+ weight: float = 0.0,
91
+ price: float = 0.0,
92
+ author: int = 0,
93
+ visibility: int = 0,
94
+ tags: Optional[list[str]] = None,
95
+ creation_time: int | None = None,
96
+ last_update_time: int | None = None,
97
+ ):
98
+ """Serializes a BRMFile
99
+
100
+ Args:
101
+ file_name (Optional[str], optional): Auto-generated if it is an empty string. Can be an
102
+ empty string. Defaults to None.
103
+ description (str, optional): Description. Defaults to ''.
104
+ brick_count (Optional[int], optional): Auto-generated if None and a brv is provided.
105
+ Defaults to None.
106
+ size (_Vec3, optional): Size. Defaults to _Vec3(0, 0, 0).
107
+ weight (float, optional): Weight. Defaults to 0.0.
108
+ price (float, optional): Price. Defaults to 0.0.
109
+ creation_time (int | None, optional): Creation time in BR's format. Defaults to None.
110
+ last_update_time (int | None, optional): Creation time in BR's format. Defaults to None.
111
+ """
112
+
113
+ creation_time = _net_ticks_now() if creation_time is None else creation_time
114
+ last_update_time = _net_ticks_now() if last_update_time is None else last_update_time
115
+
116
+ if self.brv is not None:
117
+ if brick_count is None:
118
+ brick_count = len(self.brv.bricks)
119
+
120
+ if file_name is None:
121
+ file_name = f'BrickEdit-{last_update_time}'
122
+
123
+ assert brick_count <= 65_534, "Too many bricks! Max: 65,534"
124
+
125
+ # Init buffer
126
+ buffer = bytearray()
127
+
128
+ # No repeated global lookups
129
+ write = buffer.extend
130
+
131
+ # Precompile struct
132
+ pack_B = struct.Struct('B').pack # 'B' → uint8
133
+ pack_H = struct.Struct('<H').pack # '<H' → uint16 LE
134
+ # pack_I = struct.Struct('<I').pack # '<I' → uint32 LE
135
+ pack_Q = struct.Struct('<Q').pack # '<Q' → uint64 LE
136
+ pack_f = struct.Struct('<f').pack # '<f' → sp float LE
137
+ pack_vec3 = struct.Struct('<3f').pack
138
+
139
+ # Write version
140
+ write(pack_B(self.version))
141
+
142
+ # Write name
143
+ write(_UserTextSerialization.serialize(file_name, self.version, {}))
144
+ # Write description
145
+ write(_UserTextSerialization.serialize(description, self.version, {}))
146
+
147
+ # Write brick count
148
+ write(pack_H(brick_count))
149
+
150
+ # Write size
151
+ write(pack_vec3(*size.as_tuple()))
152
+
153
+ # Write weight and price
154
+ write(pack_f(weight))
155
+ write(pack_f(price))
156
+
157
+ # Write author
158
+ # Convert author to string.
159
+ write(b'\x1D') # Steam id stuff
160
+ packed_author = encode_author(author)
161
+ # (... + 7) // 8 is like ceil() for bytes.
162
+ bin_author = packed_author.to_bytes((packed_author.bit_length() + 7)//8, 'little')
163
+ write(bin_author)
164
+
165
+ write(b'\x00\x00\x00\x00') # The 4 forbidden bytes that breaks brms if you edit them
166
+
167
+ # Creation and update time
168
+ write(pack_Q(creation_time))
169
+ write(pack_Q(last_update_time))
170
+
171
+ write(pack_B(visibility))
172
+
173
+ write(pack_H(len(tags)))
174
+ for t in tags:
175
+ write(pack_B(len(t)))
176
+ write(t.encode('ascii'))
177
+
178
+ return buffer.getvalue()
179
+
180
+
181
+
182
+ _UNPACK_FROM_B = struct.Struct('B').unpack_from
183
+ _UNPACK_FROM_h = struct.Struct('<h').unpack_from
184
+ _UNPACK_FROM_H = struct.Struct('<H').unpack_from
185
+ _UNPACK_FROM_5f = struct.Struct('<5f').unpack_from
186
+ _UNPACK_FROM_2Q = struct.Struct('<2Q').unpack_from
187
+
188
+
189
+ def deserialize(self, buffer: bytes | bytearray, config: BRMDeserializationConfig, auto_version: bool = False) -> list[Any]:
190
+ """Deserializes the BRMFile according to the config.
191
+
192
+ Args:
193
+ buffer (bytes | bytearray): The buffer to deserialize.
194
+ config (BRMDeserializationConfig): Configuration for deserialization.
195
+ auto_version (bool, optional): If true, will automatically set self.version
196
+ to the version found in the buffer. Defaults to False.
197
+
198
+ Returns:
199
+ dict: A dictionary with the deserialized data.
200
+ """
201
+
202
+ result = []
203
+
204
+ # Get memory view
205
+ mv = memoryview(buffer)
206
+
207
+ # Get version before running other stuff
208
+ brm_version: int = mv[0]
209
+ if auto_version:
210
+ self.version = brm_version
211
+ if config.version:
212
+ result.append(brm_version)
213
+
214
+ # Precompute, local cache and other variables
215
+ last_step = config.length()
216
+ version = self.version
217
+ offset = 3 # 1 because we already loaded version + 2 because the next value also has a fixed size
218
+
219
+ unpack_from_B = self._UNPACK_FROM_B
220
+ unpack_from_h = self._UNPACK_FROM_h
221
+ unpack_from_H = self._UNPACK_FROM_H
222
+ unpack_from_5f = self._UNPACK_FROM_5f
223
+ unpack_from_2Q = self._UNPACK_FROM_2Q
224
+
225
+
226
+ # -- Name and description
227
+ # for UTF-16, we use -2*name_len because each element in a UTF-16 string is 2 bytes
228
+ # we use - because utf-16/ascii is indicated by whether the length is negative or not
229
+
230
+ if last_step <= 1:
231
+ return result
232
+
233
+ name_len, = unpack_from_h(mv, 1) # Compute before because we use it eitherway
234
+ name_byte_len = name_len if name_len >= 0 else -2*name_len
235
+ if config.name:
236
+ if name_len >= 0: # ASCII
237
+ name = bytes(mv[offset : offset+name_byte_len]).decode('ascii')
238
+ else: # UTF-16
239
+ name = bytes(mv[offset : offset+name_byte_len]).decode('utf-16-le')
240
+ result.append(name)
241
+ offset += name_byte_len
242
+
243
+ if last_step <= 2:
244
+ return result
245
+
246
+ desc_len, = unpack_from_h(mv, offset)
247
+ desc_byte_len = desc_len if desc_len >= 0 else -2*desc_len
248
+ offset += 2
249
+ if config.description:
250
+ if desc_len >= 0: # ASCII
251
+ desc = bytes(mv[offset : offset+desc_byte_len]).decode('ascii')
252
+ else: # UTF-16
253
+ desc = bytes(mv[offset : offset+desc_byte_len]).decode('utf-16-le')
254
+ result.append(desc)
255
+ offset += desc_byte_len
256
+
257
+ if last_step <= 3:
258
+ return result
259
+
260
+ # -- Brick count
261
+
262
+ brick_count, = unpack_from_H(mv, offset)
263
+ if config.brick_count:
264
+ result.append(brick_count)
265
+ offset += 2
266
+
267
+ if last_step <= 4:
268
+ return result
269
+
270
+ # -- Size, weight, price
271
+ sx, sy, sz, weight, price = unpack_from_5f(mv, offset)
272
+ offset += 20
273
+
274
+ if config.size:
275
+ result.append(_Vec3(sx, sy, sz))
276
+ if config.weight:
277
+ result.append(weight)
278
+ if config.price:
279
+ result.append(price)
280
+
281
+ if last_step <= 6:
282
+ return result
283
+
284
+ # -- Author (insert thousand miles stare)
285
+ # AUTHOR_MARKER = 0x1D
286
+ # assert mv[offset] == AUTHOR_MARKER
287
+ offset += 1
288
+
289
+ author_len, = unpack_from_B(mv, offset)
290
+ offset += 1
291
+ if config.author:
292
+ author = decode_author(mv[offset : offset+author_len])
293
+ result.append(author)
294
+ offset += author_len
295
+
296
+ if last_step <= 7:
297
+ return result
298
+
299
+ # -- 4 bytes of dread
300
+ offset += 4
301
+
302
+ # Create and update time (.NET)
303
+ creation_time, last_update_time = unpack_from_2Q(mv, offset)
304
+ if config.creation_time:
305
+ result.append(creation_time)
306
+ if config.last_update_time:
307
+ result.append(last_update_time)
308
+ offset += 8
309
+
310
+ if last_step <= 9:
311
+ return result
312
+
313
+ # -- Visibility
314
+ if config.visibility:
315
+ result.append(mv[offset])
316
+ offset += 1
317
+
318
+ if last_step <= 10:
319
+ return result
320
+
321
+ # -- Tags
322
+ # Do not care about propertly updating offset if we don't load because this is EOF
323
+ if config.tags:
324
+ num_tags, = unpack_from_h(mv, offset)
325
+ offset += 2
326
+ tags = [None] * num_tags
327
+ for i in range(num_tags):
328
+ tag_len = mv[offset]
329
+ offset += 1
330
+ tag = mv[offset : offset+tag_len].decode('ascii')
331
+ offset += tag_len
332
+ tags[i] = tag
333
+ result.append(tags)
334
+
335
+
336
+ return result