Mike0021 commited on
Commit
bf9b2f2
·
verified ·
1 Parent(s): 293cfcd

Terrain round 30: drawn layers with rules stay in their shapes, forest-floor swatches, water repaint only on low gentle ground

Browse files
Files changed (2) hide show
  1. app/llms.txt +11 -11
  2. projtex/terrain.py +46 -7
app/llms.txt CHANGED
@@ -193,17 +193,17 @@ Request (JSON; only the ground and a prompt are required):
193
  20 % of the layer's area in natural patches (dirt in grass). paths/areas are ground coordinates in metres:
194
  [x, z] of glTF / three.js; from Blender pass "axes": "z-up" and give [x, y]. A layer with only paths or areas
195
  exists only there (roads, fields); with rules too, the rules apply inside them.
196
- The layer prompt describes the ground close up (it becomes a 1024 px photo texture of tile_m metres); the main
197
- prompt describes the place as a whole: climate, season, region. The colour map shows only the ground: buildings
198
- named in the prompt are left out (put them in your scene as 3D models; texture them with POST /api/jobs and give
199
- the ground around them as a layer area, e.g. a yard), and roads or streams drawn by a layer's paths follow your
200
- points, not the prompt's words. Trees and bushes appear only when the prompt or a layer asks for them, and with a
201
- forest layer only inside it (ask for "scattered trees" or "hedgerows" to get them elsewhere). The texture repeats
202
- in fixed directions, so describe surfaces, not structures with a direction: "packed gravel", not "a lane with two
203
- ruts and a grass strip" (paths get an even surface automatically). "from_above" (optional) says how a layer looks
204
- in the colour map when that differs from its close-up; forests and woods default to their tree crowns. Presets
205
- (when layers is omitted, chosen from the prompt's words): alpine, temperate, desert, arctic, winter, volcanic (GET
206
- /api lists them).
207
  - uv: planar (default: terrain.glb gets top-down UVs) | keep (the colour map is baked into your own UVs).
208
  - backend: qwen-turbo (default) | qwen-space | mock (free dry run). Cost: layers + 1 + tiles images (2048: about
209
  15 images, ~2 min of GPU quota and ~2-3 min in all; 4096: ~31 images).
 
193
  20 % of the layer's area in natural patches (dirt in grass). paths/areas are ground coordinates in metres:
194
  [x, z] of glTF / three.js; from Blender pass "axes": "z-up" and give [x, y]. A layer with only paths or areas
195
  exists only there (roads, fields); with rules too, the rules apply inside them.
196
+ The layer prompt describes the ground close up (it becomes a 1024 px photo texture of tile_m metres, made from
197
+ that prompt alone: name the material, "groomed snow piste", not "groomed piste"); the main prompt describes the
198
+ place as a whole: climate, season, region. The colour map shows only the ground: buildings named in the prompt are
199
+ left out (put them in your scene as 3D models; texture them with POST /api/jobs and give the ground around them as
200
+ a layer area, e.g. a yard), and roads or streams drawn by a layer's paths follow your points, not the prompt's
201
+ words. Trees and bushes appear only when the prompt or a layer asks for them, and with a forest layer only inside
202
+ it (ask for "scattered trees" or "hedgerows" to get them elsewhere). The texture repeats in fixed directions, so
203
+ describe surfaces, not structures with a direction: "packed gravel", not "a lane with two ruts and a grass strip"
204
+ (paths get an even surface automatically). "from_above" (optional) says how a layer looks in the colour map when
205
+ that differs from its close-up; forests and woods default to their tree crowns. Presets (when layers is omitted,
206
+ chosen from the prompt's words): alpine, temperate, desert, arctic, winter, volcanic (GET /api lists them).
207
  - uv: planar (default: terrain.glb gets top-down UVs) | keep (the colour map is baked into your own UVs).
208
  - backend: qwen-turbo (default) | qwen-space | mock (free dry run). Cost: layers + 1 + tiles images (2048: about
209
  15 images, ~2 min of GPU quota and ~2-3 min in all; 4096: ~31 images).
projtex/terrain.py CHANGED
@@ -305,8 +305,9 @@ def splat_weights(maps: dict, layers: list[dict], seed: int = 0) -> np.ndarray:
305
  W = np.zeros((len(layers), size, size), np.float32)
306
  for i, L in enumerate(layers):
307
  w = np.ones((size, size), np.float32)
308
- if i and not L.get("where") and (L.get("paths") or L.get("areas")):
309
- w[:] = 0 # drawn only where its shapes are (added by Terrain.plan)
 
310
  elif i:
311
  br = float(L.get("breakup", 0.5))
312
  nz = fbm(size, 6, 5, seed * 1000 + 17 * i) if br > 0 else 0.0
@@ -890,6 +891,35 @@ def wooded(layer: dict) -> bool:
890
  return bool(re.search(_WOODED, text, re.I))
891
 
892
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
893
  def guide_colour(layer: dict) -> np.ndarray:
894
  """A layer's colour in the overview's guide: the given colour, else canopy green for trees (the guide must
895
  agree with the legend: a forest floor's brown under "pine tree crowns" made the model ignore the guide), else
@@ -1127,11 +1157,14 @@ _WATER_LAYER = r"\b(?:water|rivers?|streams?|creeks?|brooks?|ponds?|lakes?|sea|o
1127
 
1128
 
1129
  def dry_painted_water(img: np.ndarray, H: np.ndarray, level: float, W: np.ndarray, layers: list[dict],
1130
- relief: float, water_rgb) -> tuple[np.ndarray, int]:
1131
  """Water the image model painted on ground above the caller's water plane, repainted in the colour its layers
1132
  have elsewhere in the image. Measured (round 29): an 18 m pond in an 800 m desert was painted 33 to 52 times
1133
  its area whatever the prompt said; under the caller's water plane the extra became dark blue "water" on dry
1134
- sand. ``H`` and ``W`` are at the image's size; returns the image and the number of pixels repainted."""
 
 
 
1135
  import cv2
1136
  import re
1137
 
@@ -1153,7 +1186,9 @@ def dry_painted_water(img: np.ndarray, H: np.ndarray, level: float, W: np.ndarra
1153
  refs = [np.median(L[(w > 0.6) & plain], 0) if ((w > 0.6) & plain).sum() >= 30 else fallback for w in W]
1154
  E = np.einsum("lhw,lc->hwc", W, np.stack(refs).astype(np.float32))
1155
  de = np.linalg.norm(L - E, axis=-1)
1156
- wet = dry & (dw < de) & (dw < np.maximum(12.0, 0.5 * np.linalg.norm(E - mu, axis=-1)))
 
 
1157
  keep = [i for i, Lr in enumerate(layers) if re.search(_WATER_LAYER, Lr.get("name", "").replace("_", " "), re.I)]
1158
  if keep:
1159
  wet &= W[keep].sum(0) < 0.3
@@ -1492,7 +1527,10 @@ class Terrain:
1492
  seed = self._seed("layer:" + L["name"], new_seeds)
1493
  col = np.asarray(L["color"], np.float32) / 255 if L.get("color") else np.asarray(default_colour(L))
1494
  path = bool(L.get("paths"))
1495
- prompt = SWATCH_PROMPT.format(m=L["tile_m"], p=surface_words(L["prompt"]) if path else L["prompt"])
 
 
 
1496
  if path: # the texture repeats in fixed directions: ruts or edge lines would cross the road
1497
  prompt += (" Only the road surface material itself, even all over: no edges, verges, ruts, lines or "
1498
  "stripes running across it.")
@@ -1573,7 +1611,8 @@ class Terrain:
1573
  H = resample(np.load(self.root / "mesh" / "height.npy").astype(np.float32), ov.shape[0])
1574
  fixed, n = dry_painted_water(ov, H, float(self.prm["water_level"]), self.weights(ov.shape[0]),
1575
  self.state["layers"], self.state["relief_m"],
1576
- water_look(self.prm.get("prompt", ""))[0])
 
1577
  if n:
1578
  _save(self.root / "macro" / "overview_raw.png", ov)
1579
  ov = fixed
 
305
  W = np.zeros((len(layers), size, size), np.float32)
306
  for i, L in enumerate(layers):
307
  w = np.ones((size, size), np.float32)
308
+ if i and (L.get("paths") or L.get("areas")):
309
+ w[:] = 0 # drawn only where its shapes are, its rules applied there (Terrain.plan); round 30: with
310
+ # rules too it also covered every slope its rules allow, 38 % of a fjord for a 0.6 % village green
311
  elif i:
312
  br = float(L.get("breakup", 0.5))
313
  nz = fbm(size, 6, 5, seed * 1000 + 17 * i) if br > 0 else 0.0
 
891
  return bool(re.search(_WOODED, text, re.I))
892
 
893
 
894
+ _TREE_PARTS = (r"\b(?:trunks?|crowns?|canop(?:y|ies)|trees?|branch(?:es)?|foliage|woodlands?|woods|forests?|groves?|"
895
+ r"copses?|thickets?|jungles?)\b(?!\s+floor)")
896
+ _WOOD_NOUN = r"\b(?:woodlands?|woods|forests?|groves?|copses?|thickets?|jungles?|orchards?)\b"
897
+
898
+
899
+ def floor_words(prompt: str) -> str:
900
+ """What a wooded layer's detail texture shows: the ground under the trees. A prompt describing the trees
901
+ ("dense Nordic birch woodland: slender white birch trunks, bright fresh green canopy, ...") gave a swatch of a
902
+ whole wood seen from afar, tiled on the ground every 8 m (round 30); asking for "the forest floor under" it kept
903
+ the trees. Clauses about the trees are dropped; the ground clauses stay, after the wood's name."""
904
+ import re
905
+
906
+ clauses = [c.strip() for c in re.split(r"[,;:.]", prompt or "") if c.strip()]
907
+ trees = [c for c in clauses if re.search(_TREE_PARTS, c, re.I)]
908
+ if not trees:
909
+ return prompt
910
+ ground = []
911
+ for c in clauses: # "gnarled olive trees on dry red earth with stones": the earth stays
912
+ tail = re.search(r"\b(?:on|over|among|in)\s+(.+)$", c, re.I) if c in trees else None
913
+ if c not in trees or (tail and not re.search(_TREE_PARTS, tail.group(1), re.I)):
914
+ ground.append(c if c not in trees else tail.group(1))
915
+ # the wood's name: its noun and up to three words before it ("dense Nordic birch woodland")
916
+ m = re.search(r"\b((?:(?!(?:in|on|of|a|an|the|with|and|trees?)\b)[\w-]+\s+){0,3})" + _WOOD_NOUN, " ".join(trees),
917
+ re.I)
918
+ wood = m.group(0).strip() if m else "woodland"
919
+ return (f"forest floor of {'an' if wood[0].lower() in 'aeiou' else 'a'} {wood}: "
920
+ + (", ".join(ground) if ground else "fallen leaves or needles, twigs and moss"))
921
+
922
+
923
  def guide_colour(layer: dict) -> np.ndarray:
924
  """A layer's colour in the overview's guide: the given colour, else canopy green for trees (the guide must
925
  agree with the legend: a forest floor's brown under "pine tree crowns" made the model ignore the guide), else
 
1157
 
1158
 
1159
  def dry_painted_water(img: np.ndarray, H: np.ndarray, level: float, W: np.ndarray, layers: list[dict],
1160
+ relief: float, water_rgb, cell=(1.0, 1.0)) -> tuple[np.ndarray, int]:
1161
  """Water the image model painted on ground above the caller's water plane, repainted in the colour its layers
1162
  have elsewhere in the image. Measured (round 29): an 18 m pond in an 800 m desert was painted 33 to 52 times
1163
  its area whatever the prompt said; under the caller's water plane the extra became dark blue "water" on dry
1164
+ sand. Only gentle ground near the level counts (under 30 degrees, within max(10 m, 2 % of the relief)): shaded
1165
+ fjord cliffs hundreds of metres up are as dark and blue as water (round 30; the oasis's extra water lay on
1166
+ slopes up to 30 degrees, at most 6 m up). ``H`` and ``W`` are at the image's size, ``cell`` its metres per pixel
1167
+ (along Z, along X); returns the image and the number of pixels repainted."""
1168
  import cv2
1169
  import re
1170
 
 
1186
  refs = [np.median(L[(w > 0.6) & plain], 0) if ((w > 0.6) & plain).sum() >= 30 else fallback for w in W]
1187
  E = np.einsum("lhw,lc->hwc", W, np.stack(refs).astype(np.float32))
1188
  de = np.linalg.norm(L - E, axis=-1)
1189
+ gz, gx = np.gradient(H.astype(np.float64), float(cell[0]), float(cell[1]))
1190
+ low = (np.degrees(np.arctan(np.hypot(gx, gz))) < 30) & (H < level + max(10.0, 0.02 * relief))
1191
+ wet = dry & low & (dw < de) & (dw < np.maximum(12.0, 0.5 * np.linalg.norm(E - mu, axis=-1)))
1192
  keep = [i for i, Lr in enumerate(layers) if re.search(_WATER_LAYER, Lr.get("name", "").replace("_", " "), re.I)]
1193
  if keep:
1194
  wet &= W[keep].sum(0) < 0.3
 
1527
  seed = self._seed("layer:" + L["name"], new_seeds)
1528
  col = np.asarray(L["color"], np.float32) / 255 if L.get("color") else np.asarray(default_colour(L))
1529
  path = bool(L.get("paths"))
1530
+ words = surface_words(L["prompt"]) if path else floor_words(L["prompt"]) if wooded(L) else L["prompt"]
1531
+ if words != L["prompt"]:
1532
+ L["detail_prompt"] = words
1533
+ prompt = SWATCH_PROMPT.format(m=L["tile_m"], p=words)
1534
  if path: # the texture repeats in fixed directions: ruts or edge lines would cross the road
1535
  prompt += (" Only the road surface material itself, even all over: no edges, verges, ruts, lines or "
1536
  "stripes running across it.")
 
1611
  H = resample(np.load(self.root / "mesh" / "height.npy").astype(np.float32), ov.shape[0])
1612
  fixed, n = dry_painted_water(ov, H, float(self.prm["water_level"]), self.weights(ov.shape[0]),
1613
  self.state["layers"], self.state["relief_m"],
1614
+ water_look(self.prm.get("prompt", ""))[0],
1615
+ (self.state["depth_m"] / H.shape[0], self.state["width_m"] / H.shape[1]))
1616
  if n:
1617
  _save(self.root / "macro" / "overview_raw.png", ov)
1618
  ov = fixed