-
Notifications
You must be signed in to change notification settings - Fork 2
/
Copy pathblb_processor.py
2888 lines (2311 loc) · 130 KB
/
blb_processor.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# ##### BEGIN GPL LICENSE BLOCK #####
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ##### END GPL LICENSE BLOCK #####
"""
A module for processing Blender data into the BLB file format for writing.
@author: Demian Wright
"""
# Set the Decimal number context for operations: 0.5 is rounded up. (Precision can be whatever.)
# NOTE: prec=n limits the number of digits for the whole number.
# E.g. 1234.56 has a precision of 6, not 2.
from collections import OrderedDict, Sequence
from decimal import Context, Decimal, ROUND_HALF_UP, setcontext
from math import atan, ceil, modf, pi, radians, sqrt
import bpy
from mathutils import Euler, Vector
import bmesh
import numpy
from . import common, const, logger
from .const import Axis3D, AxisPlane3D, X, Y, Z
setcontext(Context(rounding=ROUND_HALF_UP))
# Globals.
# Number of decimal places to round floating point numbers to when performing calculations.
# The value was chosen to eliminate most floating points errors but it does
# have the side effect of quantizing the positions of all vertices to
# multiples of the value since everything is rounded using this precision.
__CALCULATION_FP_PRECISION_STR = None
# ==============
# Math Functions
# ==============
def __is_even(value):
"""Checks if the specified value is even.
Args:
value (number): A numerical value to check.
Returns:
True if the specified value is exactly divisible by 2.
"""
return value % 2 == 0
def __is_sequence(seq, allow_string=False):
# String check is XNOR.
is_str = isinstance(seq, str)
return (isinstance(seq, Sequence) and ((allow_string and is_str) or (not allow_string and not is_str))) or isinstance(seq, Vector)
def __to_decimal(val, quantize=None):
"""Creates a Decimal number of the specified value and rounds it to the closest specified quantize value.
The number of decimal digits in the quantize value will determine the number of decimal digits in the returned value
This is a recursive function.
Args:
val (sequence or Number): A Number or a sequence of numerical values to create Decimals out of.
Sequence may contain other sequences.
quantize (string or Decimal): The optional value to round the specified numbers to.
The value may be given as a string or a Decimal number.
If no value is specified, the floating point precision user has specified in export properties will be used.
Returns:
A Decimal representation of the specified number or all the numbers in the specified sequence(s) as the closest multiple of the quantize value, with half rounded up.
"""
def make_decimal(value, quantize=None):
"""Creates a Decimal number of the specified value and rounds it to the closest specified quantize value.
The number of decimal digits in the quantize value will determine the number of decimal digits in the returned value.
Args:
value (number): A numerical value to create a Decimal out of.
quantize (string or Decimal): The optional value to round the specified number to.
The value may be given as a string or a Decimal number.
If no value is specified, the user-specified floating point precision will be used.
Returns:
A Decimal representation of the specified value as the closest multiple of the quantize value, with half rounded up.
"""
if quantize is None:
quantize = __CALCULATION_FP_PRECISION_STR
# Make a Decimal out of the quantize value if it already isn't.
if isinstance(quantize, str):
quantize = Decimal(quantize)
elif isinstance(quantize, Decimal):
pass
else:
# EXCEPTION
raise ValueError("__to_decimal(value) quantize must be a string or a Decimal, was '{}'.".format(type(quantize)))
# Calculate the fraction that will be used to do the rounding to an arbitrary number.
fraction = const.DECIMAL_ONE / quantize
# If the value is not a Decimal, convert the value to string and create a Decimal out of the formatted string.
# Using strings is the only way to create Decimals accurately from numbers as the Decimal representation of
# the input will be identical to that of the string.
# I.e. I'm pushing the issue of accurate floating point representation to whatever is the default formatting.
if not isinstance(value, Decimal):
value = Decimal("{}".format(value))
# Multiply the Decimal value with the Decimal fraction.
# Round to the nearest integer with quantize.
# Divide with the Decimal fraction.
# Quantize the result to get the correct number of decimal digits.
# Result: value is rounded to the nearest value of quantize (half rounded up)
return ((value * fraction).quantize(const.DECIMAL_ONE) / fraction).quantize(quantize)
result = []
if quantize is None:
quantize = __CALCULATION_FP_PRECISION_STR
if __is_sequence(val):
for value in val:
result.append(__to_decimal(value, quantize))
else:
# ROUND & CAST
return make_decimal(val, quantize)
return result
def __force_to_ints(values):
"""Casts all values in the specified sequence to ints.
Args:
values (sequence of numbers): A sequence of numerical values to be casted to ints.
Returns:
A list of the sequence values casted to integers.
"""
return [int(val) for val in values]
def __are_ints(values):
"""Checks if all values in the specified sequence are ints.
Args:
values (sequence of numbers): A sequence of numerical values.
Returns:
True if all values in the specified sequence are numerically equal to their integer counterparts.
"""
for value in values:
if value != int(value):
return False
return True
def __like_int(value):
"""Checks if the specified string is like a pure integer.
Handles negative integers.
Args:
value (string): A string representing a number.
Returns:
True if the specified string is like an integer and has no fractional part.
"""
return value.isdigit() or (value.startswith("-") and value[1:].isdigit())
def __get_world_min(obj):
"""Gets the world space coordinates of the vertex in the specified object that is the closest to the world origin.
Args:
obj (Blender object): A Blender mesh.
Returns:
A new Vector of the minimum world space coordinates of the specified object.
"""
# This function deals with Vectors instead of Decimals because it works with Blender object data, which uses Vectors.
vec_min = Vector((float("+inf"), float("+inf"), float("+inf")))
for vert in obj.data.vertices:
# Local coordinates to world space.
coord = obj.matrix_world * vert.co
for i in range(3):
vec_min[i] = min(vec_min[i], coord[i])
return vec_min
def __get_world_min_max(obj, min_coords=None, max_coords=None):
"""Checks if the specified Blender object's vertices' world space coordinates are smaller or greater than the coordinates stored in their respective minimum and maximum vectors.
Args:
obj (Blender object): The Blender object whose vertex coordinates to check against the current minimum and maximum coordinates.
min_coords (Vector): The Vector of smallest XYZ world space coordinates to compare against. (Optional)
max_coords (Vector): The Vector of largest XYZ world space coordinates to compare against. (Optional)
Returns:
The smallest and largest world coordinates from the specified vectors or object's vertex coordinates.
"""
# I have no idea why but if I create the vectors as default values for the
# arguments, the min/max coord vectors from the last time this function
# was called are somehow carried over. I tried creating a new instance
# with the vector values but that didn't work either so it isn't an issue
# with object references.
if min_coords is None:
min_coords = Vector((float("+inf"), float("+inf"), float("+inf")))
if max_coords is None:
max_coords = Vector((float("-inf"), float("-inf"), float("-inf")))
for vert in obj.data.vertices:
coordinates = obj.matrix_world * vert.co
for i in range(3):
min_coords[i] = min(min_coords[i], coordinates[i])
max_coords[i] = max(max_coords[i], coordinates[i])
return min_coords, max_coords
def __get_vert_world_coord(obj, mesh, vert_idx):
"""Calculates the world coordinates for the vertex at the specified index in the specified mesh's polygon loop.
Args:
obj (Blender object): The Blender object that is the parent of the mesh.
mesh (Blender mesh): The Blender mesh where the vertex is stored.
vert_idx (int): The index of the vertex in the specified mesh's polygon loop.
Returns:
A Vector of the world coordinates of the vertex.
"""
# Get the vertex index in the loop.
# Get the vertex coordinates in object space.
# Convert object space to world space.
return obj.matrix_world * mesh.vertices[mesh.loops[vert_idx].vertex_index].co
def __loop_index_to_normal_vector(obj, mesh, index):
"""Calculates the normalized vertex normal vector for the vertex at the specified index in the specified Blender object.
Args:
obj (Blender object): The Blender object that is the parent of the mesh.
mesh (Blender mesh): The Blender mesh where the vertex is stored.
index (int): The index of the loop in the specified objects's loop data sequence.
Returns:
A normalized normal vector of the specified vertex.
"""
return (obj.matrix_world.to_3x3() * mesh.vertices[mesh.loops[index].vertex_index].normal).normalized()
def __normalize_vector(obj, normal):
""" Gets rid of the object's rotation from the specified normal and calculates the normalized vector for it.
Args:
obj (Blender object): The Blender object the normal is in.
normal (Vector): A normal vector to be normalized.
Returns:
A normalized normal vector.
"""
# Multiplying the normals with the world matrix gets rid of the OBJECT's rotation from the MESH NORMALS.
return (obj.matrix_world.to_3x3() * normal).normalized()
def __all_within_bounds(local_coordinates, bounding_dimensions):
"""Checks if all the values in the specified local coordinates are within the specified bounding box dimensions.
Assumes that both sequences have the same number of elements.
Args:
local_coordinates (sequence of numbers): A sequence of local space coordinates.
bounding_dimensions (sequence of numbers): A sequence of dimensions of a bounding box centered at the origin.
Returns:
True if all values are within the bounding dimensions.
"""
# Divide all dimension values by 2.
halved_dimensions = [value * const.DECIMAL_HALF for value in bounding_dimensions]
# Check if any values in the given local_coordinates are beyond the given bounding_dimensions.
# bounding_dimensions / 2 = max value
# -bounding_dimensions / 2 = min value
for index, value in enumerate(local_coordinates):
if value > halved_dimensions[index]:
return False
for index, value in enumerate(local_coordinates):
if value < -(halved_dimensions[index]):
return False
return True
def __calculate_center(object_minimum_coordinates, object_dimensions):
"""Calculates the coordinates of the center of a 3D object.
Args:
object_minimum_coordinates (sequence of numbers): A sequence of minimum XYZ coordinates of the object.
This function is only useful is these are world space coordinates.
If local space coordinates are given, (0, 0, 0) will always be returned as the center regardless of the specified dimensions.
object_dimensions (sequence of numbers): The dimensions of the object.
Returns:
A tuple of Decimal type XYZ coordinates.
"""
return (object_minimum_coordinates[X] + (object_dimensions[X] * const.DECIMAL_HALF),
object_minimum_coordinates[Y] + (object_dimensions[Y] * const.DECIMAL_HALF),
object_minimum_coordinates[Z] + (object_dimensions[Z] * const.DECIMAL_HALF))
def __world_to_local(coordinates, new_origin):
"""Translates the specified coordinates to be relative to the specified new origin coordinates.
Commonly used to translate coordinates from world space (centered on (0, 0, 0)) to local space (arbitrary center).
Performs rounding with __to_decimal().
Args:
coordinates (sequence of numbers): The sequence of XYZ coordinates to be translated.
new_origin (sequence of numbers): The new origin point as a sequence of XYZ coordinates in the same space as the specified coordinates.
Returns:
A list of Decimal type coordinates relative to the specified new origin coordinates.
"""
# Make the coordinates Decimals if all of them are not.
if not all(isinstance(coord, Decimal) for coord in coordinates):
# ROUND & CAST
coordinates = __to_decimal(coordinates)
# Make the new origin Decimals if all of them are not.
if not all(isinstance(coord, Decimal) for coord in new_origin):
# ROUND & CAST
new_origin = __to_decimal(new_origin)
return [old_coord - new_origin[index] for index, old_coord in enumerate(coordinates)]
def __mirror(xyz, forward_axis):
"""Mirrors the given XYZ sequence according to the specified forward axis.
Args:
xyz (sequence): A sequence of elements to be mirrored.
forward_axis (Axis3D): A value of the Axis3D enum. The axis that will point forwards in-game.
Returns:
A new list of XYZ values.
"""
mirrored = xyz
if forward_axis is Axis3D.POS_X or forward_axis is Axis3D.NEG_X:
mirrored[Y] = -mirrored[Y]
else:
mirrored[X] = -mirrored[X]
return mirrored
def __multiply_sequence(multiplier, sequence):
"""Multiplies every value in the specified sequence with a number.
Args:
multiplier (numerical value): A number to multiply with.
sequence (sequence of numerical values): The sequence to whose elements to multiply.
Returns:
A new sequence with the values of the specified sequence multiplied with the specified multiplier.
"""
return [multiplier * value for value in sequence]
def __sequence_product(sequence):
"""Multiplies all values in the specified sequence together.
Args:
sequence (sequence of numerical values): The sequence to get the product of.
Returns:
The product of the sequence.
"""
product = 1
for value in sequence:
product *= value
return product
def __has_volume(min_coords, max_coords):
"""Checks if a n-dimensional object has volume in n-dimensional space.
Args:
min_coords (sequence of numbers): The minimum coordinates of an object.
max_coords (sequence of numbers): The maximum coordinates of an object.
Returns:
True if an object with the specified coordinates has volume, False otherwise.
"""
for index, value in enumerate(max_coords):
if (value - min_coords[index]) == 0:
return False
return True
def __count_occurrences(value, sequence, not_equal=False):
"""Counts the number of occurrences of the specified value in the sequence.
Args:
value (value): Value to count the occurrences of.
sequence (sequence): Sequence to iterate over.
not_equal (boolean): Count the number of times the value does not appear in the sequence instead. (Default: False)
Return:
The number of times the value did/did not appear in the sequence.
"""
if not_equal:
return len([val for val in sequence if val != value])
else:
return len([val for val in sequence if val == value])
# =================================
# Blender Data Processing Functions
# =================================
class BrickBounds(object):
"""A class for storing the Blender data of brick bounds.
Stores the following data:
- Blender object name,
- object dimensions,
- object center world coordinates,
- minimum vertex world coordinates,
- maximum vertex world coordinates,
- dimensions of the axis-aligned bounding box of visual meshes,
- and world center coordinates of the axis-aligned bounding box of visual meshes.
"""
def __init__(self):
# The name of the Blender object.
self.object_name = None
# The dimensions are stored separately even though it is trivial to calculate them from the coordinates because they are used often.
self.dimensions = []
# The object center coordinates are stored separately for convenience.
self.world_center = []
self.world_coords_min = []
self.world_coords_max = []
# TODO: Consider moving to another object?
# The axis-aligned bounding box of visual meshes of this brick.
self.aabb_dimensions = None
self.aabb_world_center = None
def __repr__(self):
return "<BrickBounds object_name:{} dimensions:{} world_center:{} world_coords_min:{} world_coords_max:{} aabb_dimensions:{} aabb_world_center:{}>".format(
self.object_name, self.dimensions, self.world_center, self.world_coords_min, self.world_coords_max, self.aabb_dimensions, self.aabb_world_center)
class BLBData(object):
"""A class for storing the brick data to be written to a BLB file.
Stores the following data:
- BLB file name without extension
- size (dimensions) in plates,
- brick grid data,
- collision cuboids,
- coverage data,
- and sorted quad data.
"""
def __init__(self):
# Brick BLB file name.
self.brick_name = None
# Brick XYZ integer size in plates.
self.brick_size = []
# Brick grid data sequences.
self.brick_grid = []
# Brick collision box coordinates.
self.collision = []
# Brick coverage data sequences.
self.coverage = []
# Sorted quad data sequences.
self.quads = []
class OutOfBoundsException(Exception):
"""An exception thrown when a vertex position is outside of brick bounds."""
pass
class ZeroSizeException(Exception):
"""An exception thrown when a definition object has zero brick size on at least one axis."""
pass
# ================
# Helper Functions
# ================
def __round_to_plate_coordinates(local_coordinates, brick_dimensions, plate_height):
"""Rounds the specified sequence of local space XYZ coordinates to the nearest valid plate coordinates in a brick with the specified dimensions.
Args:
local_coordinates (sequence of numbers): A sequence of local space coordinates.
brick_dimensions (sequence of numbers): A sequence of dimensions of the brick.
plate_height (Decimal): The height of a Blockland plate in Blender units.
Returns:
A list of rounded local space coordinates as Decimal values.
"""
result = []
# 1 plate is 1.0 Blender units wide and deep.
# Plates can only be 1.0 units long on the X and Y axes.
# Valid plate positions exist every 0.5 units on odd sized bricks and every 1.0 units on even sized bricks.
if __is_even(brick_dimensions[X]):
# ROUND & CAST
result.append(__to_decimal(local_coordinates[X], "1.0"))
else:
# ROUND & CAST
result.append(__to_decimal(local_coordinates[X], "0.5"))
if __is_even(brick_dimensions[Y]):
# ROUND & CAST
result.append(__to_decimal(local_coordinates[Y], "1.0"))
else:
# ROUND & CAST
result.append(__to_decimal(local_coordinates[Y], "0.5"))
# Round to the nearest full plate height. (Half is rounded up)
if __is_even(brick_dimensions[Z] / plate_height):
# ROUND & CAST
result.append(__to_decimal(local_coordinates[Z], plate_height))
else:
# ROUND & CAST
result.append(__to_decimal(local_coordinates[Z], (plate_height * const.DECIMAL_HALF)))
return result
def __sequence_z_to_plates(xyz, plate_height):
"""Performs __to_decimal(sequence) on the given sequence and scales the Z component to match Blockland plates.
If the given sequence does not have exactly three components (assumed format is (X, Y, Z)) the input is returned unchanged.
Args:
xyz (sequence of numbers): A sequence of three numerical values.
plate_height (Decimal): The height of a Blockland plate in Blender units.
Returns:
A list of three Decimal type numbers.
"""
if len(xyz) == 3:
# ROUND & CAST
sequence = __to_decimal(xyz)
sequence[Z] /= plate_height
return sequence
else:
return xyz
def __split_object_string_to_tokens(name, replace_commas=False):
"""Splits a Blender object name into its token parts.
Correctly takes into account duplicate object names with .### at the end.
Args:
name (string): A Blender object name.
replace_commas (bool): Replace all commas with periods in the object name? (Default: False)
Returns:
The name split into a list of uppercase strings at whitespace characters.
"""
if replace_commas:
name = name.replace(",", ".")
# If the object name has "." as the fourth last character, it could mean that Blender has added the index (e.g. ".002") to the end of the object name because an object with the same name already exists.
# Removing the end of the name fixes an issue where for example two grid definition objects exist with identical names (which is very common) "gridx" and "gridx.001".
# When the name is split at whitespace, the first object is recognized as a grid definition object and the second is not.
if len(name) > 4 and name[-4] == ".":
# Remove the last 4 characters of from the name before splitting at whitespace.
tokens = name[:-4].upper().split()
else:
# Split the object name at whitespace.
tokens = name.upper().split()
return tokens
def __get_tokens_from_object_name(name, tokens):
"""Retrieves a set of common tokens from the specified name and sequence of tokens.
Args:
name (string or sequence of strings): A raw Blender object name or a sequence of tokens.
tokens (sequence of strings): A sequence of tokens.
Returns:
A set of tokens that exist both in the Blender object name and the specified sequence of tokens, in the order they appeared in the name.
"""
if isinstance(name, str):
name_tokens = __split_object_string_to_tokens(name)
else:
name_tokens = name
# In case tokens sequence contains mixed case characters, convert everything to uppercase.
tokens = [token.upper() for token in tokens]
# Convert name tokens and wanted tokens into sets.
# Get their intersection.
# Sort the set according to the order the elements were in the object tokens.
# Returned tokens contain all tokens that were in the object tokens AND in the wanted tokens.
# It contains zero or more tokens.
return sorted(set(name_tokens) & set(tokens), key=name_tokens.index)
def __modify_brick_grid(brick_grid, volume, symbol):
"""Modifies the specified brick grid by adding the specified symbol to every grid slot specified by the volume.
Will crash if specified volume extends beyond the 3D space defined by the brick grid.
Args:
brick_grid (3D array): A pre-initialized three dimensional array representing every plate of a brick.
volume (sequence of numerical ranges): A sequence of three (XYZ) sequences representing the dimensions of a 3D volume.
Each element contains a sequence of two numbers representing a range of indices ([min, max[) in the brick grid.
symbol (string): A valid brick grid symbol to place in the elements specified by the volume.
"""
# Ranges are exclusive [min, max[ index ranges.
width_range = volume[X]
depth_range = volume[Y]
height_range = volume[Z]
# Example data for a cuboid brick that is:
# - 2 plates wide
# - 3 plates deep
# - 4 plates tall
# Ie. a brick of size "3 2 4"
#
# uuu
# xxx
# xxx
# ddd
#
# uuu
# xxx
# xxx
# ddd
# For every slice of the width axis.
for w in range(width_range[0], width_range[1]):
# For every row from top to bottom.
for h in range(height_range[0], height_range[1]):
# For every character the from left to right.
for d in range(depth_range[0], depth_range[1]):
# Set the given symbol.
brick_grid[w][h][d] = symbol
def __calculate_coverage(calculate_side=None, hide_adjacent=None, brick_grid=None, forward_axis=None):
"""Calculates the BLB coverage data for a brick.
Args:
calculate_side (sequence of booleans): An optional sequence of boolean values where the values must in the same order as const.QUAD_SECTION_ORDER.
A value of true means that coverage will be calculated for that side of the brick according the specified size of the brick.
A value of false means that the default coverage value will be used for that side.
If not defined, default coverage will be used.
hide_adjacent (sequence of booleans): An optional sequence of boolean values where the values must in the same order as const.QUAD_SECTION_ORDER.
A value of true means that faces of adjacent bricks covering this side of this brick will be hidden.
A value of false means that adjacent brick faces will not be hidden.
Must be defined if calculate_side is defined.
brick_grid (sequence of integers): An optional sequence of the sizes of the brick on each of the XYZ axes.
Must be defined if calculate_side is defined.
forward_axis (Axis): The optional user-defined BLB forward axis.
Must be defined if calculate_side is defined.
Returns:
A sequence of BLB coverage data.
"""
coverage = []
# Does the user want to calculate coverage in the first place?
if calculate_side is not None:
# Initially assume that forward axis is +X, data will be swizzled later.
for index, side in enumerate(calculate_side):
if side:
# Bricks are cuboid in shape.
# The brick sides in the grid are as follows:
# - Blender top / grid top : first row of every slice.
# - Blender bottom / grid bottom: last row of every slice.
# - Blender north / grid east : last index of every row.
# - Blender east / grid south : last slice in grid.
# - Blender south / grid west : first index of every row.
# - Blender west / grid north : first slice in grid.
# Coverage only takes into account symbols that are not empty space "-".
# Calculate the area of the brick grid symbols on each the brick side.
area = 0
if index == const.BLBQuadSection.TOP.value:
for axis_slice in brick_grid:
area += __count_occurrences(const.GRID_OUTSIDE, axis_slice[0], True)
elif index == const.BLBQuadSection.BOTTOM.value:
slice_last_row_idx = len(brick_grid[0]) - 1
for axis_slice in brick_grid:
area += __count_occurrences(const.GRID_OUTSIDE, axis_slice[slice_last_row_idx], True)
elif index == const.BLBQuadSection.NORTH.value:
row_last_symbol_idx = len(brick_grid[0][0]) - 1
for axis_slice in brick_grid:
for row in axis_slice:
area += 0 if row[row_last_symbol_idx] == const.GRID_OUTSIDE else 1
elif index == const.BLBQuadSection.EAST.value:
for row in brick_grid[len(brick_grid) - 1]:
area += __count_occurrences(const.GRID_OUTSIDE, row, True)
elif index == const.BLBQuadSection.SOUTH.value:
for axis_slice in brick_grid:
for row in axis_slice:
area += 0 if row[0] == const.GRID_OUTSIDE else 1
elif index == const.BLBQuadSection.WEST.value:
for row in brick_grid[0]:
area += __count_occurrences(const.GRID_OUTSIDE, row, True)
else:
# EXCEPTION
raise RuntimeError("Invalid quad section index '{}'.".format(index))
else:
area = const.DEFAULT_COVERAGE
# Hide adjacent face?
# Valid values are 1 and 0, Python will write True and False as integers.
coverage.append((hide_adjacent[index], area))
# Swizzle the coverage values around according to the defined forward axis.
# Coverage was calculated with forward axis at +X.
# ===========================================
# The order of the values in the coverage is:
# 0 = a = +Z: Top
# 1 = b = -Z: Bottom
# 2 = c = +X: North
# 3 = d = -Y: East
# 4 = e = -X: South
# 5 = f = +Y: West
# ===========================================
# Technically this is wrong as the order would be different for -Y
# forward, but since the bricks must be cuboid in shape, the
# transformations are symmetrical.
if forward_axis is Axis3D.POS_Y or forward_axis is Axis3D.NEG_Y:
# New North will be +Y.
# Old North (+X) will be the new East
coverage = common.swizzle(coverage, "abfcde")
# Else forward_axis is +X or -X: no need to do anything, the calculation was done with +X.
# No support for Z axis remapping yet.
else:
# Use the default coverage.
# Do not hide adjacent face.
# Hide this face if it is covered by const.DEFAULT_COVERAGE plates.
coverage = [(0, const.DEFAULT_COVERAGE)] * 6
return coverage
def __sort_quad(positions, bounds_dimensions, plate_height):
"""Calculates the section (brick side) for the specified quad within the specified bounds dimensions.
The section is determined by whether all vertices of the quad are in the same plane as one of the planes (brick sides) defined by the (cuboid) brick bounds.
The quad's section is needed for brick coverage.
Args:
positions (sequence of numbers): A sequence containing the vertex positions of the quad to be sorted.
bounds_dimensions (sequence of Decimals): The dimensions of the brick bounds.
plate_height (Decimal): The height of a Blockland plate in Blender units.
Returns:
The section of the quad as a value of the BLBQuadSection enum.
"""
# ROUND & CAST
# Divide all dimension values by 2 to get the local bounding plane values.
# The dimensions are in Blender units so Z height needs to be converted to plates.
local_bounds = __sequence_z_to_plates([value * const.DECIMAL_HALF for value in bounds_dimensions], plate_height)
# Assume omni direction until otherwise proven.
direction = const.BLBQuadSection.OMNI
# Each position list has exactly 3 values.
# 0 = X
# 1 = Y
# 2 = Z
for axis in range(3):
# This function only handles quads so there are always exactly 4 position lists. (One for each vertex.)
# If the vertex coordinate is the same on an axis for all 4 vertices, this face is parallel to the plane perpendicular to that axis.
if positions[0][axis] == positions[1][axis] == positions[2][axis] == positions[3][axis]:
# If the common value is equal to one of the bounding values the quad is on the same plane as one of the edges of the brick.
# Stop searching as soon as the first plane is found because it is impossible for the quad to be on multiple planes at the same time.
# If the vertex coordinates are equal on more than one axis, it means that the quad is either a line (2 axes) or a point (3 axes).
# Blockland assumes by default that forward axis is Blender +X. (In terms of the algorithm.)
# Then in-game the brick north is to the left of the player, which is +Y in Blender.
# All vertex coordinates are the same on this axis, only the first one needs to be checked.
# Positive values.
if positions[0][axis] == local_bounds[axis]:
# +X = East
if axis == X:
direction = const.BLBQuadSection.EAST
break
# +Y = North
elif axis == Y:
direction = const.BLBQuadSection.NORTH
break
# +Z = Top
else:
direction = const.BLBQuadSection.TOP
break
# Negative values.
elif positions[0][axis] == -local_bounds[axis]:
# -X = West
if axis == X:
direction = const.BLBQuadSection.WEST
break
# -Y = South
elif axis == Y:
direction = const.BLBQuadSection.SOUTH
break
# -Z = Bottom
else:
direction = const.BLBQuadSection.BOTTOM
break
# Else the quad is not on the same plane with one of the bounding planes = Omni
# Else the quad is not planar = Omni
return direction
def __rotate_section_value(section, forward_axis):
"""
Args:
section (BLBQuadSection): A value of the BLBQuadSection enum.
forward_axis (Axis3D): A value of the Axis3D enum. The axis that will point forwards in-game.
Returns:
The input section rotated according to the specified forward_axis as a value in the BLBQuadSection.
"""
# ==========
# TOP = 0
# BOTTOM = 1
# NORTH = 2
# EAST = 3
# SOUTH = 4
# WEST = 5
# OMNI = 6
# ==========
# Top and bottom always the same and do not need to be rotated because Z axis remapping is not yet supported.
# Omni is not planar and does not need to be rotated.
# The initial values are calculated according to +X forward axis.
if section <= const.BLBQuadSection.BOTTOM or section == const.BLBQuadSection.OMNI or forward_axis is Axis3D.POS_X:
return section
# ========================================================================
# Rotate the section according the defined forward axis.
# 0. section is in the range [2, 5].
# 1. Subtract 2 to put section in the range [0, 3]: sec - 2
# 2. Add the rotation constant: sec - 2 + R
# 3. Use modulo make section wrap around 3 -> 0: sec - 2 + R % 4
# 4. Add 2 to get back to the correct range [2, 5]: sec - 2 + R % 4 + 2
# ========================================================================
elif forward_axis is Axis3D.POS_Y:
# 90 degrees clockwise.
# [2] North -> [3] East: (2 - 2 + 1) % 4 + 2 = 3
# [5] West -> [2] North: (5 - 2 + 1) % 4 + 2 = 2
return const.BLBQuadSection((section - 1) % 4 + 2)
elif forward_axis is Axis3D.NEG_X:
# 180 degrees clockwise.
# [2] North -> [4] South: (2 - 2 + 2) % 4 + 2 = 4
# [4] South -> [2] North
return const.BLBQuadSection(section % 4 + 2)
elif forward_axis is Axis3D.NEG_Y:
# 270 degrees clockwise.
# [2] North -> [5] West: (2 - 2 + 3) % 4 + 2 = 5
# [5] West -> [4] South
return const.BLBQuadSection((section + 1) % 4 + 2)
def __record_bounds_data(properties, blb_data, bounds_data):
"""Adds the brick bounds data to the specified BLB data object.
Args:
properties (Blender properties object): A Blender object containing user preferences.
blb_data (BLBData): A BLBData object containing all the necessary data for writing a BLB file.
bounds_data (BrickBounds): A BrickBounds object containing the bounds data.
Returns:
The modified blb_data object containing the bounds data.
"""
# ROUND & CAST
# Get the dimensions of the Blender object and convert the height to plates.
bounds_size = __sequence_z_to_plates(bounds_data.dimensions, properties.plate_height)
# Are the dimensions of the bounds object not integers?
if not __are_ints(bounds_size):
if bounds_data.object_name is None:
logger.warning("IOBLBW000", "Calculated bounds have a non-integer size {} {} {}, rounding up.".format(bounds_size[X],
bounds_size[Y],
bounds_size[Z]), 1)
# In case height conversion or rounding introduced floating point errors, round up to be on the safe side.
for index, value in enumerate(bounds_size):
bounds_size[index] = ceil(value)
else:
logger.warning("IOBLBW001", "Defined bounds have a non-integer size {} {} {}, rounding to a precision of {}.".format(bounds_size[X],
bounds_size[Y],
bounds_size[Z],
properties.human_brick_grid_error), 1)
for index, value in enumerate(bounds_size):
# Round to the specified error amount.
bounds_size[index] = round(properties.human_brick_grid_error * round(value / properties.human_brick_grid_error))
# The value type must be int because you can't have partial plates. Returns a list.
blb_data.brick_size = __force_to_ints(bounds_size)
if properties.blendprop.export_count == "SINGLE" and properties.blendprop.brick_name_source == "BOUNDS":
if bounds_data.object_name is None:
logger.warning("IOBLBW002", "Brick name was supposed to be in the bounds definition object but no such object exists, file name used instead.", 1)
else:
if len(bounds_data.object_name.split()) == 1:
logger.warning("IOBLBW003", "Brick name was supposed to be in the bounds definition object but no name (separated with a space) was found after the definition token, file name used instead.",
1)
else:
# Brick name follows the bounds definition, must be separated by a space.
# Substring the object name: everything after properties.deftokens.bounds and 1 space character till the end of the name.
blb_data.brick_name = bounds_data.object_name[
bounds_data.object_name.upper().index(properties.deftokens.bounds) + len(properties.deftokens.bounds) + 1:]
logger.info("Found brick name from bounds definition: {}".format(blb_data.brick_name), 1)
elif properties.blendprop.export_count == "MULTIPLE" and properties.blendprop.brick_name_source_multi == "BOUNDS":
if bounds_data.object_name is None:
if properties.blendprop.brick_definition == "LAYERS":
# RETURN ON ERROR
return "IOBLBF000 When exporting multiple bricks in separate layers, a bounds definition object must exist in every layer. It is also used to provide a name for the brick."
else:
# TODO: Does this work? Does it actually export multiple bricks or overwrite the first one?
logger.warning("IOBLBW002", "Brick name was supposed to be in the bounds definition object but no such object exists, file name used instead.", 1)
else:
if len(bounds_data.object_name.split()) == 1:
if properties.blendprop.brick_definition == "LAYERS":
# RETURN ON ERROR
return "IOBLBF001 When exporting multiple bricks in separate layers, the brick name must be after the bounds definition token (separated with a space) in the bounds definition object name."
else:
logger.warning("IOBLBW003", "Brick name was supposed to be in the bounds definition object but no name (separated with a space) was found after the definition token, file name used instead.",
1)
else:
# Brick name follows the bounds definition, must be separated by a space.
# Substring the object name: everything after properties.deftokens.bounds and 1 space character till the end of the name.
blb_data.brick_name = bounds_data.object_name[
bounds_data.object_name.upper().index(properties.deftokens.bounds) + len(properties.deftokens.bounds) + 1:]
logger.info("Found brick name from bounds definition: {}".format(blb_data.brick_name), 1)
return blb_data
def __calculate_bounding_box_size(min_coords, max_coords):
"""Calculates the XYZ dimensions of a cuboid with the specified minimum and maximum coordinates.
Args:
min_coords (sequence of numbers): The minimum coordinates as a sequence: [X, Y, Z]
max_coords (sequence of numbers): The maximum coordinates as a sequence: [X, Y, Z]