File headers: use SPDX license identifiers
[blender-addons.git] / ant_landscape / eroder.py
blobeee91bf753f1ab5a73e2b5083dc13ef8222cfa53
1 # SPDX-License-Identifier: GPL-2.0-or-later
4 from time import time
5 import unittest
6 import sys
7 import os
8 from random import random as rand, shuffle
9 import numpy as np
11 numexpr_available = False
14 def getmemsize():
15 return 0.0
18 def getptime():
19 return time()
22 class Grid:
24 def __init__(self, size=10, dtype=np.single):
25 self.center = np.zeros([size, size], dtype)
26 self.water = None
27 self.sediment = None
28 self.scour = None
29 self.flowrate = None
30 self.sedimentpct = None
31 self.sedimentpct = None
32 self.capacity = None
33 self.avalanced = None
34 self.minx = None
35 self.miny = None
36 self.maxx = None
37 self.maxy = None
38 self.zscale = 1
39 self.maxrss = 0.0
40 self.sequence = [0, 1, 2, 3]
41 self.watermax = 1.0
42 self.flowratemax = 1.0
43 self.scourmax = 1.0
44 self.sedmax = 1.0
45 self.scourmin = 1.0
48 def init_water_and_sediment(self):
49 if self.water is None:
50 self.water = np.zeros(self.center.shape, dtype=np.single)
51 if self.sediment is None:
52 self.sediment = np.zeros(self.center.shape, dtype=np.single)
53 if self.scour is None:
54 self.scour = np.zeros(self.center.shape, dtype=np.single)
55 if self.flowrate is None:
56 self.flowrate = np.zeros(self.center.shape, dtype=np.single)
57 if self.sedimentpct is None:
58 self.sedimentpct = np.zeros(self.center.shape, dtype=np.single)
59 if self.capacity is None:
60 self.capacity = np.zeros(self.center.shape, dtype=np.single)
61 if self.avalanced is None:
62 self.avalanced = np.zeros(self.center.shape, dtype=np.single)
65 def __str__(self):
66 return ''.join(self.__str_iter__(fmt="%.3f"))
69 def __str_iter__(self, fmt):
70 for row in self.center[::]:
71 values=[]
72 for v in row:
73 values.append(fmt%v)
74 yield ' '.join(values) + '\n'
77 @staticmethod
78 def fromFile(filename):
79 if filename == '-':
80 filename = sys.stdin
81 g=Grid()
82 g.center=np.loadtxt(filename,np.single)
83 return g
86 def toFile(self, filename, fmt="%.3f"):
87 if filename == '-' :
88 filename = sys.stdout.fileno()
89 with open(filename,"w") as f:
90 for line in self.__str_iter__(fmt):
91 f.write(line)
94 def raw(self,format="%.3f"):
95 fstr=format+" "+ format+" "+ format+" "
96 a=self.center / self.zscale
97 minx = 0.0 if self.minx is None else self.minx
98 miny = 0.0 if self.miny is None else self.miny
99 maxx = 1.0 if self.maxx is None else self.maxx
100 maxy = 1.0 if self.maxy is None else self.maxy
101 dx = (maxx - minx) / (a.shape[0] - 1)
102 dy = (maxy - miny) / (a.shape[1] - 1)
103 for row in range(a.shape[0] - 1):
104 row0 = miny + row * dy
105 row1 = row0 + dy
106 for col in range(a.shape[1] - 1):
107 col0 = minx + col * dx
108 col1 = col0 + dx
109 yield (fstr%(row0 ,col0 ,a[row ][col ])+
110 fstr%(row0 ,col1 ,a[row ][col+1])+
111 fstr%(row1 ,col0 ,a[row+1][col ])+"\n")
112 yield (fstr%(row0 ,col1 ,a[row ][col+1])+
113 fstr%(row1 ,col0 ,a[row+1][col ])+
114 fstr%(row1 ,col1 ,a[row+1][col+1])+"\n")
117 def toRaw(self, filename, infomap=None):
118 with open(filename if type(filename) == str else sys.stdout.fileno() , "w") as f:
119 f.writelines(self.raw())
120 if infomap:
121 with open(os.path.splitext(filename)[0]+".inf" if type(filename) == str else sys.stdout.fileno() , "w") as f:
122 f.writelines("\n".join("%-15s: %s"%t for t in sorted(infomap.items())))
125 @staticmethod
126 def fromRaw(filename):
127 """initialize a grid from a Blender .raw file.
128 currently supports just rectangular grids of all triangles
130 g = Grid.fromFile(filename)
131 # we assume tris and an axis aligned grid
132 g.center = np.reshape(g.center,(-1,3))
133 g._sort()
134 return g
137 def _sort(self, expfact):
138 # keep unique vertices only by creating a set and sort first on x then on y coordinate
139 # using rather slow python sort but couldn't wrap my head around np.lexsort
140 verts = sorted(list({ tuple(t) for t in self.center[::] }))
141 x = set(c[0] for c in verts)
142 y = set(c[1] for c in verts)
143 nx = len(x)
144 ny = len(y)
145 self.minx = min(x)
146 self.maxx = max(x)
147 self.miny = min(y)
148 self.maxy = max(y)
149 xscale = (self.maxx-self.minx)/(nx-1)
150 yscale = (self.maxy-self.miny)/(ny-1)
151 # note: a purely flat plane cannot be scaled
152 if (yscale != 0.0) and (abs(xscale/yscale) - 1.0 > 1e-3):
153 raise ValueError("Mesh spacing not square %d x %d %.4f x %4.f"%(nx,ny,xscale,yscale))
154 self.zscale = 1.0
155 if abs(yscale) > 1e-6 :
156 self.zscale = 1.0/yscale
158 # keep just the z-values and null any ofsset
159 # we might catch a reshape error that will occur if nx*ny != # of vertices (if we are not dealing with a heightfield but with a mesh with duplicate x,y coords, like an axis aligned cube
160 self.center = np.array([c[2] for c in verts],dtype=np.single).reshape(nx,ny)
161 self.center = (self.center-np.amin(self.center))*self.zscale
162 if self.rainmap is not None:
163 rmscale = np.max(self.center)
164 self.rainmap = expfact + (1-expfact)*(self.center/rmscale)
167 @staticmethod
168 def fromBlenderMesh(me, vg, expfact):
169 g = Grid()
170 g.center = np.asarray(list(tuple(v.co) for v in me.vertices), dtype=np.single )
171 g.rainmap = None
172 if vg is not None:
173 for v in me.vertices:
174 vg.add([v.index],0.0,'ADD')
175 g.rainmap=np.asarray(list( (v.co[0], v.co[1], vg.weight(v.index)) for v in me.vertices), dtype=np.single )
176 g._sort(expfact)
177 return g
180 def setrainmap(self, rainmap):
181 self.rainmap = rainmap
184 def _verts(self, surface):
185 a = surface / self.zscale
186 minx = 0.0 if self.minx is None else self.minx
187 miny = 0.0 if self.miny is None else self.miny
188 maxx = 1.0 if self.maxx is None else self.maxx
189 maxy = 1.0 if self.maxy is None else self.maxy
190 dx = (maxx - minx) / (a.shape[0] - 1)
191 dy = (maxy - miny) / (a.shape[1] - 1)
192 for row in range(a.shape[0]):
193 row0 = miny + row * dy
194 for col in range(a.shape[1]):
195 col0 = minx + col * dx
196 yield (row0 ,col0 ,a[row ][col ])
199 def _faces(self):
200 nrow, ncol = self.center.shape
201 for row in range(nrow-1):
202 for col in range(ncol-1):
203 vi = row * ncol + col
204 yield (vi, vi+ncol, vi+1)
205 yield (vi+1, vi+ncol, vi+ncol+1)
208 def toBlenderMesh(self, me):
209 # pass me as argument so that we don't need to import bpy and create a dependency
210 # the docs state that from_pydata takes iterators as arguments but it will fail with generators because it does len(arg)
211 me.from_pydata(list(self._verts(self.center)),[],list(self._faces()))
214 def toWaterMesh(self, me):
215 # pass me as argument so that we don't need to import bpy and create a dependency
216 # the docs state that from_pydata takes iterators as arguments but it will fail with generators because it does len(arg)
217 me.from_pydata(list(self._verts(self.water)),[],list(self._faces()))
220 def peak(self, value=1):
221 nx,ny = self.center.shape
222 self.center[int(nx/2),int(ny/2)] += value
225 def shelf(self, value=1):
226 nx,ny = self.center.shape
227 self.center[:nx/2] += value
230 def mesa(self, value=1):
231 nx,ny = self.center.shape
232 self.center[nx/4:3*nx/4,ny/4:3*ny/4] += value
235 def random(self, value=1):
236 self.center += np.random.random_sample(self.center.shape)*value
239 def neighborgrid(self):
240 self.up = np.roll(self.center,-1,0)
241 self.down = np.roll(self.center,1,0)
242 self.left = np.roll(self.center,-1,1)
243 self.right = np.roll(self.center,1,1)
246 def zeroedge(self, quantity=None):
247 c = self.center if quantity is None else quantity
248 c[0,:] = 0
249 c[-1,:] = 0
250 c[:,0] = 0
251 c[:,-1] = 0
254 def diffuse(self, Kd, IterDiffuse, numexpr):
255 self.zeroedge()
256 c = self.center[1:-1,1:-1]
257 up = self.center[ :-2,1:-1]
258 down = self.center[2: ,1:-1]
259 left = self.center[1:-1, :-2]
260 right = self.center[1:-1,2: ]
261 if(numexpr and numexpr_available):
262 self.center[1:-1,1:-1] = ne.evaluate('c + Kd * (up + down + left + right - 4.0 * c)')
263 else:
264 self.center[1:-1,1:-1] = c + (Kd/IterDiffuse) * (up + down + left + right - 4.0 * c)
265 self.maxrss = max(getmemsize(), self.maxrss)
266 return self.center
269 def avalanche(self, delta, iterava, prob, numexpr):
270 self.zeroedge()
271 c = self.center[1:-1,1:-1]
272 up = self.center[ :-2,1:-1]
273 down = self.center[2: ,1:-1]
274 left = self.center[1:-1, :-2]
275 right = self.center[1:-1,2: ]
276 where = np.where
278 if(numexpr and numexpr_available):
279 self.center[1:-1,1:-1] = ne.evaluate('c + where((up -c) > delta ,(up -c -delta)/2, 0) \
280 + where((down -c) > delta ,(down -c -delta)/2, 0) \
281 + where((left -c) > delta ,(left -c -delta)/2, 0) \
282 + where((right-c) > delta ,(right-c -delta)/2, 0) \
283 + where((up -c) < -delta,(up -c +delta)/2, 0) \
284 + where((down -c) < -delta,(down -c +delta)/2, 0) \
285 + where((left -c) < -delta,(left -c +delta)/2, 0) \
286 + where((right-c) < -delta,(right-c +delta)/2, 0)')
287 else:
288 sa = (
289 # incoming
290 where((up -c) > delta ,(up -c -delta)/2, 0)
291 + where((down -c) > delta ,(down -c -delta)/2, 0)
292 + where((left -c) > delta ,(left -c -delta)/2, 0)
293 + where((right-c) > delta ,(right-c -delta)/2, 0)
294 # outgoing
295 + where((up -c) < -delta,(up -c +delta)/2, 0)
296 + where((down -c) < -delta,(down -c +delta)/2, 0)
297 + where((left -c) < -delta,(left -c +delta)/2, 0)
298 + where((right-c) < -delta,(right-c +delta)/2, 0)
300 randarray = np.random.randint(0,100,sa.shape) *0.01
301 sa = where(randarray < prob, sa, 0)
302 self.avalanced[1:-1,1:-1] = self.avalanced[1:-1,1:-1] + sa/iterava
303 self.center[1:-1,1:-1] = c + sa/iterava
305 self.maxrss = max(getmemsize(), self.maxrss)
306 return self.center
309 def rain(self, amount=1, variance=0, userainmap=False):
310 self.water += (1.0 - np.random.random(self.water.shape) * variance) * (amount if ((self.rainmap is None) or (not userainmap)) else self.rainmap * amount)
313 def spring(self, amount, px, py, radius):
314 # px, py and radius are all fractions
315 nx, ny = self.center.shape
316 rx = max(int(nx*radius),1)
317 ry = max(int(ny*radius),1)
318 px = int(nx*px)
319 py = int(ny*py)
320 self.water[px-rx:px+rx+1,py-ry:py+ry+1] += amount
323 def river(self, Kc, Ks, Kdep, Ka, Kev, numexpr):
324 zeros = np.zeros
325 where = np.where
326 min = np.minimum
327 max = np.maximum
328 abs = np.absolute
329 arctan = np.arctan
330 sin = np.sin
332 center = (slice( 1, -1,None),slice( 1, -1,None))
333 up = (slice(None, -2,None),slice( 1, -1,None))
334 down = (slice( 2, None,None),slice( 1, -1,None))
335 left = (slice( 1, -1,None),slice(None, -2,None))
336 right = (slice( 1, -1,None),slice( 2,None,None))
338 water = self.water
339 rock = self.center
340 sediment = self.sediment
341 height = rock + water
343 # !! this gives a runtime warning for division by zero
344 verysmallnumber = 0.0000000001
345 water += verysmallnumber
346 sc = where(water > verysmallnumber, sediment / water, 0)
348 sdw = zeros(water[center].shape)
349 svdw = zeros(water[center].shape)
350 sds = zeros(water[center].shape)
351 angle = zeros(water[center].shape)
352 for d in (up,down,left,right):
353 if(numexpr and numexpr_available):
354 hdd = height[d]
355 hcc = height[center]
356 dw = ne.evaluate('hdd-hcc')
357 inflow = ne.evaluate('dw > 0')
358 wdd = water[d]
359 wcc = water[center]
360 dw = ne.evaluate('where(inflow, where(wdd<dw, wdd, dw), where(-wcc>dw, -wcc, dw))/4.0') # nested where() represent min() and max()
361 sdw = ne.evaluate('sdw + dw')
362 scd = sc[d]
363 scc = sc[center]
364 rockd= rock[d]
365 rockc= rock[center]
366 sds = ne.evaluate('sds + dw * where(inflow, scd, scc)')
367 svdw = ne.evaluate('svdw + abs(dw)')
368 angle= ne.evaluate('angle + arctan(abs(rockd-rockc))')
369 else:
370 dw = (height[d]-height[center])
371 inflow = dw > 0
372 dw = where(inflow, min(water[d], dw), max(-water[center], dw))/4.0
373 sdw = sdw + dw
374 sds = sds + dw * where(inflow, sc[d], sc[center])
375 svdw = svdw + abs(dw)
376 angle= angle + np.arctan(abs(rock[d]-rock[center]))
378 if(numexpr and numexpr_available):
379 wcc = water[center]
380 scc = sediment[center]
381 rcc = rock[center]
382 water[center] = ne.evaluate('wcc + sdw')
383 sediment[center] = ne.evaluate('scc + sds')
384 sc = ne.evaluate('where(wcc>0, scc/wcc, 2000*Kc)')
385 fKc = ne.evaluate('Kc*sin(Ka*angle)*svdw')
386 ds = ne.evaluate('where(sc > fKc, -Kd * scc, Ks * svdw)')
387 rock[center] = ne.evaluate('rcc - ds')
388 # there isn't really a bottom to the rock but negative values look ugly
389 rock[center] = ne.evaluate('where(rcc<0,0,rcc)')
390 sediment[center] = ne.evaluate('scc + ds')
391 else:
392 wcc = water[center]
393 scc = sediment[center]
394 rcc = rock[center]
395 water[center] = wcc * (1-Kev) + sdw
396 sediment[center] = scc + sds
397 sc = where(wcc > 0, scc / wcc, 2 * Kc)
398 fKc = Kc*svdw
399 ds = where(fKc > sc, (fKc - sc) * Ks, (fKc - sc) * Kdep) * wcc
400 self.flowrate[center] = svdw
401 self.scour[center] = ds
402 self.sedimentpct[center] = sc
403 self.capacity[center] = fKc
404 sediment[center] = scc + ds + sds
407 def flow(self, Kc, Ks, Kz, Ka, numexpr):
408 zeros = np.zeros
409 where = np.where
410 min = np.minimum
411 max = np.maximum
412 abs = np.absolute
413 arctan = np.arctan
414 sin = np.sin
416 center = (slice( 1, -1,None),slice( 1, -1,None))
417 rock = self.center
418 ds = self.scour[center]
419 rcc = rock[center]
420 rock[center] = rcc - ds * Kz
421 # there isn't really a bottom to the rock but negative values look ugly
422 rock[center] = where(rcc<0,0,rcc)
425 def rivergeneration(self, rainamount, rainvariance, userainmap, Kc, Ks, Kdep, Ka, Kev, Kspring, Kspringx, Kspringy, Kspringr, numexpr):
426 self.init_water_and_sediment()
427 self.rain(rainamount, rainvariance, userainmap)
428 self.zeroedge(self.water)
429 self.zeroedge(self.sediment)
430 self.river(Kc, Ks, Kdep, Ka, Kev, numexpr)
431 self.watermax = np.max(self.water)
434 def fluvial_erosion(self, rainamount, rainvariance, userainmap, Kc, Ks, Kdep, Ka, Kspring, Kspringx, Kspringy, Kspringr, numexpr):
435 self.flow(Kc, Ks, Kdep, Ka, numexpr)
436 self.flowratemax = np.max(self.flowrate)
437 self.scourmax = np.max(self.scour)
438 self.scourmin = np.min(self.scour)
439 self.sedmax = np.max(self.sediment)
442 def analyze(self):
443 self.neighborgrid()
444 # just looking at up and left to avoid needless double calculations
445 slopes=np.concatenate((np.abs(self.left - self.center),np.abs(self.up - self.center)))
446 return '\n'.join(["%-15s: %.3f"%t for t in [
447 ('height average', np.average(self.center)),
448 ('height median', np.median(self.center)),
449 ('height max', np.max(self.center)),
450 ('height min', np.min(self.center)),
451 ('height std', np.std(self.center)),
452 ('slope average', np.average(slopes)),
453 ('slope median', np.median(slopes)),
454 ('slope max', np.max(slopes)),
455 ('slope min', np.min(slopes)),
456 ('slope std', np.std(slopes))
461 class TestGrid(unittest.TestCase):
463 def test_diffuse(self):
464 g = Grid(5)
465 g.peak(1)
466 self.assertEqual(g.center[2,2],1.0)
467 g.diffuse(0.1, numexpr=False)
468 for n in [(2,1),(2,3),(1,2),(3,2)]:
469 self.assertAlmostEqual(g.center[n],0.1)
470 self.assertAlmostEqual(g.center[2,2],0.6)
473 def test_diffuse_numexpr(self):
474 g = Grid(5)
475 g.peak(1)
476 g.diffuse(0.1, numexpr=False)
477 h = Grid(5)
478 h.peak(1)
479 h.diffuse(0.1, numexpr=True)
480 self.assertEqual(list(g.center.flat),list(h.center.flat))
483 def test_avalanche_numexpr(self):
484 g = Grid(5)
485 g.peak(1)
486 g.avalanche(0.1, numexpr=False)
487 h = Grid(5)
488 h.peak(1)
489 h.avalanche(0.1, numexpr=True)
490 print(g)
491 print(h)
492 np.testing.assert_almost_equal(g.center,h.center)
495 if __name__ == "__main__":
497 import argparse
499 parser = argparse.ArgumentParser(description='Erode a terrain while assuming zero boundary conditions.')
500 parser.add_argument('-I', dest='iterations', type=int, default=1, help='the number of iterations')
501 parser.add_argument('-Kd', dest='Kd', type=float, default=0.01, help='Diffusion constant')
502 parser.add_argument('-Kh', dest='Kh', type=float, default=6, help='Maximum stable cliff height')
503 parser.add_argument('-Kp', dest='Kp', type=float, default=0.1, help='Avalanche probability for unstable cliffs')
504 parser.add_argument('-Kr', dest='Kr', type=float, default=0.1, help='Average amount of rain per iteration')
505 parser.add_argument('-Kspring', dest='Kspring', type=float, default=0.0, help='Average amount of wellwater per iteration')
506 parser.add_argument('-Kspringx', dest='Kspringx', type=float, default=0.5, help='relative x position of spring')
507 parser.add_argument('-Kspringy', dest='Kspringy', type=float, default=0.5, help='relative y position of spring')
508 parser.add_argument('-Kspringr', dest='Kspringr', type=float, default=0.02, help='radius of spring')
509 parser.add_argument('-Kdep', dest='Kdep', type=float, default=0.1, help='Sediment deposition constant')
510 parser.add_argument('-Ks', dest='Ks', type=float, default=0.1, help='Soil softness constant')
511 parser.add_argument('-Kc', dest='Kc', type=float, default=1.0, help='Sediment capacity')
512 parser.add_argument('-Ka', dest='Ka', type=float, default=2.0, help='Slope dependency of erosion')
513 parser.add_argument('-ri', action='store_true', dest='rawin', default=False, help='use Blender raw format for input')
514 parser.add_argument('-ro', action='store_true', dest='rawout', default=False, help='use Blender raw format for output')
515 parser.add_argument('-i', action='store_true', dest='useinputfile', default=False, help='use an inputfile (instead of just a synthesized grid)')
516 parser.add_argument('-t', action='store_true', dest='timingonly', default=False, help='do not write anything to an output file')
517 parser.add_argument('-infile', type=str, default="-", help='input filename')
518 parser.add_argument('-outfile', type=str, default="-", help='output filename')
519 parser.add_argument('-Gn', dest='gridsize', type=int, default=20, help='Gridsize (always square)')
520 parser.add_argument('-Gp', dest='gridpeak', type=float, default=0, help='Add peak with given height')
521 parser.add_argument('-Gs', dest='gridshelf', type=float, default=0, help='Add shelve with given height')
522 parser.add_argument('-Gm', dest='gridmesa', type=float, default=0, help='Add mesa with given height')
523 parser.add_argument('-Gr', dest='gridrandom', type=float, default=0, help='Add random values between 0 and given value')
524 parser.add_argument('-m', dest='threads', type=int, default=1, help='number of threads to use')
525 parser.add_argument('-u', action='store_true', dest='unittest', default=False, help='perform unittests')
526 parser.add_argument('-a', action='store_true', dest='analyze', default=False, help='show some statistics of input and output meshes')
527 parser.add_argument('-d', action='store_true', dest='dump', default=False, help='show sediment and water meshes at end of run')
528 parser.add_argument('-n', action='store_true', dest='usenumexpr', default=False, help='use numexpr optimizations')
530 args = parser.parse_args()
531 print("\nInput arguments:")
532 print("\n".join("%-15s: %s"%t for t in sorted(vars(args).items())), file=sys.stderr)
534 if args.unittest:
535 unittest.main(argv=[sys.argv[0]])
536 sys.exit(0)
538 if args.useinputfile:
539 if args.rawin:
540 grid = Grid.fromRaw(args.infile)
541 else:
542 grid = Grid.fromFile(args.infile)
543 else:
544 grid = Grid(args.gridsize)
546 if args.gridpeak > 0 : grid.peak(args.gridpeak)
547 if args.gridmesa > 0 : grid.mesa(args.gridmesa)
548 if args.gridshelf > 0 : grid.shelf(args.gridshelf)
549 if args.gridrandom > 0 : grid.random(args.gridrandom)
551 if args.analyze:
552 print('\nstatistics of the input grid:\n\n', grid.analyze(), file=sys.stderr, sep='' )
553 t = getptime()
554 for g in range(args.iterations):
555 if args.Kd > 0:
556 grid.diffuse(args.Kd, args.usenumexpr)
557 if args.Kh > 0 and args.Kp > rand():
558 grid.avalanche(args.Kh, args.usenumexpr)
559 if args.Kr > 0 or args.Kspring > 0:
560 grid.fluvial_erosion(args.Kr, args.Kc, args.Ks, args.Kdep, args.Ka, args.Kspring, args.Kspringx, args.Kspringy, args.Kspringr, args.usenumexpr)
561 t = getptime() - t
562 print("\nElapsed time: %.1f seconds, max memory %.1f Mb.\n"%(t,grid.maxrss), file=sys.stderr)
563 if args.analyze:
564 print('\nstatistics of the output grid:\n\n', grid.analyze(), file=sys.stderr, sep='')
566 if not args.timingonly:
567 if args.rawout:
568 grid.toRaw(args.outfile, vars(args))
569 else:
570 grid.toFile(args.outfile)
572 if args.dump:
573 print("sediment\n", np.array_str(grid.sediment,precision=3), file=sys.stderr)
574 print("water\n", np.array_str(grid.water,precision=3), file=sys.stderr)
575 print("sediment concentration\n", np.array_str(grid.sediment/grid.water,precision=3), file=sys.stderr)