resqpy 4.14.2__py3-none-any.whl → 5.1.6__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
Files changed (67) hide show
  1. resqpy/__init__.py +1 -1
  2. resqpy/fault/_gcs_functions.py +10 -10
  3. resqpy/fault/_grid_connection_set.py +277 -113
  4. resqpy/grid/__init__.py +2 -3
  5. resqpy/grid/_defined_geometry.py +3 -3
  6. resqpy/grid/_extract_functions.py +8 -2
  7. resqpy/grid/_grid.py +95 -12
  8. resqpy/grid/_grid_types.py +22 -7
  9. resqpy/grid/_points_functions.py +1 -1
  10. resqpy/grid/_regular_grid.py +6 -2
  11. resqpy/grid_surface/__init__.py +17 -38
  12. resqpy/grid_surface/_blocked_well_populate.py +5 -5
  13. resqpy/grid_surface/_find_faces.py +1413 -253
  14. resqpy/lines/_polyline.py +24 -33
  15. resqpy/model/_catalogue.py +9 -0
  16. resqpy/model/_forestry.py +18 -14
  17. resqpy/model/_hdf5.py +11 -3
  18. resqpy/model/_model.py +85 -10
  19. resqpy/model/_xml.py +38 -13
  20. resqpy/multi_processing/wrappers/grid_surface_mp.py +92 -37
  21. resqpy/olio/read_nexus_fault.py +8 -2
  22. resqpy/olio/relperm.py +1 -1
  23. resqpy/olio/transmission.py +8 -8
  24. resqpy/olio/triangulation.py +36 -30
  25. resqpy/olio/vector_utilities.py +340 -6
  26. resqpy/olio/volume.py +0 -20
  27. resqpy/olio/wellspec_keywords.py +19 -13
  28. resqpy/olio/write_hdf5.py +1 -1
  29. resqpy/olio/xml_et.py +12 -0
  30. resqpy/property/__init__.py +6 -4
  31. resqpy/property/_collection_add_part.py +4 -3
  32. resqpy/property/_collection_create_xml.py +4 -2
  33. resqpy/property/_collection_get_attributes.py +4 -0
  34. resqpy/property/attribute_property_set.py +311 -0
  35. resqpy/property/grid_property_collection.py +11 -11
  36. resqpy/property/property_collection.py +79 -31
  37. resqpy/property/property_common.py +3 -8
  38. resqpy/rq_import/_add_surfaces.py +34 -14
  39. resqpy/rq_import/_grid_from_cp.py +2 -2
  40. resqpy/rq_import/_import_nexus.py +75 -48
  41. resqpy/rq_import/_import_vdb_all_grids.py +64 -52
  42. resqpy/rq_import/_import_vdb_ensemble.py +12 -13
  43. resqpy/surface/_mesh.py +4 -0
  44. resqpy/surface/_surface.py +593 -118
  45. resqpy/surface/_tri_mesh.py +13 -10
  46. resqpy/surface/_tri_mesh_stencil.py +4 -4
  47. resqpy/surface/_triangulated_patch.py +71 -51
  48. resqpy/time_series/_any_time_series.py +7 -4
  49. resqpy/time_series/_geologic_time_series.py +1 -1
  50. resqpy/unstructured/_hexa_grid.py +6 -2
  51. resqpy/unstructured/_prism_grid.py +13 -5
  52. resqpy/unstructured/_pyramid_grid.py +6 -2
  53. resqpy/unstructured/_tetra_grid.py +6 -2
  54. resqpy/unstructured/_unstructured_grid.py +6 -2
  55. resqpy/well/_blocked_well.py +1986 -1946
  56. resqpy/well/_deviation_survey.py +3 -3
  57. resqpy/well/_md_datum.py +11 -21
  58. resqpy/well/_trajectory.py +10 -5
  59. resqpy/well/_wellbore_frame.py +10 -2
  60. resqpy/well/blocked_well_frame.py +3 -3
  61. resqpy/well/well_object_funcs.py +7 -9
  62. resqpy/well/well_utils.py +33 -0
  63. {resqpy-4.14.2.dist-info → resqpy-5.1.6.dist-info}/METADATA +8 -9
  64. {resqpy-4.14.2.dist-info → resqpy-5.1.6.dist-info}/RECORD +66 -66
  65. {resqpy-4.14.2.dist-info → resqpy-5.1.6.dist-info}/WHEEL +1 -1
  66. resqpy/grid/_moved_functions.py +0 -15
  67. {resqpy-4.14.2.dist-info → resqpy-5.1.6.dist-info}/LICENSE +0 -0
@@ -8,6 +8,7 @@ import logging
8
8
  log = logging.getLogger(__name__)
9
9
 
10
10
  import numpy as np
11
+ import math as maths
11
12
 
12
13
  import resqpy.crs as rqc
13
14
  import resqpy.lines as rql
@@ -47,7 +48,7 @@ class Surface(rqsb.BaseSurface):
47
48
  originator = None,
48
49
  extra_metadata = {}):
49
50
  """Create an empty Surface object (RESQML TriangulatedSetRepresentation).
50
-
51
+
51
52
  Optionally populates from xml, point set or mesh.
52
53
 
53
54
  arguments:
@@ -122,6 +123,76 @@ class Surface(rqsb.BaseSurface):
122
123
  self.set_from_mesh_file(mesh_file, mesh_format, quad_triangles = quad_triangles)
123
124
  elif tsurf_file is not None:
124
125
  self.set_from_tsurf_file(tsurf_file)
126
+ self._load_normal_vector_from_extra_metadata()
127
+
128
+ @classmethod
129
+ def from_list_of_patches(cls, model, patch_list, title, crs_uuid = None, extra_metadata = None):
130
+ """Create a Surface from a prepared list of TriangulatedPatch objects.
131
+
132
+ arguments:
133
+ - model (Model): the model to which the surface will be associated
134
+ - patch_list (list of TriangulatedPatch): the list of patches to be combined to form the surface
135
+ - title (str): the citation title for the new surface
136
+ - crs_uuid (uuid, optional): the uuid of a crs in model which the points are deemed to be in
137
+ - extra_metadata (dict of (str: str), optional): extra metadata to add to the new surface
138
+
139
+ returns:
140
+ - new Surface comprised of a patch for each entry in the patch list
141
+
142
+ notes:
143
+ - the triangulated patch objects are used directly in the surface
144
+ - the patches should not have had their hdf5 data written yet
145
+ - the patch index values will be set, with any previous values ignored
146
+ - the patches will be hijacked to the target model if their model is different
147
+ - each patch will have its points converted in situ into the surface crs
148
+ - if the crs_uuid argument is None, the crs_uuid is taken from the first patch
149
+ """
150
+ assert len(patch_list) > 0, 'attempting to create Surface from empty patch list'
151
+ if crs_uuid is None:
152
+ crs_uuid = patch_list[0].crs_uuid
153
+ if model.uuid(uuid = crs_uuid) is None:
154
+ model.copy_uuid_from_other_model(patch_list[0].model, crs_uuid)
155
+ surf = cls(model, title = title, crs_uuid = crs_uuid, extra_metadata = extra_metadata)
156
+ surf.patch_list = patch_list
157
+ surf.crs_uuid = crs_uuid
158
+ crs = rqc.Crs(model, uuid = crs_uuid)
159
+ for i, patch in enumerate(surf.patch_list):
160
+ assert patch.points is not None, f'points missing in patch {i} when making surface {title}'
161
+ patch.index = i
162
+ patch._set_t_type()
163
+ if not bu.matching_uuids(patch.crs_uuid, crs_uuid):
164
+ p_crs = rqc.Crs(patch.model, uuid = patch.crs_uuid)
165
+ p_crs.convert_array_to(crs, patch.points)
166
+ patch.model = model
167
+ return surf
168
+
169
+ @classmethod
170
+ def from_list_of_patches_of_triangles_and_points(cls, model, t_p_list, title, crs_uuid, extra_metadata = None):
171
+ """Create a Surface from a prepared list of pairs of (triangles, points).
172
+
173
+ arguments:
174
+ - model (Model): the model to which the surface will be associated
175
+ - t_p_list (list of (numpy int array, numpy float array)): the list of patches of triangles and points;
176
+ the int arrays have shape (N, 3) being the triangle vertex indices of points; the float array has
177
+ shape (M, 3) being the xyx values for the points, in the crs identified by crs_uuid
178
+ - title (str): the citation title for the new surface
179
+ - crs_uuid (uuid): the uuid of a crs in model which the points are deemed to be in
180
+ - extra_metadata (dict of (str: str), optional): extra metadata to add to the new surface
181
+
182
+ returns:
183
+ - new Surface comprised of a patch for each entry in the list of pairs of triangles and points data
184
+
185
+ note:
186
+ - each entry in the t_p_list will have its own patch in the resulting surface, indexed in order of list
187
+ """
188
+ assert t_p_list, f'no triangles and points pairs in list when generating surface: {title}'
189
+ assert crs_uuid is not None
190
+ patch_list = []
191
+ for i, (t, p) in enumerate(t_p_list):
192
+ patch = rqs.TriangulatedPatch(model, patch_index = i, crs_uuid = crs_uuid)
193
+ patch.set_from_triangles_and_points(t, p)
194
+ patch_list.append(patch)
195
+ return cls.from_list_of_patches(model, patch_list, title, crs_uuid = crs_uuid, extra_metadata = extra_metadata)
125
196
 
126
197
  @classmethod
127
198
  def from_tri_mesh(cls, tri_mesh, exclude_nans = False):
@@ -272,69 +343,169 @@ class Surface(rqsb.BaseSurface):
272
343
 
273
344
  self.model = parent_model
274
345
 
275
- def triangles_and_points(self):
276
- """Returns arrays representing combination of all the patches in the surface.
346
+ def number_of_patches(self):
347
+ """Returns the number of patches present in the surface."""
348
+
349
+ self.extract_patches(self.root)
350
+ return len(self.patch_list)
351
+
352
+ def triangles_and_points(self, patch = None, copy = False):
353
+ """Returns arrays representing one patch or a combination of all the patches in the surface.
354
+
355
+ arguments:
356
+ patch (int, optional): patch index; if None, combined arrays for all patches are returned
357
+ copy (bool, default False): if True, a copy of the arrays is returned; if False, the cached
358
+ arrays are returned
277
359
 
278
360
  returns:
279
361
  tuple (triangles, points):
280
362
  triangles (int array of shape[:, 3]): integer indices into points array,
281
- being the nodes of the corners of the triangles;
363
+ being the nodes of the corners of the triangles;
282
364
  points (float array of shape[:, 3]): flat array of xyz points, indexed by triangles
283
365
 
284
366
  :meta common:
285
367
  """
286
368
 
287
- if self.triangles is not None:
288
- return (self.triangles, self.points)
289
369
  self.extract_patches(self.root)
290
- points_offset = 0
291
- for triangulated_patch in self.patch_list:
292
- (t, p) = triangulated_patch.triangles_and_points()
293
- if points_offset == 0:
294
- self.triangles = t.copy()
295
- self.points = p.copy()
370
+ if patch is None:
371
+ if self.triangles is None or self.points is None:
372
+ if self.triangles is None:
373
+ points_offset = 0
374
+ for triangulated_patch in self.patch_list:
375
+ (t, p) = triangulated_patch.triangles_and_points()
376
+ if points_offset == 0:
377
+ self.triangles = t
378
+ self.points = p
379
+ else:
380
+ self.triangles = np.concatenate((self.triangles, t.copy() + points_offset))
381
+ self.points = np.concatenate((self.points, p))
382
+ points_offset += p.shape[0]
383
+ if copy:
384
+ return (self.triangles.copy(), self.points.copy())
296
385
  else:
297
- self.triangles = np.concatenate((self.triangles, t.copy() + points_offset))
298
- self.points = np.concatenate((self.points, p.copy()))
299
- points_offset += p.shape[0]
300
- return (self.triangles, self.points)
386
+ return (self.triangles, self.points)
387
+ assert 0 <= patch < len(self.patch_list), \
388
+ ValueError(f'patch index {patch} out of range for surface with {len(self.patch_list)} patches')
389
+ return self.patch_list[patch].triangles_and_points(copy = copy)
390
+
391
+ def patch_index_for_triangle_index(self, triangle_index):
392
+ """Returns the patch index for a triangle index (as applicable to triangles_and_points() triangles)."""
393
+ if triangle_index is None or triangle_index < 0:
394
+ return None
395
+ self.extract_patches(self.root)
396
+ if not self.patch_list:
397
+ return None
398
+ for i, patch in enumerate(self.patch_list):
399
+ triangle_index -= patch.triangle_count
400
+ if triangle_index < 0:
401
+ return i
402
+ return None
403
+
404
+ def patch_indices_for_triangle_indices(self, triangle_indices, lazy = True):
405
+ """Returns array of patch indices for array of triangle indices (as applicable to triangles_and_points() triangles)."""
406
+ self.extract_patches(self.root)
407
+ if not self.patch_list:
408
+ return np.full(triangle_indices.shape, -1, dtype = np.int8)
409
+ patch_count = len(self.patch_list)
410
+ dtype = (np.int8 if patch_count < 127 else np.int32)
411
+ if lazy and patch_count == 1:
412
+ return np.zeros(triangle_indices.shape, dtype = np.int8)
413
+ patch_limits = np.zeros(patch_count, dtype = np.int32)
414
+ t_count = 0
415
+ for p_i in range(patch_count):
416
+ t_count += self.patch_list[p_i].triangle_count
417
+ patch_limits[p_i] = t_count
418
+ patches = np.empty(triangle_indices.shape, dtype = dtype)
419
+ patches[:] = np.digitize(triangle_indices, patch_limits, right = False)
420
+ if not lazy:
421
+ patches[np.logical_or(triangle_indices < 0, patches == patch_count)] = -1
422
+ return patches
423
+
424
+ def decache_triangles_and_points(self):
425
+ """Removes the cached composite triangles and points arrays."""
426
+ self.points = None
427
+ self.triangles = None
428
+
429
+ def triangle_count(self, patch = None):
430
+ """Return the numner of triangles in this surface, or in one patch.
301
431
 
302
- def triangle_count(self):
303
- """Return the numner of triangles in this surface."""
432
+ arguments:
433
+ patch (int, optional): patch index; if None, a combined triangle count for all patches is returned
434
+
435
+ returns:
436
+ int being the number of trianges in the patch (if specified) or in all the patches
437
+ """
304
438
 
305
439
  self.extract_patches(self.root)
306
- if not self.patch_list:
307
- return 0
308
- return np.sum([tp.triangle_count for tp in self.patch_list])
440
+ if patch is None:
441
+ if not self.patch_list:
442
+ return 0
443
+ return np.sum([tp.triangle_count for tp in self.patch_list])
444
+ assert 0 <= patch < len(self.patch_list), \
445
+ ValueError(f'patch index {patch} out of range for surface with {len(self.patch_list)} patches in triangle_count')
446
+ return self.patch_list[patch].triangle_count
447
+
448
+ def node_count(self, patch = None):
449
+ """Return the number of nodes (points) used in this surface, or in one patch.
450
+
451
+ arguments:
452
+ patch (int, optional): patch index; if None, a combined node count for all patches is returned
309
453
 
310
- def node_count(self):
311
- """Return the number of nodes (points) used in this surface."""
454
+ returns:
455
+ int being the number of nodes in the patch (if specified) or in all the patches
456
+
457
+ note:
458
+ a multi patch surface might have more than one node colocated; this method will treat such coincident nodes
459
+ as separate nodes
460
+ """
312
461
 
313
462
  self.extract_patches(self.root)
314
- if not self.patch_list:
315
- return 0
316
- return np.sum([tp.node_count for tp in self.patch_list])
463
+ if patch is None:
464
+ if not self.patch_list:
465
+ return 0
466
+ return np.sum([tp.node_count for tp in self.patch_list])
467
+ assert 0 <= patch < len(self.patch_list), \
468
+ ValueError(f'patch index {patch} out of range for surface with {len(self.patch_list)} patches in node_count')
469
+ return self.patch_list[patch].node_count
317
470
 
318
471
  def change_crs(self, required_crs):
319
472
  """Changes the crs of the surface, also sets a new uuid if crs changed.
320
473
 
321
- note:
474
+ notes:
322
475
  this method is usually used to change the coordinate system for a temporary resqpy object;
323
- to add as a new part, call write_hdf5() and create_xml() methods
476
+ to add as a new part, call write_hdf5() and create_xml() methods;
477
+ patches are maintained by this method;
478
+ normal vector extra metadata item is updated if present; rotation matrix is removed
324
479
  """
325
480
 
326
481
  old_crs = rqc.Crs(self.model, uuid = self.crs_uuid)
327
482
  self.crs_uuid = required_crs.uuid
328
- if required_crs == old_crs or not self.patch_list:
483
+ if bu.matching_uuids(required_crs.uuid, old_crs.uuid) or not self.patch_list:
329
484
  log.debug(f'no crs change needed for {self.title}')
330
485
  return
486
+ equivalent_crs = (required_crs == old_crs)
331
487
  log.debug(f'crs change needed for {self.title} from {old_crs.title} to {required_crs.title}')
332
488
  for patch in self.patch_list:
333
- patch.triangles_and_points()
334
- required_crs.convert_array_from(old_crs, patch.points)
489
+ assert bu.matching_uuids(patch.crs_uuid, old_crs.uuid)
490
+ if not equivalent_crs:
491
+ patch.triangles_and_points()
492
+ required_crs.convert_array_from(old_crs, patch.points)
335
493
  patch.crs_uuid = self.crs_uuid
336
494
  self.triangles = None # clear cached arrays for surface
337
495
  self.points = None
496
+ if not equivalent_crs:
497
+ if self.extra_metadata.pop('rotation matrix', None) is not None:
498
+ log.debug(f'discarding rotation matrix extra metadata during crs change of: {self.title}')
499
+ self._load_normal_vector_from_extra_metadata()
500
+ if self.normal_vector is not None:
501
+ if required_crs.z_inc_down != old_crs.z_inc_down:
502
+ self.normal_vector[2] = -self.normal_vector[2]
503
+ theta = (wam.convert(required_crs.rotation, required_crs.rotation_units, 'dega') -
504
+ wam.convert(old_crs.rotation, old_crs.rotation_units, 'dega'))
505
+ if not maths.isclose(theta, 0.0):
506
+ self.normal_vector = vec.rotate_vector(vec.rotation_matrix_3d_axial(2, theta), self.normal_vector)
507
+ self.extra_metadata['normal vector'] = str(
508
+ f'{self.normal_vector[0]},{self.normal_vector[1]},{self.normal_vector[2]}')
338
509
  self.uuid = bu.new_uuid() # hope this doesn't cause problems
339
510
  assert self.root is None
340
511
 
@@ -381,7 +552,7 @@ class Surface(rqsb.BaseSurface):
381
552
  self.uuid = bu.new_uuid()
382
553
 
383
554
  def set_to_split_surface(self, large_surface, line, delta_xyz):
384
- """Populate this (empty) surface with a version of a larger surface split by an xy line.
555
+ """Populate this (empty) surface with a version of a larger surface split by a straight xy line.
385
556
 
386
557
  arguments:
387
558
  large_surface (Surface): the larger surface, a copy of which is to be split
@@ -399,13 +570,14 @@ class Surface(rqsb.BaseSurface):
399
570
  t, p = large_surface.triangles_and_points()
400
571
  assert p.ndim == 2 and p.shape[1] == 3
401
572
  pp = np.concatenate((p, line), axis = 0)
402
- tp = np.empty(p.shape, dtype = int)
573
+ t_type = np.int32 if len(pp) <= 2_147_483_647 else np.int64
574
+ tp = np.empty(p.shape, dtype = t_type)
403
575
  tp[:, 0] = len(p)
404
576
  tp[:, 1] = len(p) + 1
405
- tp[:, 2] = np.arange(len(p), dtype = int)
577
+ tp[:, 2] = np.arange(len(p), dtype = t_type)
406
578
  cw = vec.clockwise_triangles(pp, tp)
407
- pai = np.where(cw >= 0.0, True, False) # bool mask over p
408
- pbi = np.where(cw <= 0.0, True, False) # bool mask over p
579
+ pai = (cw >= 0.0) # bool mask over p
580
+ pbi = (cw <= 0.0) # bool mask over p
409
581
  tap = pai[t]
410
582
  tbp = pbi[t]
411
583
  ta = np.any(tap, axis = 1) # bool array over t
@@ -414,11 +586,11 @@ class Surface(rqsb.BaseSurface):
414
586
  # here we stick the two halves together into a single patch
415
587
  # todo: keep as two patches as required by RESQML business rules
416
588
  p_combo = np.empty((0, 3))
417
- t_combo = np.empty((0, 3), dtype = int)
589
+ t_combo = np.empty((0, 3), dtype = t_type)
418
590
  for i, tab in enumerate((ta, tb)):
419
591
  p_keep = np.unique(t[tab])
420
592
  # note new point index for each old point that is being kept
421
- p_map = np.full(len(p), -1, dtype = int)
593
+ p_map = np.full(len(p), -1, dtype = t_type)
422
594
  p_map[p_keep] = np.arange(len(p_keep))
423
595
  # copy those unique points into a trimmed points array
424
596
  points_trimmed = p[p_keep].copy()
@@ -435,17 +607,24 @@ class Surface(rqsb.BaseSurface):
435
607
 
436
608
  self.set_from_triangles_and_points(t_combo, p_combo)
437
609
 
438
- def distinct_edges(self):
439
- """Returns a numpy int array of shape (N, 2) being the ordered node pairs of distinct edges of triangles."""
610
+ def distinct_edges(self, patch = None):
611
+ """Returns a numpy int array of shape (N, 2) being the ordered node pairs of distinct edges of triangles.
612
+
613
+ arguments:
614
+ patch (int, optional): patch index; if None, a combination of edges for all patches is returned
615
+ """
440
616
 
441
- triangles, _ = self.triangles_and_points()
617
+ triangles, _ = self.triangles_and_points(patch = patch)
442
618
  assert triangles is not None
443
619
  unique_edges, _ = triangulate.edges(triangles)
444
620
  return unique_edges
445
621
 
446
- def distinct_edges_and_counts(self):
622
+ def distinct_edges_and_counts(self, patch = None):
447
623
  """Returns unique edges as pairs of point indices, and a count of uses of each edge.
448
624
 
625
+ arguments:
626
+ patch (int, optional): patch index; if None, combined results for all patches are returned
627
+
449
628
  returns:
450
629
  numpy int array of shape (N, 2), numpy int array of shape (N,)
451
630
  where 2D array is list-like sorted points index pairs for unique edges
@@ -453,14 +632,39 @@ class Surface(rqsb.BaseSurface):
453
632
 
454
633
  notes:
455
634
  first entry in each pair is always the lower of the two point indices;
456
- for well formed surfaces, the count should everywhere be zero or one;
635
+ for well formed surfaces, the count should everywhere be one or two;
457
636
  the function does not attempt to detect coincident points
458
637
  """
459
638
 
460
- triangles, _ = self.triangles_and_points()
639
+ triangles, _ = self.triangles_and_points(patch = patch)
461
640
  assert triangles is not None
462
641
  return triangulate.edges(triangles)
463
642
 
643
+ def edge_lengths(self, required_uom = None, patch = None):
644
+ """Returns float array of shape (N, 3) being triangle edge lengths.
645
+
646
+ arguments:
647
+ required_uom (str, optional): the required length uom for the resulting edge lengths; default is crs xy units
648
+ patch (int, optional): patch index; if None, edge lengths for all patches are returned
649
+
650
+ returns:
651
+ numpy float array of shape (N, 3) where N is the number of triangles
652
+ """
653
+
654
+ t, p = self.triangles_and_points(patch = patch)
655
+ crs = rqc.Crs(self.model, uuid = self.crs_uuid)
656
+ if required_uom is None:
657
+ required_uom = crs.xy_units
658
+ if crs.xy_units != required_uom or crs.z_units != required_uom:
659
+ p = p.copy()
660
+ wam.convert_lengths(p[:, :2], crs.xy_units, required_uom)
661
+ wam.convert_lengths(p[:, 2], crs.z_units, required_uom)
662
+ t_end = np.empty_like(t)
663
+ t_end[:, :2] = t[:, 1:]
664
+ t_end[:, 2] = t[:, 0]
665
+ edge_v = p[t_end, :] - p[t, :]
666
+ return vec.naive_lengths(edge_v)
667
+
464
668
  def set_from_triangles_and_points(self, triangles, points):
465
669
  """Populate this (empty) Surface object from an array of triangle corner indices and an array of points."""
466
670
 
@@ -468,6 +672,22 @@ class Surface(rqsb.BaseSurface):
468
672
  tri_patch.set_from_triangles_and_points(triangles, points)
469
673
  self.patch_list = [tri_patch]
470
674
  self.uuid = bu.new_uuid()
675
+ self.triangles = triangles.copy()
676
+ self.points = points.copy()
677
+
678
+ def set_multi_patch_from_triangles_and_points(self, triangles_and_points_list):
679
+ """Populate this (empty) Surface object from a list of paits: array of triangle corner indices, array of points."""
680
+
681
+ self.patch_list = []
682
+ self.trianges = None
683
+ self.points = None
684
+ for patch, entry in enumerate(triangles_and_points_list):
685
+ assert len(entry) == 2, 'expecting pair of arrays (triangles, points) for each patch'
686
+ triangles, points = entry
687
+ tri_patch = rqstp.TriangulatedPatch(self.model, patch_index = patch, crs_uuid = self.crs_uuid)
688
+ tri_patch.set_from_triangles_and_points(triangles, points)
689
+ self.patch_list.append(tri_patch)
690
+ self.uuid = bu.new_uuid()
471
691
 
472
692
  def set_from_point_set(self,
473
693
  point_set,
@@ -480,7 +700,8 @@ class Surface(rqsb.BaseSurface):
480
700
  flange_radial_distance = None,
481
701
  flange_inner_ring = False,
482
702
  saucer_parameter = None,
483
- make_clockwise = False):
703
+ make_clockwise = False,
704
+ normal_vector = None):
484
705
  """Populate this (empty) Surface object with a Delaunay triangulation of points in a PointSet object.
485
706
 
486
707
  arguments:
@@ -489,9 +710,10 @@ class Surface(rqsb.BaseSurface):
489
710
  convex; reduce to 1.0 to allow slightly more concavities; increase to 100.0 or more for very little
490
711
  chance of even a slight concavity
491
712
  reorient (bool, default False): if True, a copy of the points is made and reoriented to minimise the
492
- z range (ie. z axis is approximate normal to plane of points), to enhace the triangulation
713
+ z range (ie. z axis is approximate normal to plane of points), to enhace the triangulation; if a
714
+ normal_vector is supplied, the reorientation is based on that instead of minimising z
493
715
  reorient_max_dip (float, optional): if present, the reorientation of perspective off vertical is
494
- limited to this angle in degrees
716
+ limited to this angle in degrees; ignored if normal_vector is specified
495
717
  extend_with_flange (bool, default False): if True, a ring of points is added around the outside of the
496
718
  points before the triangulation, effectively extending the surface with a flange
497
719
  flange_point_count (int, default 11): the number of points to generate in the flange ring; ignored if
@@ -509,10 +731,12 @@ class Surface(rqsb.BaseSurface):
509
731
  make_clockwise (bool, default False): if True, the returned triangles will all be clockwise when
510
732
  viewed in the direction -ve to +ve z axis; if reorient is also True, the clockwise aspect is
511
733
  enforced in the reoriented space
734
+ normal_vector (triple float, optional): if present and reorienting, the normal vector to use for reorientation;
735
+ if None, the reorientation is made so as to minimise the z range
512
736
 
513
737
  returns:
514
738
  if extend_with_flange is True, numpy bool array with a value per triangle indicating flange triangles;
515
- if extent_with_flange is False, None
739
+ if extend_with_flange is False, None
516
740
 
517
741
  notes:
518
742
  if extend_with_flange is True, then a boolean array is created for the surface, with a value per triangle,
@@ -520,23 +744,18 @@ class Surface(rqsb.BaseSurface):
520
744
  suitable for adding as a property for the surface, with indexable element 'faces';
521
745
  when flange extension occurs, the radius is the greater of the values determined from the radial factor
522
746
  and radial distance arguments;
523
- the saucer_parameter is interpreted in one of two ways: (1) +ve fractoinal values between zero and one
524
- are the fractional distance from the centre of the points to its rim at which to sample the surface for
525
- extrapolation and thereby modify the recumbent z of flange points; 0 will usually give shallower and
526
- smoother saucer; larger values (must be less than one) will lead to stronger and more erratic saucer
527
- shape in flange; (2) other values between -90.0 and 90.0 are interpreted as an angle to apply out of
528
- the plane of the original points, to give a simple (and less computationally demanding) saucer shape;
529
- +ve angles result in the shift being in the direction of the -ve z hemisphere; -ve angles result in
530
- the shift being in the +ve z hemisphere; in either case the direction of the shift is perpendicular
531
- to the average plane of the original points
747
+ the saucer_parameter must be between -90.0 and 90.0, and is interpreted as an angle to apply out of
748
+ the plane of the original points, to give a simple saucer shape; +ve angles result in the shift being in
749
+ the direction of the -ve z hemisphere; -ve angles result in the shift being in the +ve z hemisphere; in
750
+ either case the direction of the shift is perpendicular to the average plane of the original points;
751
+ normal_vector, if supplied, should be in the crs of the point set
532
752
  """
533
753
 
534
754
  simple_saucer_angle = None
535
- if saucer_parameter is not None and (saucer_parameter > 1.0 or saucer_parameter < 0.0):
755
+ if saucer_parameter is not None:
536
756
  assert -90.0 < saucer_parameter < 90.0, f'simple saucer angle parameter must be less than 90 degrees; too big: {saucer_parameter}'
537
757
  simple_saucer_angle = saucer_parameter
538
758
  saucer_parameter = None
539
- assert saucer_parameter is None or 0.0 <= saucer_parameter < 1.0
540
759
  crs = rqc.Crs(self.model, uuid = point_set.crs_uuid)
541
760
  p = point_set.full_array_ref()
542
761
  assert p.ndim >= 2
@@ -549,37 +768,44 @@ class Surface(rqsb.BaseSurface):
549
768
  f'removing {len(p) - np.count_nonzero(row_mask)} NaN points from point set {point_set.title} prior to surface triangulation'
550
769
  )
551
770
  p = p[row_mask, :]
552
- if crs.xy_units == crs.z_units or not reorient:
771
+ if crs.xy_units == crs.z_units:
553
772
  unit_adjusted_p = p
554
773
  else:
555
774
  unit_adjusted_p = p.copy()
556
775
  wam.convert_lengths(unit_adjusted_p[:, 2], crs.z_units, crs.xy_units)
557
- if reorient:
776
+ # note: normal vector should already be for a crs with common xy & z units
777
+ # reorient the points to the fault normal vector
778
+ if normal_vector is None:
558
779
  p_xy, self.normal_vector, reorient_matrix = triangulate.reorient(unit_adjusted_p,
559
780
  max_dip = reorient_max_dip)
560
781
  else:
561
- p_xy = unit_adjusted_p
782
+ assert len(normal_vector) == 3
783
+ self.normal_vector = np.array(normal_vector, dtype = np.float64)
784
+ if self.normal_vector[2] < 0.0:
785
+ self.normal_vector = -self.normal_vector
786
+ incl = vec.inclination(normal_vector)
787
+ if maths.isclose(incl, 0.0):
788
+ reorient_matrix = vec.no_rotation_matrix()
789
+ p_xy = unit_adjusted_p
790
+ else:
791
+ azi = vec.azimuth(normal_vector)
792
+ reorient_matrix = vec.tilt_3d_matrix(azi, incl)
793
+ p_xy = vec.rotate_array(reorient_matrix, unit_adjusted_p)
562
794
  if extend_with_flange:
563
- if not reorient:
564
- assert saucer_parameter is None and simple_saucer_angle is None, \
565
- 'flange saucer mode only available with reorientation active'
566
- log.warning('extending point set with flange without reorientation')
567
- flange_points = triangulate.surrounding_xy_ring(p_xy,
568
- count = flange_point_count,
569
- radial_factor = flange_radial_factor,
570
- radial_distance = flange_radial_distance,
571
- inner_ring = flange_inner_ring,
572
- saucer_angle = simple_saucer_angle)
573
- p_xy_e = np.concatenate((p_xy, flange_points), axis = 0)
795
+ flange_points, radius = triangulate.surrounding_xy_ring(p_xy,
796
+ count = flange_point_count,
797
+ radial_factor = flange_radial_factor,
798
+ radial_distance = flange_radial_distance,
799
+ inner_ring = flange_inner_ring,
800
+ saucer_angle = 0.0)
801
+ flange_points_reverse_oriented = vec.rotate_array(reorient_matrix.T, flange_points)
574
802
  if reorient:
575
- # reorient back extenstion points into original p space
576
- flange_points_reverse_oriented = vec.rotate_array(reorient_matrix.T, flange_points)
577
- p_e = np.concatenate((unit_adjusted_p, flange_points_reverse_oriented), axis = 0)
803
+ p_xy_e = np.concatenate((p_xy, flange_points), axis = 0)
578
804
  else:
579
- p_e = p_xy_e
805
+ p_xy_e = np.concatenate((unit_adjusted_p, flange_points_reverse_oriented), axis = 0)
806
+
580
807
  else:
581
- p_xy_e = p_xy
582
- p_e = unit_adjusted_p
808
+ p_xy_e = unit_adjusted_p
583
809
  flange_array = None
584
810
  log.debug('number of points going into dt: ' + str(len(p_xy_e)))
585
811
  success = False
@@ -594,19 +820,214 @@ class Surface(rqsb.BaseSurface):
594
820
  t = triangulate.dt(p_xy_e[:, :2], container_size_factor = convexity_parameter * 1.1)
595
821
  log.debug('number of triangles: ' + str(len(t)))
596
822
  if make_clockwise:
597
- triangulate.make_all_clockwise_xy(t, p_e) # modifies t in situ
823
+ triangulate.make_all_clockwise_xy(t, p_xy_e) # modifies t in situ
598
824
  if extend_with_flange:
599
825
  flange_array = np.zeros(len(t), dtype = bool)
600
826
  flange_array[:] = np.where(np.any(t >= len(p), axis = 1), True, False)
601
- if saucer_parameter is not None:
602
- _adjust_flange_z(self.model, self.crs_uuid, p_xy_e, len(p), t, flange_array, saucer_parameter)
603
- p_e = vec.rotate_array(reorient_matrix.T, p_xy_e)
604
- if crs.xy_units != crs.z_units and reorient:
605
- wam.convert_lengths(p_e[:, 2], crs.xy_units, crs.z_units)
827
+ if simple_saucer_angle is not None:
828
+ assert abs(simple_saucer_angle) < 90.0
829
+ z_shift = radius * maths.tan(vec.radians_from_degrees(simple_saucer_angle))
830
+ flange_points[:, 2] -= z_shift
831
+ flange_points_reverse_oriented = vec.rotate_array(reorient_matrix.T, flange_points)
832
+ if crs.xy_units != crs.z_units:
833
+ wam.convert_lengths(flange_points_reverse_oriented[:, 2], crs.xy_units, crs.z_units)
834
+ p_e = np.concatenate((p, flange_points_reverse_oriented))
835
+ else:
836
+ p_e = p
606
837
  self.crs_uuid = point_set.crs_uuid
607
838
  self.set_from_triangles_and_points(t, p_e)
608
839
  return flange_array
609
840
 
841
+ def extend_surface_with_flange(self,
842
+ convexity_parameter = 5.0,
843
+ reorient = False,
844
+ reorient_max_dip = None,
845
+ flange_point_count = 11,
846
+ flange_radial_factor = 10.0,
847
+ flange_radial_distance = None,
848
+ flange_inner_ring = False,
849
+ saucer_parameter = None,
850
+ make_clockwise = False,
851
+ retriangulate = False,
852
+ normal_vector = None):
853
+ """Returns a new Surface object where the original surface has been extended with a flange with a Delaunay triangulation of points in a PointSet object.
854
+
855
+ arguments:
856
+ convexity_parameter (float, default 5.0): controls how likely the resulting triangulation is to be
857
+ convex; reduce to 1.0 to allow slightly more concavities; increase to 100.0 or more for very little
858
+ chance of even a slight concavity
859
+ reorient (bool, default False): if True, a copy of the points is made and reoriented to minimise the
860
+ z range (ie. z axis is approximate normal to plane of points), to enhace the triangulation; if
861
+ normal_vector is supplied that is used to determine the reorientation instead of minimising z
862
+ reorient_max_dip (float, optional): if present, the reorientation of perspective off vertical is
863
+ limited to this angle in degrees; ignored if normal_vector is specified
864
+ flange_point_count (int, default 11): the number of points to generate in the flange ring; ignored if
865
+ retriangulate is False
866
+ flange_radial_factor (float, default 10.0): distance of flange points from centre of points, as a
867
+ factor of the maximum radial distance of the points themselves; ignored if extend_with_flange is False
868
+ flange_radial_distance (float, optional): if present, the minimum absolute distance of flange points from
869
+ centre of points; units are those of the crs
870
+ flange_inner_ring (bool, default False): if True, an inner ring of points, with double flange point counr,
871
+ is created at a radius just outside that of the furthest flung original point; this improves
872
+ triangulation of the extended point set when the original has a non-convex hull. Ignored if retriangulate
873
+ is False
874
+ saucer_parameter (float, optional): if present, and extend_with_flange is True, then a parameter
875
+ controlling the shift of flange points in a perpendicular direction away from the fault plane;
876
+ see notes for how this parameter is interpreted
877
+ make_clockwise (bool, default False): if True, the returned triangles will all be clockwise when
878
+ viewed in the direction -ve to +ve z axis; if reorient is also True, the clockwise aspect is
879
+ enforced in the reoriented space
880
+ retriangulate (bool, default False): if True, the surface will be generated with a retriangulation of
881
+ the existing points. If False, the surface will be generated by adding flange points and triangles directly
882
+ from the original surface edges, and will no retriangulate the input surface. If False the surface must not
883
+ contain tears
884
+ normal_vector (triple float, optional): if present and reorienting, the normal vector to use for reorientation;
885
+ if None, the reorientation is made so as to minimise the z range
886
+
887
+ returns:
888
+ a new surface, and a boolean array of length N, where N is the number of triangles on the surface. This boolean
889
+ array is False on original triangle points, and True for extended flange triangles
890
+
891
+ notes:
892
+ a boolean array is created for the surface, with a value per triangle, set to False (zero) for non-flange
893
+ triangles and True (one) for flange triangles; this array is suitable for adding as a property for the
894
+ surface, with indexable element 'faces';
895
+ when flange extension occurs, the radius is the greater of the values determined from the radial factor
896
+ and radial distance arguments;
897
+ the saucer_parameter is interpreted in one of two ways: (1) +ve fractoinal values between zero and one
898
+ are the fractional distance from the centre of the points to its rim at which to sample the surface for
899
+ extrapolation and thereby modify the recumbent z of flange points; 0 will usually give shallower and
900
+ smoother saucer; larger values (must be less than one) will lead to stronger and more erratic saucer
901
+ shape in flange; (2) other values between -90.0 and 90.0 are interpreted as an angle to apply out of
902
+ the plane of the original points, to give a simple (and less computationally demanding) saucer shape;
903
+ +ve angles result in the shift being in the direction of the -ve z hemisphere; -ve angles result in
904
+ the shift being in the +ve z hemisphere; in either case the direction of the shift is perpendicular
905
+ to the average plane of the original points;
906
+ normal_vector, if supplied, should be in the crs of this surface
907
+ """
908
+ prev_t, prev_p = self.triangles_and_points()
909
+ point_set = rqs.PointSet(self.model, crs_uuid = self.crs_uuid, title = self.title, points_array = prev_p)
910
+ if retriangulate:
911
+ out_surf = Surface(self.model, crs_uuid = self.crs_uuid, title = self.title)
912
+ return out_surf, out_surf.set_from_point_set(point_set, convexity_parameter, reorient, reorient_max_dip,
913
+ True, flange_point_count, flange_radial_factor,
914
+ flange_radial_distance, flange_inner_ring, saucer_parameter,
915
+ make_clockwise, normal_vector)
916
+ else:
917
+ simple_saucer_angle = None
918
+ if saucer_parameter is not None and (saucer_parameter > 1.0 or saucer_parameter < 0.0):
919
+ assert -90.0 < saucer_parameter < 90.0, f'simple saucer angle parameter must be less than 90 degrees; too big: {saucer_parameter}'
920
+ simple_saucer_angle = saucer_parameter
921
+ saucer_parameter = None
922
+ assert saucer_parameter is None or 0.0 <= saucer_parameter < 1.0
923
+ crs = rqc.Crs(self.model, uuid = point_set.crs_uuid)
924
+ assert prev_p.ndim >= 2
925
+ assert prev_p.shape[-1] == 3
926
+ p = prev_p.reshape((-1, 3))
927
+ if crs.xy_units == crs.z_units or not reorient:
928
+ unit_adjusted_p = p
929
+ else:
930
+ unit_adjusted_p = p.copy()
931
+ wam.convert_lengths(unit_adjusted_p[:, 2], crs.z_units, crs.xy_units)
932
+ if reorient:
933
+ p_xy, normal, reorient_matrix = triangulate.reorient(unit_adjusted_p, max_dip = reorient_max_dip)
934
+ else:
935
+ p_xy = unit_adjusted_p
936
+ normal = self.normal()
937
+ reorient_matrix = None
938
+
939
+ centre_point = np.nanmean(p_xy.reshape((-1, 3)), axis = 0) # work out the radius for the flange points
940
+ p_radius_v = np.nanmax(np.abs(p.reshape((-1, 3)) - np.expand_dims(centre_point, axis = 0)), axis = 0)[:2]
941
+ p_radius = maths.sqrt(np.sum(p_radius_v * p_radius_v))
942
+ radius = p_radius * flange_radial_factor
943
+ if flange_radial_distance is not None and flange_radial_distance > radius:
944
+ radius = flange_radial_distance
945
+
946
+ de, dc = self.distinct_edges_and_counts() # find the distinct edges and counts
947
+ unique_edge = de[dc == 1] # find hull edges (edges on only a single triangle)
948
+ hull_points = p_xy[unique_edge] # find points defining the hull edges
949
+ hull_centres = np.mean(hull_points, axis = 1) # find the centre of each edge
950
+
951
+ flange_points = np.empty(
952
+ shape = (hull_centres.shape), dtype = float
953
+ ) # loop over all the hull centres, generating a flange point and finding the azimuth from the centre to the hull centre point
954
+ az = np.empty(shape = len(hull_centres), dtype = float)
955
+ for i, c in enumerate(hull_centres):
956
+ v = [centre_point[0] - c[0], centre_point[1] - c[1], centre_point[2] - c[2]]
957
+ uv = -vec.unit_vector(v)
958
+ az[i] = vec.azimuth(uv)
959
+ flange_point = centre_point + radius * uv
960
+ if simple_saucer_angle is not None:
961
+ z_shift = radius * maths.tan(vec.radians_from_degrees(simple_saucer_angle))
962
+ if reorient:
963
+ flange_point[2] -= z_shift
964
+ else:
965
+ flange_point -= (-vec.unit_vector(normal) * z_shift)
966
+ flange_points[i] = flange_point
967
+
968
+ sort_az_ind = np.argsort(np.array(az)) # sort by azimuth, to run through the hull points
969
+ new_points = np.empty(shape = (len(flange_points), 3), dtype = float)
970
+ new_triangles = np.empty(shape = (len(flange_points) * 2, 3), dtype = int)
971
+ point_offset = len(p_xy) # the indices of the new triangles will begin after this
972
+ for i, ind in enumerate(sort_az_ind): # loop over each point in azimuth order
973
+ new_points[i] = flange_points[ind]
974
+ this_hull_edge = unique_edge[ind]
975
+
976
+ def az_for_point(c):
977
+ v = [centre_point[0] - c[0], centre_point[1] - c[1], centre_point[2] - c[2]]
978
+ uv = -vec.unit_vector(v)
979
+ return vec.azimuth(uv)
980
+
981
+ this_edge_az_sort = np.array(
982
+ [az_for_point(p_xy[this_hull_edge[0]]),
983
+ az_for_point(p_xy[this_hull_edge[1]])])
984
+ if np.min(this_edge_az_sort) < az[ind] < np.max(this_edge_az_sort):
985
+ first, second = np.argsort(this_edge_az_sort)
986
+ else:
987
+ second, first = np.argsort(this_edge_az_sort)
988
+ if i != len(sort_az_ind) - 1:
989
+ new_triangles[2 * i] = np.array(
990
+ [this_hull_edge[first], this_hull_edge[second],
991
+ i + point_offset]) # add a triangle between the two hull points and the flange point
992
+ new_triangles[(2 * i) + 1] = np.array(
993
+ [this_hull_edge[second], i + point_offset,
994
+ i + point_offset + 1]) # for all but the last point, hookup triangle to the next flange point
995
+ else:
996
+ new_triangles[2 * i] = np.array(
997
+ [this_hull_edge[first], this_hull_edge[second],
998
+ i + point_offset]) # add a triangle between the two hull points and the first flange point
999
+ new_triangles[(2 * i) + 1] = np.array(
1000
+ [this_hull_edge[second], point_offset,
1001
+ i + point_offset]) # add in the final triangle between the first and last flange points
1002
+
1003
+ all_points = np.concatenate((p_xy, new_points)) # concatenate triangle and points arrays
1004
+ all_triangles = np.concatenate((prev_t, new_triangles))
1005
+
1006
+ flange_array = np.zeros(shape = all_triangles.shape[0], dtype = bool)
1007
+ flange_array[
1008
+ len(prev_t):] = True # make a flange bool array, where all new triangles are flange and therefore True
1009
+
1010
+ assert len(all_points) == (
1011
+ point_offset + len(flange_points)), "New point count should be old point count + flange point count"
1012
+ assert len(all_triangles) == (
1013
+ len(prev_t) +
1014
+ 2 * len(flange_points)), "New triangle count should be old triangle count + 2 x #flange points"
1015
+
1016
+ if saucer_parameter is not None:
1017
+ _adjust_flange_z(self.model, crs.uuid, all_points, len(all_points), all_triangles, flange_array,
1018
+ saucer_parameter) # adjust the flange points if in saucer mode
1019
+ if reorient:
1020
+ all_points = vec.rotate_array(reorient_matrix.T, all_points)
1021
+ if crs.xy_units != crs.z_units and reorient:
1022
+ wam.convert_lengths(all_points[:, 2], crs.xy_units, crs.z_units)
1023
+
1024
+ if make_clockwise:
1025
+ triangulate.make_all_clockwise_xy(all_triangles, all_points) # modifies t in situ
1026
+
1027
+ out_surf = Surface(self.model, crs_uuid = self.crs_uuid, title = self.title)
1028
+ out_surf.set_from_triangles_and_points(all_triangles, all_points) # create the new surface
1029
+ return out_surf, flange_array
1030
+
610
1031
  def make_all_clockwise_xy(self, reorient = False):
611
1032
  """Reorders cached triangles data such that all triangles are clockwise when viewed from -ve z axis.
612
1033
 
@@ -629,7 +1050,7 @@ class Surface(rqsb.BaseSurface):
629
1050
 
630
1051
  _, p = self.triangles_and_points()
631
1052
  crs = rqc.Crs(self.model, uuid = self.crs_uuid)
632
- if crs.xy_units == self.z_units:
1053
+ if crs.xy_units == crs.z_units:
633
1054
  return p
634
1055
  unit_adjusted_p = p.copy()
635
1056
  wam.convert_lengths(unit_adjusted_p[:, 2], crs.z_units, crs.xy_units)
@@ -641,9 +1062,10 @@ class Surface(rqsb.BaseSurface):
641
1062
  notes:
642
1063
  the result becomes more meaningless the less planar the surface is;
643
1064
  even for a parfectly planar surface, the result is approximate;
644
- true normal vector is found when xy & z units differ
1065
+ true normal vector is found when xy & z units differ, ie. for consistent units
645
1066
  """
646
1067
 
1068
+ self._load_normal_vector_from_extra_metadata()
647
1069
  if self.normal_vector is None:
648
1070
  p = self.unit_adjusted_points()
649
1071
  _, self.normal_vector, _ = triangulate.reorient(p)
@@ -746,7 +1168,7 @@ class Surface(rqsb.BaseSurface):
746
1168
 
747
1169
  def set_to_multi_cell_faces_from_corner_points(self, cp, quad_triangles = True):
748
1170
  """Populates this (empty) surface to represent faces of a set of cells.
749
-
1171
+
750
1172
  From corner points of shape (N, 2, 2, 2, 3).
751
1173
  """
752
1174
  assert cp.size % 24 == 0
@@ -782,7 +1204,7 @@ class Surface(rqsb.BaseSurface):
782
1204
 
783
1205
  def set_to_horizontal_plane(self, depth, box_xyz, border = 0.0, quad_triangles = False):
784
1206
  """Populate this (empty) surface with a patch of two triangles.
785
-
1207
+
786
1208
  Triangles define a flat, horizontal plane at a given depth.
787
1209
 
788
1210
  arguments:
@@ -850,7 +1272,7 @@ class Surface(rqsb.BaseSurface):
850
1272
  v_index = None
851
1273
  for line in lines:
852
1274
  if "VRTX" in line:
853
- words = line.rstrip().split(" ")
1275
+ words = line.rstrip().split()
854
1276
  v_i = int(words[1])
855
1277
  if v_index is None:
856
1278
  v_index = v_i
@@ -860,10 +1282,11 @@ class Surface(rqsb.BaseSurface):
860
1282
  v_index = v_i
861
1283
  vertices.append(words[2:5])
862
1284
  elif "TRGL" in line:
863
- triangles.append(line.rstrip().split(" ")[1:4])
1285
+ triangles.append(line.rstrip().split()[1:4])
864
1286
  assert len(vertices) >= 3, 'vertices missing'
865
1287
  assert len(triangles) > 0, 'triangles missing'
866
- triangles = np.array(triangles, dtype = int) - index_offset
1288
+ t_type = np.int32 if len(vertices) <= 2_147_483_647 else np.int64
1289
+ triangles = np.array(triangles, dtype = t_type) - index_offset
867
1290
  vertices = np.array(vertices, dtype = float)
868
1291
  assert np.all(triangles >= 0) and np.all(triangles < len(vertices)), 'triangle vertex indices out of range'
869
1292
  self.set_from_triangles_and_points(triangles = triangles, points = vertices)
@@ -885,7 +1308,7 @@ class Surface(rqsb.BaseSurface):
885
1308
 
886
1309
  def vertical_rescale_points(self, ref_depth = None, scaling_factor = 1.0):
887
1310
  """Modify the z values of points by rescaling.
888
-
1311
+
889
1312
  Stretches the distance from reference depth by scaling factor.
890
1313
  """
891
1314
  if scaling_factor == 1.0:
@@ -901,10 +1324,10 @@ class Surface(rqsb.BaseSurface):
901
1324
  for patch in self.patch_list:
902
1325
  patch.vertical_rescale_points(ref_depth, scaling_factor)
903
1326
 
904
- def line_intersection(self, line_p, line_v, line_segment = False):
1327
+ def line_intersection(self, line_p, line_v, line_segment = False, patch = None):
905
1328
  """Returns x,y,z of an intersection point of straight line with the surface, or None if no intersection found."""
906
1329
 
907
- t, p = self.triangles_and_points()
1330
+ t, p = self.triangles_and_points(patch = patch)
908
1331
  tp = p[t]
909
1332
  intersects = meet.line_triangles_intersects(line_p, line_v, tp, line_segment = line_segment)
910
1333
  indices = meet.intersects_indices(intersects)
@@ -912,21 +1335,22 @@ class Surface(rqsb.BaseSurface):
912
1335
  return None
913
1336
  return intersects[indices[0]]
914
1337
 
915
- def sample_z_at_xy_points(self, points, multiple_handling = 'any'):
1338
+ def sample_z_at_xy_points(self, points, multiple_handling = 'any', patch = None):
916
1339
  """Returns interpolated z values for an array of xy values.
917
1340
 
918
1341
  arguments:
919
1342
  points (numpy float array of shape (..., 2 or 3)): xy points to sample surface at (z values ignored)
920
1343
  multiple_handling (str, default 'any'): one of 'any', 'minimum', 'maximum', 'exception'
1344
+ patch (int, optional): patch index; if None, results are for the full surface
921
1345
 
922
1346
  returns:
923
1347
  numpy float array of shape points.shape[:-1] being z values interpolated from the surface z values
924
1348
 
925
1349
  notes:
926
1350
  points must be in the same crs as the surface;
927
- NaN will be set for any points that do not intersect with the surface in the xy projection;
928
- multiple_handling argument controls behaviour when one sample point intersects surface more than
929
- once: 'any' a random one of the intersection z values is returned; 'minimum' or 'maximum': the
1351
+ NaN will be set for any points that do not intersect with the patch or surface in the xy projection;
1352
+ multiple_handling argument controls behaviour when one sample point intersects more than once:
1353
+ 'any' a random one of the intersection z values is returned; 'minimum' or 'maximum': the
930
1354
  numerical min or max of the z values is returned; 'exception': a ValueError is raised
931
1355
  """
932
1356
 
@@ -938,10 +1362,10 @@ class Surface(rqsb.BaseSurface):
938
1362
  else:
939
1363
  sample_xy = np.zeros((points.size // 2, 3), dtype = float)
940
1364
  sample_xy[:, :2] = points.reshape((-1, 2))
941
- t, p = self.triangles_and_points()
1365
+ t, p = self.triangles_and_points(patch = patch)
942
1366
  p_list = vec.points_in_triangles_njit(sample_xy, p[t], 1)
943
1367
  vertical = np.array((0.0, 0.0, 1.0), dtype = float)
944
- z = np.full(sample_xy.shape[0], np.NaN, dtype = float)
1368
+ z = np.full(sample_xy.shape[0], np.nan, dtype = float)
945
1369
  for triangle_index, p_index, _ in p_list:
946
1370
  # todo: replace following with cheaper trilinear interpolation, given vertical intersection line
947
1371
  xyz = meet.line_triangle_intersect_numba(sample_xy[p_index], vertical, p[t[triangle_index]], t_tol = 0.05)
@@ -957,17 +1381,18 @@ class Surface(rqsb.BaseSurface):
957
1381
  raise ValueError(f'multiple {self.title} surface intersections at xy: {sample_xy[p_index]}')
958
1382
  return z.reshape(points.shape[:-1])
959
1383
 
960
- def normal_vectors(self, add_as_property: bool = False) -> np.ndarray:
961
- """Returns the normal vectors for each triangle in the surface.
962
-
1384
+ def normal_vectors(self, add_as_property: bool = False, patch = None) -> np.ndarray:
1385
+ """Returns the normal vectors for each triangle in the patch or surface.
1386
+
963
1387
  arguments:
964
- add_as_property (bool): if True, face_surface_normal_vectors_array is added as a property to the model.
1388
+ add_as_property (bool): if True, face_surface_normal_vectors_array is added as a property to the model
1389
+ patch (int, optional): patch index; if None, normal vectors for triangles in all patches are returned
965
1390
 
966
1391
  returns:
967
- normal_vectors_array (np.ndarray): the normal vectors corresponding to each triangle in the surface.
1392
+ normal_vectors_array (np.ndarray): the normal vectors corresponding to each triangle in the surface
968
1393
  """
969
1394
  crs = rqc.Crs(self.model, uuid = self.crs_uuid)
970
- triangles, points = self.triangles_and_points()
1395
+ triangles, points = self.triangles_and_points(patch = patch)
971
1396
  if crs.xy_units != crs.z_units:
972
1397
  points = points.copy()
973
1398
  wam.convert_lengths(points[:, 2], crs.z_units, crs.xy_units)
@@ -1027,13 +1452,13 @@ class Surface(rqsb.BaseSurface):
1027
1452
  return ep
1028
1453
 
1029
1454
  def resampled_surface(self, title = None):
1030
- """Creates a new triangulated set which is a resampled version of the current triangulated set. Each existing triangle in the tset is divided equally into 4 new triangles.
1031
-
1455
+ """Creates a new surface which is a refined version of this surface; each triangle is divided equally into 4 new triangles.
1456
+
1032
1457
  arguments:
1033
- title (str): a new title for the output triangulated set, if None the title will have the same title as the input triangulated set
1034
-
1458
+ title (str): title for the output triangulated set, if None the title will be inherited from the input surface
1459
+
1035
1460
  returns:
1036
- resqpy.surface.Surface object, with extra_metadata ('resampled from surface': <uuid>), where uuid is the origin surface uuid
1461
+ resqpy.surface.Surface object, with extra_metadata ('resampled from surface': uuid), where uuid is for the original surface uuid
1037
1462
  """
1038
1463
  rt, rp = self.triangles_and_points()
1039
1464
  edge1 = np.mean(rp[rt[:]][:, ::2, :], axis = 1)
@@ -1050,8 +1475,9 @@ class Surface(rqsb.BaseSurface):
1050
1475
 
1051
1476
  # TODO: implement alternate solution using edge functions in olio triangulation to optimise
1052
1477
  points_unique, inverse = np.unique(allpoints, axis = 0, return_inverse = True)
1053
- tris = np.array(tris)
1054
- tris_unique = np.empty(shape = tris.shape)
1478
+ t_type = np.int32 if len(allpoints) <= 2_147_483_647 else np.int64
1479
+ tris = np.array(tris, dtype = t_type)
1480
+ tris_unique = np.empty(shape = tris.shape, dtype = t_type)
1055
1481
  tris_unique[:, 0] = inverse[tris[:, 0]]
1056
1482
  tris_unique[:, 1] = inverse[tris[:, 1]]
1057
1483
  tris_unique[:, 2] = inverse[tris[:, 2]]
@@ -1066,6 +1492,37 @@ class Surface(rqsb.BaseSurface):
1066
1492
 
1067
1493
  return resampled
1068
1494
 
1495
+ def resample_surface_unique_edges(self):
1496
+ """Returns a new surface, with the same model, title and crs as the original, but with additional refined points along tears and edges.
1497
+
1498
+ Each edge forming a tear or outer edge in the surface will have 3 additional points added, with 2 additional points
1499
+ on each edge of the original triangle. The output surface is re-triangulated using these new points (tears will be filled)
1500
+
1501
+ returns:
1502
+ new Surface object with extra_metadata ('unique edges resampled from surface': uuid), where uuid is for the original surface uuid
1503
+
1504
+ note:
1505
+ this method involves a tr-triangulation
1506
+ """
1507
+ _, op = self.triangles_and_points()
1508
+ ref = self.resampled_surface() # resample the original surface
1509
+ rt, rp = ref.triangles_and_points()
1510
+ de, dc = ref.distinct_edges_and_counts() # find the distinct edges and counts for the resampled surface
1511
+ de_edge = de[dc == 1] # find edges that only appear once - tears or surface edges
1512
+ edge_tri_index = np.sum(np.isin(rt, de_edge), axis = 1) == 2
1513
+ edge_tris = rp[rt[edge_tri_index]]
1514
+ mid = np.mean(rp[de_edge], axis = 1) # get the midpoint of each surface edge
1515
+ edge_ref_points = np.unique(np.concatenate([op, edge_tris.reshape(-1, 3), mid]), axis = 0) # combine all points
1516
+
1517
+ points = rqs.PointSet(self.model, points_array = edge_ref_points, title = self.title,
1518
+ crs_uuid = self.crs_uuid) # generate a pointset from these points
1519
+
1520
+ output = Surface(self.model, point_set = points,
1521
+ extra_metadata = {'resampled from surface': str(self.uuid)
1522
+ }) # return a surface with generated from these points
1523
+
1524
+ return output
1525
+
1069
1526
  def write_hdf5(self, file_name = None, mode = 'a'):
1070
1527
  """Create or append to an hdf5 file, writing datasets for the triangulated patches after caching arrays.
1071
1528
 
@@ -1115,7 +1572,13 @@ class Surface(rqsb.BaseSurface):
1115
1572
  if not self.title:
1116
1573
  self.title = 'surface'
1117
1574
 
1118
- tri_rep = super().create_xml(add_as_part = False, title = title, originator = originator)
1575
+ em = None
1576
+ if self.normal_vector is not None and (self.extra_metadata is None or
1577
+ 'normal vector' not in self.extra_metadata):
1578
+ assert len(self.normal_vector) == 3
1579
+ em = {'normal vector': f'{self.normal_vector[0]},{self.normal_vector[1]},{self.normal_vector[2]}'}
1580
+
1581
+ tri_rep = super().create_xml(add_as_part = False, title = title, originator = originator, extra_metadata = em)
1119
1582
 
1120
1583
  # todo: if crs_uuid is None, attempt to set to surface patch crs uuid (within patch loop, below)
1121
1584
  if crs_uuid is not None:
@@ -1221,6 +1684,16 @@ class Surface(rqsb.BaseSurface):
1221
1684
 
1222
1685
  return tri_rep
1223
1686
 
1687
+ def _load_normal_vector_from_extra_metadata(self):
1688
+ if self.normal_vector is None and self.extra_metadata is not None:
1689
+ nv_str = self.extra_metadata.get('normal vector')
1690
+ if nv_str is not None:
1691
+ nv_words = nv_str.split(',')
1692
+ assert len(nv_words) == 3, f'failed to convert normal vector string into triplet: {nv_str}'
1693
+ self.normal_vector = np.empty(3, dtype = float)
1694
+ for i in range(3):
1695
+ self.normal_vector[i] = float(nv_words[i])
1696
+
1224
1697
 
1225
1698
  def distill_triangle_points(t, p):
1226
1699
  """Returns a (triangles, points) pair with points distilled as only those used from p."""
@@ -1229,7 +1702,8 @@ def distill_triangle_points(t, p):
1229
1702
  # find unique points used by triangles
1230
1703
  p_keep = np.unique(t)
1231
1704
  # note new point index for each old point that is being kept
1232
- p_map = np.full(len(p), -1, dtype = int)
1705
+ t_type = np.int32 if len(p) <= 2_147_483_647 else np.int64
1706
+ p_map = np.full(len(p), -1, dtype = t_type)
1233
1707
  p_map[p_keep] = np.arange(len(p_keep))
1234
1708
  # copy those unique points into a trimmed points array
1235
1709
  points_distilled = p[p_keep]
@@ -1256,8 +1730,9 @@ def nan_removed_triangles_and_points(t, p):
1256
1730
  expanded_mask[:] = np.expand_dims(np.logical_not(t_nan_mask), axis = -1)
1257
1731
  t_filtered = t[expanded_mask].reshape((-1, 3))
1258
1732
  # modified the filtered t values to adjust for the compression of filtered p
1259
- p_map = np.full(len(p), -1, dtype = int)
1260
- p_map[p_non_nan_mask] = np.arange(len(p_filtered), dtype = int)
1733
+ t_type = np.int32 if len(p) <= 2_147_483_647 else np.int64
1734
+ p_map = np.full(len(p), -1, dtype = t_type)
1735
+ p_map[p_non_nan_mask] = np.arange(len(p_filtered), dtype = t_type)
1261
1736
  t_filtered = p_map[t_filtered]
1262
1737
  assert t_filtered.ndim == 2 and t_filtered.shape[1] == 3
1263
1738
  assert not np.any(t_filtered < 0) and not np.any(t_filtered >= len(p_filtered))