1 # SPDX-FileCopyrightText: 2017-2022 Blender Foundation
3 # SPDX-License-Identifier: GPL-2.0-or-later
9 from random
import random
as rand
, shuffle
12 numexpr_available
= False
25 def __init__(self
, size
=10, dtype
=np
.single
):
26 self
.center
= np
.zeros([size
, size
], dtype
)
31 self
.sedimentpct
= None
32 self
.sedimentpct
= None
41 self
.sequence
= [0, 1, 2, 3]
43 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
)
65 return ''.join(self
.__str
_iter
__(fmt
="%.3f"))
67 def __str_iter__(self
, fmt
):
68 for row
in self
.center
[::]:
71 values
.append(fmt
% v
)
72 yield ' '.join(values
) + '\n'
75 def fromFile(filename
):
79 g
.center
= np
.loadtxt(filename
, np
.single
)
82 def toFile(self
, filename
, fmt
="%.3f"):
84 filename
= sys
.stdout
.fileno()
85 with
open(filename
, "w") as f
:
86 for line
in self
.__str
_iter
__(fmt
):
89 def raw(self
, format
="%.3f"):
90 fstr
= format
+ " " + format
+ " " + format
+ " "
91 a
= self
.center
/ self
.zscale
92 minx
= 0.0 if self
.minx
is None else self
.minx
93 miny
= 0.0 if self
.miny
is None else self
.miny
94 maxx
= 1.0 if self
.maxx
is None else self
.maxx
95 maxy
= 1.0 if self
.maxy
is None else self
.maxy
96 dx
= (maxx
- minx
) / (a
.shape
[0] - 1)
97 dy
= (maxy
- miny
) / (a
.shape
[1] - 1)
98 for row
in range(a
.shape
[0] - 1):
99 row0
= miny
+ row
* dy
101 for col
in range(a
.shape
[1] - 1):
102 col0
= minx
+ col
* dx
104 yield (fstr
% (row0
, col0
, a
[row
][col
]) +
105 fstr
% (row0
, col1
, a
[row
][col
+ 1]) +
106 fstr
% (row1
, col0
, a
[row
+ 1][col
]) + "\n")
107 yield (fstr
% (row0
, col1
, a
[row
][col
+ 1]) +
108 fstr
% (row1
, col0
, a
[row
+ 1][col
]) +
109 fstr
% (row1
, col1
, a
[row
+ 1][col
+ 1]) + "\n")
111 def toRaw(self
, filename
, infomap
=None):
112 with
open(filename
if type(filename
) == str else sys
.stdout
.fileno(), "w") as f
:
113 f
.writelines(self
.raw())
115 with
open(os
.path
.splitext(filename
)[0] + ".inf" if type(filename
) == str else sys
.stdout
.fileno(), "w") as f
:
116 f
.writelines("\n".join("%-15s: %s" % t
for t
in sorted(infomap
.items())))
119 def fromRaw(filename
):
120 """initialize a grid from a Blender .raw file.
121 currently supports just rectangular grids of all triangles
123 g
= Grid
.fromFile(filename
)
124 # we assume tris and an axis aligned grid
125 g
.center
= np
.reshape(g
.center
, (-1, 3))
129 def _sort(self
, expfact
):
130 # keep unique vertices only by creating a set and sort first on x then on y coordinate
131 # using rather slow python sort but couldn't wrap my head around np.lexsort
132 verts
= sorted(list({tuple(t
) for t
in self
.center
[::]}))
133 x
= set(c
[0] for c
in verts
)
134 y
= set(c
[1] for c
in verts
)
141 xscale
= (self
.maxx
- self
.minx
) / (nx
- 1)
142 yscale
= (self
.maxy
- self
.miny
) / (ny
- 1)
143 # note: a purely flat plane cannot be scaled
144 if (yscale
!= 0.0) and (abs(xscale
/ yscale
) - 1.0 > 1e-3):
145 raise ValueError("Mesh spacing not square %d x %d %.4f x %4.f" % (nx
, ny
, xscale
, yscale
))
147 if abs(yscale
) > 1e-6:
148 self
.zscale
= 1.0 / yscale
150 # keep just the z-values and null any offset
151 # we might catch a reshape error that will occur if nx*ny != # of vertices
152 # (if we are not dealing with a heightfield but with a mesh with duplicate
153 # x,y coords, like an axis aligned cube
154 self
.center
= np
.array([c
[2] for c
in verts
], dtype
=np
.single
).reshape(nx
, ny
)
155 self
.center
= (self
.center
- np
.amin(self
.center
)) * self
.zscale
156 if self
.rainmap
is not None:
157 rmscale
= np
.max(self
.center
)
158 self
.rainmap
= expfact
+ (1 - expfact
) * (self
.center
/ rmscale
)
161 def fromBlenderMesh(me
, vg
, expfact
):
163 g
.center
= np
.asarray(list(tuple(v
.co
) for v
in me
.vertices
), dtype
=np
.single
)
166 for v
in me
.vertices
:
167 vg
.add([v
.index
], 0.0, 'ADD')
168 g
.rainmap
= np
.asarray(list((v
.co
[0], v
.co
[1], vg
.weight(v
.index
)) for v
in me
.vertices
), dtype
=np
.single
)
172 def setrainmap(self
, rainmap
):
173 self
.rainmap
= rainmap
175 def _verts(self
, surface
):
176 a
= surface
/ self
.zscale
177 minx
= 0.0 if self
.minx
is None else self
.minx
178 miny
= 0.0 if self
.miny
is None else self
.miny
179 maxx
= 1.0 if self
.maxx
is None else self
.maxx
180 maxy
= 1.0 if self
.maxy
is None else self
.maxy
181 dx
= (maxx
- minx
) / (a
.shape
[0] - 1)
182 dy
= (maxy
- miny
) / (a
.shape
[1] - 1)
183 for row
in range(a
.shape
[0]):
184 row0
= miny
+ row
* dy
185 for col
in range(a
.shape
[1]):
186 col0
= minx
+ col
* dx
187 yield (row0
, col0
, a
[row
][col
])
190 nrow
, ncol
= self
.center
.shape
191 for row
in range(nrow
- 1):
192 for col
in range(ncol
- 1):
193 vi
= row
* ncol
+ col
194 yield (vi
, vi
+ ncol
, vi
+ 1)
195 yield (vi
+ 1, vi
+ ncol
, vi
+ ncol
+ 1)
197 def toBlenderMesh(self
, me
):
198 # pass me as argument so that we don't need to import bpy and create a dependency
199 # the docs state that from_pydata takes iterators as arguments but it will
200 # fail with generators because it does len(arg)
201 me
.from_pydata(list(self
._verts
(self
.center
)), [], list(self
._faces
()))
203 def toWaterMesh(self
, me
):
204 # pass me as argument so that we don't need to import bpy and create a dependency
205 # the docs state that from_pydata takes iterators as arguments but it will
206 # fail with generators because it does len(arg)
207 me
.from_pydata(list(self
._verts
(self
.water
)), [], list(self
._faces
()))
209 def peak(self
, value
=1):
210 nx
, ny
= self
.center
.shape
211 self
.center
[int(nx
/ 2), int(ny
/ 2)] += value
213 def shelf(self
, value
=1):
214 nx
, ny
= self
.center
.shape
215 self
.center
[:nx
/ 2] += value
217 def mesa(self
, value
=1):
218 nx
, ny
= self
.center
.shape
219 self
.center
[nx
/ 4:3 * nx
/ 4, ny
/ 4:3 * ny
/ 4] += value
221 def random(self
, value
=1):
222 self
.center
+= np
.random
.random_sample(self
.center
.shape
) * value
224 def neighborgrid(self
):
225 self
.up
= np
.roll(self
.center
, -1, 0)
226 self
.down
= np
.roll(self
.center
, 1, 0)
227 self
.left
= np
.roll(self
.center
, -1, 1)
228 self
.right
= np
.roll(self
.center
, 1, 1)
230 def zeroedge(self
, quantity
=None):
231 c
= self
.center
if quantity
is None else quantity
237 def diffuse(self
, Kd
, IterDiffuse
, numexpr
):
239 c
= self
.center
[1:-1, 1:-1]
240 up
= self
.center
[:-2, 1:-1]
241 down
= self
.center
[2:, 1:-1]
242 left
= self
.center
[1:-1, :-2]
243 right
= self
.center
[1:-1, 2:]
244 if(numexpr
and numexpr_available
):
245 self
.center
[1:-1, 1:-1] = ne
.evaluate('c + Kd * (up + down + left + right - 4.0 * c)')
247 self
.center
[1:-1, 1:-1] = c
+ (Kd
/ IterDiffuse
) * (up
+ down
+ left
+ right
- 4.0 * c
)
248 self
.maxrss
= max(getmemsize(), self
.maxrss
)
251 def avalanche(self
, delta
, iterava
, prob
, numexpr
):
253 c
= self
.center
[1:-1, 1:-1]
254 up
= self
.center
[:-2, 1:-1]
255 down
= self
.center
[2:, 1:-1]
256 left
= self
.center
[1:-1, :-2]
257 right
= self
.center
[1:-1, 2:]
260 if(numexpr
and numexpr_available
):
261 self
.center
[1:-1, 1:-1] = ne
.evaluate('c + where((up -c) > delta ,(up -c -delta)/2, 0) \
262 + where((down -c) > delta ,(down -c -delta)/2, 0) \
263 + where((left -c) > delta ,(left -c -delta)/2, 0) \
264 + where((right-c) > delta ,(right-c -delta)/2, 0) \
265 + where((up -c) < -delta,(up -c +delta)/2, 0) \
266 + where((down -c) < -delta,(down -c +delta)/2, 0) \
267 + where((left -c) < -delta,(left -c +delta)/2, 0) \
268 + where((right-c) < -delta,(right-c +delta)/2, 0)')
272 where((up
- c
) > delta
, (up
- c
- delta
) / 2, 0)
273 + where((down
- c
) > delta
, (down
- c
- delta
) / 2, 0)
274 + where((left
- c
) > delta
, (left
- c
- delta
) / 2, 0)
275 + where((right
- c
) > delta
, (right
- c
- delta
) / 2, 0)
277 + where((up
- c
) < -delta
, (up
- c
+ delta
) / 2, 0)
278 + where((down
- c
) < -delta
, (down
- c
+ delta
) / 2, 0)
279 + where((left
- c
) < -delta
, (left
- c
+ delta
) / 2, 0)
280 + where((right
- c
) < -delta
, (right
- c
+ delta
) / 2, 0)
282 randarray
= np
.random
.randint(0, 100, sa
.shape
) * 0.01
283 sa
= where(randarray
< prob
, sa
, 0)
284 self
.avalanced
[1:-1, 1:-1] = self
.avalanced
[1:-1, 1:-1] + sa
/ iterava
285 self
.center
[1:-1, 1:-1] = c
+ sa
/ iterava
287 self
.maxrss
= max(getmemsize(), self
.maxrss
)
290 def rain(self
, amount
=1, variance
=0, userainmap
=False):
291 self
.water
+= (1.0 - np
.random
.random(self
.water
.shape
) * variance
) * \
292 (amount
if ((self
.rainmap
is None) or (not userainmap
)) else self
.rainmap
* amount
)
294 def spring(self
, amount
, px
, py
, radius
):
295 # px, py and radius are all fractions
296 nx
, ny
= self
.center
.shape
297 rx
= max(int(nx
* radius
), 1)
298 ry
= max(int(ny
* radius
), 1)
301 self
.water
[px
- rx
:px
+ rx
+ 1, py
- ry
:py
+ ry
+ 1] += amount
303 def river(self
, Kc
, Ks
, Kdep
, Ka
, Kev
, numexpr
):
312 center
= (slice(1, -1, None), slice(1, -1, None))
313 up
= (slice(None, -2, None), slice(1, -1, None))
314 down
= (slice(2, None, None), slice(1, -1, None))
315 left
= (slice(1, -1, None), slice(None, -2, None))
316 right
= (slice(1, -1, None), slice(2, None, None))
320 sediment
= self
.sediment
321 height
= rock
+ water
323 # !! this gives a runtime warning for division by zero
324 verysmallnumber
= 0.0000000001
325 water
+= verysmallnumber
326 sc
= where(water
> verysmallnumber
, sediment
/ water
, 0)
328 sdw
= zeros(water
[center
].shape
)
329 svdw
= zeros(water
[center
].shape
)
330 sds
= zeros(water
[center
].shape
)
331 angle
= zeros(water
[center
].shape
)
332 for d
in (up
, down
, left
, right
):
333 if(numexpr
and numexpr_available
):
336 dw
= ne
.evaluate('hdd-hcc')
337 inflow
= ne
.evaluate('dw > 0')
340 # nested where() represent min() and max()
341 dw
= ne
.evaluate('where(inflow, where(wdd<dw, wdd, dw), where(-wcc>dw, -wcc, dw))/4.0')
342 sdw
= ne
.evaluate('sdw + dw')
347 sds
= ne
.evaluate('sds + dw * where(inflow, scd, scc)')
348 svdw
= ne
.evaluate('svdw + abs(dw)')
349 angle
= ne
.evaluate('angle + arctan(abs(rockd-rockc))')
351 dw
= (height
[d
] - height
[center
])
353 dw
= where(inflow
, min(water
[d
], dw
), max(-water
[center
], dw
)) / 4.0
355 sds
= sds
+ dw
* where(inflow
, sc
[d
], sc
[center
])
356 svdw
= svdw
+ abs(dw
)
357 angle
= angle
+ np
.arctan(abs(rock
[d
] - rock
[center
]))
359 if numexpr
and numexpr_available
:
361 scc
= sediment
[center
]
363 water
[center
] = ne
.evaluate('wcc + sdw')
364 sediment
[center
] = ne
.evaluate('scc + sds')
365 sc
= ne
.evaluate('where(wcc>0, scc/wcc, 2000*Kc)')
366 fKc
= ne
.evaluate('Kc*sin(Ka*angle)*svdw')
367 ds
= ne
.evaluate('where(sc > fKc, -Kd * scc, Ks * svdw)')
368 rock
[center
] = ne
.evaluate('rcc - ds')
369 # there isn't really a bottom to the rock but negative values look ugly
370 rock
[center
] = ne
.evaluate('where(rcc<0,0,rcc)')
371 sediment
[center
] = ne
.evaluate('scc + ds')
374 scc
= sediment
[center
]
376 water
[center
] = wcc
* (1 - Kev
) + sdw
377 sediment
[center
] = scc
+ sds
378 sc
= where(wcc
> 0, scc
/ wcc
, 2 * Kc
)
380 ds
= where(fKc
> sc
, (fKc
- sc
) * Ks
, (fKc
- sc
) * Kdep
) * wcc
381 self
.flowrate
[center
] = svdw
382 self
.scour
[center
] = ds
383 self
.sedimentpct
[center
] = sc
384 self
.capacity
[center
] = fKc
385 sediment
[center
] = scc
+ ds
+ sds
387 def flow(self
, Kc
, Ks
, Kz
, Ka
, numexpr
):
396 center
= (slice(1, -1, None), slice(1, -1, None))
398 ds
= self
.scour
[center
]
400 rock
[center
] = rcc
- ds
* Kz
401 # there isn't really a bottom to the rock but negative values look ugly
402 rock
[center
] = where(rcc
< 0, 0, rcc
)
420 self
.init_water_and_sediment()
421 self
.rain(rainamount
, rainvariance
, userainmap
)
422 self
.zeroedge(self
.water
)
423 self
.zeroedge(self
.sediment
)
424 self
.river(Kc
, Ks
, Kdep
, Ka
, Kev
, numexpr
)
425 self
.watermax
= np
.max(self
.water
)
442 self
.flow(Kc
, Ks
, Kdep
, Ka
, numexpr
)
443 self
.flowratemax
= np
.max(self
.flowrate
)
444 self
.scourmax
= np
.max(self
.scour
)
445 self
.scourmin
= np
.min(self
.scour
)
446 self
.sedmax
= np
.max(self
.sediment
)
450 # just looking at up and left to avoid needless double calculations
451 slopes
= np
.concatenate((np
.abs(self
.left
- self
.center
), np
.abs(self
.up
- self
.center
)))
452 return '\n'.join(["%-15s: %.3f" % t
for t
in [
453 ('height average', np
.average(self
.center
)),
454 ('height median', np
.median(self
.center
)),
455 ('height max', np
.max(self
.center
)),
456 ('height min', np
.min(self
.center
)),
457 ('height std', np
.std(self
.center
)),
458 ('slope average', np
.average(slopes
)),
459 ('slope median', np
.median(slopes
)),
460 ('slope max', np
.max(slopes
)),
461 ('slope min', np
.min(slopes
)),
462 ('slope std', np
.std(slopes
))
466 class TestGrid(unittest
.TestCase
):
468 def test_diffuse(self
):
471 self
.assertEqual(g
.center
[2, 2], 1.0)
472 g
.diffuse(0.1, numexpr
=False)
473 for n
in [(2, 1), (2, 3), (1, 2), (3, 2)]:
474 self
.assertAlmostEqual(g
.center
[n
], 0.1)
475 self
.assertAlmostEqual(g
.center
[2, 2], 0.6)
477 def test_diffuse_numexpr(self
):
480 g
.diffuse(0.1, numexpr
=False)
483 h
.diffuse(0.1, numexpr
=True)
484 self
.assertEqual(list(g
.center
.flat
), list(h
.center
.flat
))
486 def test_avalanche_numexpr(self
):
489 g
.avalanche(0.1, numexpr
=False)
492 h
.avalanche(0.1, numexpr
=True)
495 np
.testing
.assert_almost_equal(g
.center
, h
.center
)
498 if __name__
== "__main__":
502 parser
= argparse
.ArgumentParser(description
='Erode a terrain while assuming zero boundary conditions.')
503 parser
.add_argument('-I', dest
='iterations', type=int, default
=1, help='the number of iterations')
504 parser
.add_argument('-Kd', dest
='Kd', type=float, default
=0.01, help='Diffusion constant')
505 parser
.add_argument('-Kh', dest
='Kh', type=float, default
=6, help='Maximum stable cliff height')
506 parser
.add_argument('-Kp', dest
='Kp', type=float, default
=0.1, help='Avalanche probability for unstable cliffs')
507 parser
.add_argument('-Kr', dest
='Kr', type=float, default
=0.1, help='Average amount of rain per iteration')
508 parser
.add_argument('-Kspring', dest
='Kspring', type=float, default
=0.0,
509 help='Average amount of wellwater per iteration')
510 parser
.add_argument('-Kspringx', dest
='Kspringx', type=float, default
=0.5, help='relative x position of spring')
511 parser
.add_argument('-Kspringy', dest
='Kspringy', type=float, default
=0.5, help='relative y position of spring')
512 parser
.add_argument('-Kspringr', dest
='Kspringr', type=float, default
=0.02, help='radius of spring')
513 parser
.add_argument('-Kdep', dest
='Kdep', type=float, default
=0.1, help='Sediment deposition constant')
514 parser
.add_argument('-Ks', dest
='Ks', type=float, default
=0.1, help='Soil softness constant')
515 parser
.add_argument('-Kc', dest
='Kc', type=float, default
=1.0, help='Sediment capacity')
516 parser
.add_argument('-Ka', dest
='Ka', type=float, default
=2.0, help='Slope dependency of erosion')
522 help='use Blender raw format for input')
528 help='use Blender raw format for output')
529 parser
.add_argument('-i', action
='store_true', dest
='useinputfile', default
=False,
530 help='use an inputfile (instead of just a synthesized grid)')
536 help='do not write anything to an output file')
537 parser
.add_argument('-infile', type=str, default
="-", help='input filename')
538 parser
.add_argument('-outfile', type=str, default
="-", help='output filename')
539 parser
.add_argument('-Gn', dest
='gridsize', type=int, default
=20, help='Gridsize (always square)')
540 parser
.add_argument('-Gp', dest
='gridpeak', type=float, default
=0, help='Add peak with given height')
541 parser
.add_argument('-Gs', dest
='gridshelf', type=float, default
=0, help='Add shelve with given height')
542 parser
.add_argument('-Gm', dest
='gridmesa', type=float, default
=0, help='Add mesa with given height')
543 parser
.add_argument('-Gr', dest
='gridrandom', type=float, default
=0,
544 help='Add random values between 0 and given value')
545 parser
.add_argument('-m', dest
='threads', type=int, default
=1, help='number of threads to use')
546 parser
.add_argument('-u', action
='store_true', dest
='unittest', default
=False, help='perform unittests')
547 parser
.add_argument('-a', action
='store_true', dest
='analyze', default
=False,
548 help='show some statistics of input and output meshes')
549 parser
.add_argument('-d', action
='store_true', dest
='dump', default
=False,
550 help='show sediment and water meshes at end of run')
551 parser
.add_argument('-n', action
='store_true', dest
='usenumexpr', default
=False, help='use numexpr optimizations')
553 args
= parser
.parse_args()
554 print("\nInput arguments:")
555 print("\n".join("%-15s: %s" % t
for t
in sorted(vars(args
).items())), file=sys
.stderr
)
558 unittest
.main(argv
=[sys
.argv
[0]])
561 if args
.useinputfile
:
563 grid
= Grid
.fromRaw(args
.infile
)
565 grid
= Grid
.fromFile(args
.infile
)
567 grid
= Grid(args
.gridsize
)
569 if args
.gridpeak
> 0:
570 grid
.peak(args
.gridpeak
)
571 if args
.gridmesa
> 0:
572 grid
.mesa(args
.gridmesa
)
573 if args
.gridshelf
> 0:
574 grid
.shelf(args
.gridshelf
)
575 if args
.gridrandom
> 0:
576 grid
.random(args
.gridrandom
)
579 print('\nstatistics of the input grid:\n\n', grid
.analyze(), file=sys
.stderr
, sep
='')
581 for g
in range(args
.iterations
):
583 grid
.diffuse(args
.Kd
, args
.usenumexpr
)
584 if args
.Kh
> 0 and args
.Kp
> rand():
585 grid
.avalanche(args
.Kh
, args
.usenumexpr
)
586 if args
.Kr
> 0 or args
.Kspring
> 0:
587 grid
.fluvial_erosion(
600 print("\nElapsed time: %.1f seconds, max memory %.1f Mb.\n" % (t
, grid
.maxrss
), file=sys
.stderr
)
602 print('\nstatistics of the output grid:\n\n', grid
.analyze(), file=sys
.stderr
, sep
='')
604 if not args
.timingonly
:
606 grid
.toRaw(args
.outfile
, vars(args
))
608 grid
.toFile(args
.outfile
)
611 print("sediment\n", np
.array_str(grid
.sediment
, precision
=3), file=sys
.stderr
)
612 print("water\n", np
.array_str(grid
.water
, precision
=3), file=sys
.stderr
)
613 print("sediment concentration\n", np
.array_str(grid
.sediment
/ grid
.water
, precision
=3), file=sys
.stderr
)