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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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))