1 # SPDX-License-Identifier: GPL-2.0-or-later
8 from random
import random
as rand
, shuffle
11 numexpr_available
= False
24 def __init__(self
, size
=10, dtype
=np
.single
):
25 self
.center
= np
.zeros([size
, size
], dtype
)
30 self
.sedimentpct
= None
31 self
.sedimentpct
= None
40 self
.sequence
= [0, 1, 2, 3]
42 self
.flowratemax
= 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
)
66 return ''.join(self
.__str
_iter
__(fmt
="%.3f"))
69 def __str_iter__(self
, fmt
):
70 for row
in self
.center
[::]:
74 yield ' '.join(values
) + '\n'
78 def fromFile(filename
):
82 g
.center
=np
.loadtxt(filename
,np
.single
)
86 def toFile(self
, filename
, fmt
="%.3f"):
88 filename
= sys
.stdout
.fileno()
89 with
open(filename
,"w") as f
:
90 for line
in self
.__str
_iter
__(fmt
):
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
106 for col
in range(a
.shape
[1] - 1):
107 col0
= minx
+ col
* 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())
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())))
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))
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
)
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
))
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
)
168 def fromBlenderMesh(me
, vg
, expfact
):
170 g
.center
= np
.asarray(list(tuple(v
.co
) for v
in me
.vertices
), dtype
=np
.single
)
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
)
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
])
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
254 def diffuse(self
, Kd
, IterDiffuse
, numexpr
):
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)')
264 self
.center
[1:-1,1:-1] = c
+ (Kd
/IterDiffuse
) * (up
+ down
+ left
+ right
- 4.0 * c
)
265 self
.maxrss
= max(getmemsize(), self
.maxrss
)
269 def avalanche(self
, delta
, iterava
, prob
, numexpr
):
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: ]
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)')
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)
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
)
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)
320 self
.water
[px
-rx
:px
+rx
+1,py
-ry
:py
+ry
+1] += amount
323 def river(self
, Kc
, Ks
, Kdep
, Ka
, Kev
, numexpr
):
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))
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
):
356 dw
= ne
.evaluate('hdd-hcc')
357 inflow
= ne
.evaluate('dw > 0')
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')
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))')
370 dw
= (height
[d
]-height
[center
])
372 dw
= where(inflow
, min(water
[d
], dw
), max(-water
[center
], dw
))/4.0
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
):
380 scc
= sediment
[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')
393 scc
= sediment
[center
]
395 water
[center
] = wcc
* (1-Kev
) + sdw
396 sediment
[center
] = scc
+ sds
397 sc
= where(wcc
> 0, scc
/ wcc
, 2 * Kc
)
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
):
416 center
= (slice( 1, -1,None),slice( 1, -1,None))
418 ds
= self
.scour
[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
)
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
):
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
):
476 g
.diffuse(0.1, numexpr
=False)
479 h
.diffuse(0.1, numexpr
=True)
480 self
.assertEqual(list(g
.center
.flat
),list(h
.center
.flat
))
483 def test_avalanche_numexpr(self
):
486 g
.avalanche(0.1, numexpr
=False)
489 h
.avalanche(0.1, numexpr
=True)
492 np
.testing
.assert_almost_equal(g
.center
,h
.center
)
495 if __name__
== "__main__":
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
)
535 unittest
.main(argv
=[sys
.argv
[0]])
538 if args
.useinputfile
:
540 grid
= Grid
.fromRaw(args
.infile
)
542 grid
= Grid
.fromFile(args
.infile
)
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
)
552 print('\nstatistics of the input grid:\n\n', grid
.analyze(), file=sys
.stderr
, sep
='' )
554 for g
in range(args
.iterations
):
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
)
562 print("\nElapsed time: %.1f seconds, max memory %.1f Mb.\n"%(t
,grid
.maxrss
), file=sys
.stderr
)
564 print('\nstatistics of the output grid:\n\n', grid
.analyze(), file=sys
.stderr
, sep
='')
566 if not args
.timingonly
:
568 grid
.toRaw(args
.outfile
, vars(args
))
570 grid
.toFile(args
.outfile
)
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
)