env-able 1.2.0__tar.gz → 1.2.2__tar.gz

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 (32) hide show
  1. {env_able-1.2.0 → env_able-1.2.2}/.gitignore +68 -60
  2. {env_able-1.2.0 → env_able-1.2.2}/CHANGELOG.md +77 -0
  3. {env_able-1.2.0 → env_able-1.2.2}/PKG-INFO +129 -6
  4. {env_able-1.2.0 → env_able-1.2.2}/README.md +988 -865
  5. {env_able-1.2.0 → env_able-1.2.2}/pyproject.toml +1 -1
  6. {env_able-1.2.0 → env_able-1.2.2}/src/env_able/__init__.py +1 -1
  7. {env_able-1.2.0 → env_able-1.2.2}/src/env_able/atlas/desktop.py +152 -132
  8. {env_able-1.2.0 → env_able-1.2.2}/src/env_able/atlas/index.html +12700 -12300
  9. {env_able-1.2.0 → env_able-1.2.2}/src/env_able/atlas/landing.html +947 -943
  10. {env_able-1.2.0 → env_able-1.2.2}/src/env_able/atlas/server.py +4857 -4715
  11. {env_able-1.2.0 → env_able-1.2.2}/src/env_able/vector.py +226 -72
  12. {env_able-1.2.0 → env_able-1.2.2}/LICENSE +0 -0
  13. {env_able-1.2.0 → env_able-1.2.2}/src/env_able/_gdal_env.py +0 -0
  14. {env_able-1.2.0 → env_able-1.2.2}/src/env_able/_gdal_utils.py +0 -0
  15. {env_able-1.2.0 → env_able-1.2.2}/src/env_able/_pipeline.py +0 -0
  16. {env_able-1.2.0 → env_able-1.2.2}/src/env_able/_result.py +0 -0
  17. {env_able-1.2.0 → env_able-1.2.2}/src/env_able/atlas/__init__.py +0 -0
  18. {env_able-1.2.0 → env_able-1.2.2}/src/env_able/atlas/client.py +0 -0
  19. {env_able-1.2.0 → env_able-1.2.2}/src/env_able/atlas/compliance.html +0 -0
  20. {env_able-1.2.0 → env_able-1.2.2}/src/env_able/atlas/databricks.py +0 -0
  21. {env_able-1.2.0 → env_able-1.2.2}/src/env_able/atlas/debug.py +0 -0
  22. {env_able-1.2.0 → env_able-1.2.2}/src/env_able/atlas/edits.py +0 -0
  23. {env_able-1.2.0 → env_able-1.2.2}/src/env_able/atlas/project.py +0 -0
  24. {env_able-1.2.0 → env_able-1.2.2}/src/env_able/atlas/splash.html +0 -0
  25. {env_able-1.2.0 → env_able-1.2.2}/src/env_able/atlas/templates/Summary A4 Landscape.pagx +0 -0
  26. {env_able-1.2.0 → env_able-1.2.2}/src/env_able/atlas/templates/Summary A4 Portrait.pagx +0 -0
  27. {env_able-1.2.0 → env_able-1.2.2}/src/env_able/atlas/templates/Title Bar A4 Landscape.pagx +0 -0
  28. {env_able-1.2.0 → env_able-1.2.2}/src/env_able/atlas/templates/Title Bar A4 Portrait.pagx +0 -0
  29. {env_able-1.2.0 → env_able-1.2.2}/src/env_able/layout.py +0 -0
  30. {env_able-1.2.0 → env_able-1.2.2}/src/env_able/morph.py +0 -0
  31. {env_able-1.2.0 → env_able-1.2.2}/src/env_able/pull.py +0 -0
  32. {env_able-1.2.0 → env_able-1.2.2}/src/env_able/stream.py +0 -0
@@ -1,60 +1,68 @@
1
- # ── Secrets — never commit ────────────────────────────────────────────────────
2
- # Live PyPI API token used by dev/publish-*.bat
3
- **/dev/token.txt
4
- **/token.txt
5
- *.pem
6
- *.key
7
- .env
8
- .env.*
9
-
10
- # ── Virtual environments ──────────────────────────────────────────────────────
11
- .venv/
12
- venv/
13
- env/
14
- **/.venv/
15
-
16
- # ── Python build / cache ──────────────────────────────────────────────────────
17
- __pycache__/
18
- *.py[cod]
19
- *.egg-info/
20
- build/
21
- dist/
22
- .pytest_cache/
23
- .mypy_cache/
24
- .ruff_cache/
25
-
26
- # ── Atlas debug session logs ──────────────────────────────────────────────────
27
- # Bug reports are per-session working files, not source. README.md is kept.
28
- debug/atlas-*.jsonl
29
- debug/atlas-*.md
30
- debug/latest.md
31
-
32
- # ── Large test / sample data — on disk, not in history ────────────────────────
33
- # 364 MB of Bakken + Vaca Muerta shapefiles. Git is not a data store; keeping
34
- # these out is what makes the repo clonable. They stay on disk untouched.
35
- incubator/testing-data/
36
- **/TestingData/
37
- *.duckdb
38
- *.zip
39
-
40
- # Parked experiments (see CLAUDE.md) — ~58 MB, mostly binary assets.
41
- # Untracked, not deleted. Remove this line if you want them versioned.
42
- incubator/archive/
43
-
44
- # ── Editor / OS ───────────────────────────────────────────────────────────────
45
- .idea/
46
- .vscode/
47
- *.swp
48
- Thumbs.db
49
- desktop.ini
50
- $RECYCLE.BIN/
51
-
52
- # ── Claude Code working state ─────────────────────────────────────────────────
53
- .claude/worktrees/
54
- .claude/shell-snapshots/
55
- .claude/statsig/
56
- .claude/todos/
57
-
58
- # ── Scratch output ────────────────────────────────────────────────────────────
59
- *.atlas.json.bak
60
- *.bak-*
1
+ # ── Secrets — never commit ────────────────────────────────────────────────────
2
+ # Live PyPI API token used by dev/publish-*.bat
3
+ **/dev/token.txt
4
+ **/token.txt
5
+ *.pem
6
+ *.key
7
+ .env
8
+ .env.*
9
+
10
+ # ── Virtual environments ──────────────────────────────────────────────────────
11
+ .venv/
12
+ venv/
13
+ env/
14
+ **/.venv/
15
+
16
+ # ── Python build / cache ──────────────────────────────────────────────────────
17
+ __pycache__/
18
+ *.py[cod]
19
+ *.egg-info/
20
+ build/
21
+ dist/
22
+ .pytest_cache/
23
+ .mypy_cache/
24
+ .ruff_cache/
25
+
26
+ # ── Atlas debug session logs ──────────────────────────────────────────────────
27
+ # Bug reports are per-session working files, not source. README.md is kept.
28
+ debug/atlas-*.jsonl
29
+ debug/atlas-*.md
30
+ debug/latest.md
31
+
32
+ # ── Large test / sample data — on disk, not in history ────────────────────────
33
+ # 364 MB of Bakken + Vaca Muerta shapefiles. Git is not a data store; keeping
34
+ # these out is what makes the repo clonable. They stay on disk untouched.
35
+ incubator/testing-data/
36
+ **/TestingData/
37
+ *.duckdb
38
+ *.zip
39
+
40
+ # Parked experiments (see CLAUDE.md) — ~58 MB, mostly binary assets.
41
+ # Untracked, not deleted. Remove this line if you want them versioned.
42
+ incubator/archive/
43
+
44
+ # ── Editor / OS ───────────────────────────────────────────────────────────────
45
+ .idea/
46
+ .vscode/
47
+ *.swp
48
+ Thumbs.db
49
+ desktop.ini
50
+ $RECYCLE.BIN/
51
+
52
+ # ── Claude Code working state ─────────────────────────────────────────────────
53
+ .claude/worktrees/
54
+ .claude/shell-snapshots/
55
+ .claude/statsig/
56
+ .claude/todos/
57
+
58
+ # ── Scratch output ────────────────────────────────────────────────────────────
59
+ *.atlas.json.bak
60
+ *.bak-*
61
+
62
+ # ── Virtual environments the skill creates ────────────────────────────────────
63
+ # preflight.py builds skill/venv on the user's machine; it is platform-specific and large.
64
+ skill/venv/
65
+
66
+ # ── Handoff artifacts ─────────────────────────────────────────────────────────
67
+ # The .skill is rebuilt by package_skill.py and already versioned in releases/.
68
+ handoff/*.skill
@@ -1,5 +1,82 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.2.2] - 2026-08-17
4
+
5
+ ### Fixed
6
+ - **The README documented three operations out of seven.** `Union`, `Dissolve`, `SpatialJoin` and
7
+ `Near` had no entry at all, on the page PyPI renders as the front door, and had not for several
8
+ releases. All four now carry their real signatures and defaults; `Union` and `Intersect` note
9
+ that they accept a list; the async section names every variant rather than saying "all three";
10
+ and `SpatialResult` no longer claims to come from three operations.
11
+
12
+ Two things were wrong in the first draft and were caught against the code rather than shipped:
13
+ `Near` does **not** use ArcGIS's `NEAR_DIST` / `-1` convention — it writes a single column
14
+ named `dist_<near layer>_<unit>` and leaves it *null* beyond `max_distance`, keeping every
15
+ input feature — and the Spatial Join match list is all seven options in `SJ_MATCH`.
16
+
17
+ The durable part is in `docs/verify.py`: the expected operation list is derived from
18
+ `dir(env)`, and the quoted `DISSOLVE_STATISTICS` and `SJ_MATCH` values are compared against the
19
+ module. A new operation is now documented or the release fails. Nothing checked before, which
20
+ is why four of them went missing without anyone noticing.
21
+
22
+ ## [1.2.1] - 2026-08-17
23
+
24
+ ### Added
25
+ - **Union and Intersect take more than two layers.** Both accept a list where they used to
26
+ accept one other layer — `env.Union("a.shp", ["b.shp", "c.shp", "d.shp"])` — and Atlas offers
27
+ a checkbox list of layers instead of a single dropdown, the same picker Dissolve uses for
28
+ fields. Three shapefiles used to be two operations plus an intermediate file to name, track
29
+ and delete.
30
+
31
+ The layers are folded pairwise, which is the correct N-way answer. For Union, the `FID_`
32
+ columns are attached to every input **before** the fold rather than per pair, so each input
33
+ keeps its own through every pass: a piece of the output still says which of the ten layers
34
+ made it. The input's `where` filter applies to the first pass only — after that the
35
+ accumulator is an intermediate the filter was never written against.
36
+
37
+ `POST /api/geoprocess` takes `other_layers` alongside the old `other_layer`, merges the two
38
+ and drops duplicates, so a script written against the previous shape keeps working. Clip and
39
+ Spatial Join still pair exactly two layers, and say so if handed a list.
40
+ - **`POST /api/export/pdf`** builds a PDF from a map image with Pillow and returns the bytes.
41
+ - **`POST /api/databricks/open-token-page`** and **`POST /api/open-url`** open a
42
+ settings-derived address in the user's real default browser. The page names a *setting*, never
43
+ a URL, so there is no request that makes the server open something the user did not configure.
44
+
45
+ ### Changed
46
+ - **The desktop window opens maximized.** Filling the screen is what a map
47
+ application wants: the old 1600x1000 default left a ribbon, two docked panels and a status
48
+ strip competing for what was left. Maximized rather than fullscreen, deliberately — the
49
+ title bar, close button and taskbar stay, because Atlas is something you work alongside
50
+ Claude and a file explorer, not a kiosk. Un-maximizing gives back the 1600x1000 window.
51
+ Both window paths honour it (pywebview's `maximized`, Chromium's `--start-maximized`), an
52
+ older pywebview without the argument degrades to a sized window rather than failing, and
53
+ `open_window(..., maximized=False)` opts out.
54
+ - **Atlas raises no browser dialogs at all.** Every `alert`, `confirm` and `prompt` — twenty of
55
+ them — is now an Atlas panel: themed, with buttons that say what they do (**Delete field**,
56
+ not **OK**), red for destructive answers, Esc to cancel and Enter to accept. A native dialog
57
+ announces its origin in the title bar (*"localhost:8000 says"*), which reads as a stray
58
+ browser inside a desktop application, cannot be styled or relabelled, and blocks the event
59
+ loop so the map stops redrawing behind it.
60
+ - **Nothing calls `window.open` either.** In a desktop window an embedded webview has no
61
+ pop-ups, so those calls returned null and did nothing: **Find token** silently failed, and
62
+ *Export PDF* threw where it wrote into the window it never got. Both now go through the
63
+ server — Find token opens the page, PDF comes back as bytes the page downloads like the SVG.
64
+ A PDF export is also now genuinely a PDF, rather than whatever the print dialog produced.
65
+
66
+ ### Fixed
67
+ - **Intersect said "returned 0 features" when a fold emptied the result**, which with several
68
+ inputs is not a diagnosis. It now names the layer nothing survived, says how many were
69
+ skipped, and points out that every layer overlaps the *input* — it is the running result they
70
+ stop sharing area with.
71
+ - **A zero-area intersection read as success.** Where layers touch along a boundary rather than
72
+ overlapping, OGR returns the shared edge as a feature: `output_count` was 1 with no area in
73
+ it, and `count_slivers` does not catch it because a line is not a thin polygon. Now warned
74
+ about explicitly.
75
+ - **`FID_` collisions in Union.** Two layers with the same stem, or two GeoDataFrames (which
76
+ have no stem at all), produced the same column name — and a collision does not raise, the
77
+ second write just wins, so the output claimed a piece came from a layer that did not
78
+ contribute it.
79
+
3
80
  ## [1.2.0] - 2026-08-13
4
81
 
5
82
  Symbology fixes, all of them one mistake made in several places: coercing on the wrong side of
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: env-able
3
- Version: 1.2.0
3
+ Version: 1.2.2
4
4
  Summary: AI-ready GIS toolkit for energy and subsurface workflows.
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -128,18 +128,97 @@ print(result) # SpatialResult stats
128
128
  ---
129
129
 
130
130
  ### `env.Intersect(input_layer, intersect_layer, output=None, where=None, preview=False)`
131
- Geometric intersection of two layers (`OGR Layer.Intersection()` engine). Input geometries are trimmed to their overlap; attributes from **both** layers appear in the result. Returns a `SpatialResult`.
131
+ Geometric intersection — only what the layers share, with attributes from all of them (`OGR Layer.Intersection()` engine). Returns a `SpatialResult`.
132
132
  - `input_layer` — point, line, or polygon (file path or GeoDataFrame)
133
- - `intersect_layer` — polygon boundary to intersect against
133
+ - `intersect_layer` — **one layer or a list.** A list is folded pairwise, which is exactly the N-way intersection: what the input shares with the first, then what that shares with the second, and so on
134
134
  - `output` — output file path
135
135
  - `where` — optional SQL attribute filter on the input layer
136
136
  - `preview` — if `True`, pushes result to Atlas on completion
137
137
 
138
138
  ```python
139
139
  result = env.Intersect("wells.shp", "permits.gpkg", output="wells_permits.gpkg")
140
+ result = env.Intersect("wells.shp", ["leases.gpkg", "counties.gpkg"]) # three-way
140
141
  result.preview("Permitted Wells")
141
142
  ```
142
143
 
144
+ If one layer leaves nothing, the fold stops and the warning names it — with four inputs, "returned 0 features" is not a diagnosis.
145
+
146
+ ---
147
+
148
+ ### `env.Union(input_layer, union_layer, output=None, join_attributes="ALL", gaps=True, cluster_tolerance=None, where=None, preview=False)`
149
+ Everything from every layer, cut where they overlap (`OGR Layer.Union()` engine). Polygons. Mirrors `arcpy.analysis.Union`. Returns a `SpatialResult`.
150
+
151
+ Where Intersect answers "what do these share?", Union answers "what is covered by either, and by which?" — so the output usually has **more** features than the inputs, because each overlap becomes its own piece.
152
+
153
+ - `union_layer` — **one layer or a list**, folded pairwise. Each input keeps its own `FID_` column through the whole fold
154
+ - `join_attributes` — `"ALL"` (everything, plus a `FID_<layer>` per input), `"NO_FID"` (everything except those), `"ONLY_FID"` (just those)
155
+ - `gaps` — `False` (ArcGIS `NO_GAPS`) gives each enclosed void a feature of its own, with every `FID_` at `-1`, so gaps can be selected and inspected
156
+ - `cluster_tolerance` — snap coordinates within this distance together, as a GEOS precision grid
157
+ - `where` — SQL attribute filter on the input, applied before the overlay
158
+
159
+ ```python
160
+ result = env.Union("leases.gpkg", "units.gpkg", output="lease_units.gpkg",
161
+ join_attributes="ONLY_FID")
162
+ result = env.Union("a.shp", ["b.shp", "c.shp", "d.shp"]) # four layers, one call
163
+ result = env.Union(a, b, gaps=False) # a feature per enclosed void
164
+ ```
165
+
166
+ `FID_<layer>` is the source feature's id, or **-1** where that layer contributed nothing to the piece — which is how you tell a lease-only sliver from a genuine overlap. Non-overlapping inputs are valid (the answer is the layers side by side) and warn, naming the layer that misses, because that usually means a CRS mistake.
167
+
168
+ ---
169
+
170
+ ### `env.Dissolve(input_layer, output=None, dissolve_field=None, statistics_fields=None, multi_part=True, unsplit_lines=False, where=None, preview=False)`
171
+ Merge features that share a value, and summarise the rest. Mirrors `arcpy.management.Dissolve`. Returns a `SpatialResult`.
172
+
173
+ - `dissolve_field` — field name or list of them. **Omit it and every feature merges into one**, which is the usual way to turn a pile of parcels into an outline
174
+ - `statistics_fields` — `[[field, stat], ...]`. `SUM`, `MEAN`, `MIN`, `MAX`, `RANGE`, `STD`, `COUNT`, `FIRST`, `LAST`, `MEDIAN`, `VARIANCE`, `UNIQUE`, `CONCATENATE`. Output columns are named `SUM_acres`, `COUNT_id` and so on, as ArcGIS names them
175
+ - `multi_part` — `False` splits disjoint pieces into a feature each, repeating the attributes (ArcGIS's `SINGLE_PART`)
176
+ - `unsplit_lines` — lines only: merge runs that join end to end, so two unconnected roads called "Main" stay two features
177
+
178
+ ```python
179
+ result = env.Dissolve("parcels.gpkg", output="operators.gpkg",
180
+ dissolve_field="operator",
181
+ statistics_fields=[["acres", "SUM"], ["wells", "COUNT"]])
182
+ result = env.Dissolve("parcels.gpkg") # everything becomes one outline
183
+ ```
184
+
185
+ ---
186
+
187
+ ### `env.SpatialJoin(target_layer, join_layer, match="intersects", how="one_to_one", keep_all=True, fields=None, stats=None, search_radius=None, unit="km", output=None, preview=False)`
188
+ Transfer attributes from one layer to another by location. Mirrors `arcpy.analysis.SpatialJoin`. Returns a `SpatialResult`.
189
+
190
+ - `match` — `intersects` (default), `contains`, `within`, `crosses`, `overlaps`, `touches`, `closest`
191
+ - `how` — `one_to_one` (one output row per target, summarised) or `one_to_many` (a row per match)
192
+ - `keep_all` — `True` keeps targets with no match (ArcGIS's `KEEP_ALL`); `False` drops them
193
+ - `fields` — which join-layer fields to carry across. Omit for all of them
194
+ - `stats` — `[[field, stat], ...]` for `one_to_one`, since several matches have to collapse into one row
195
+ - `search_radius` / `unit` — only meaningful for `match="closest"`
196
+
197
+ ```python
198
+ result = env.SpatialJoin("wells.shp", "counties.gpkg", match="within")
199
+ result = env.SpatialJoin("wells.shp", "leases.gpkg", match="closest",
200
+ search_radius=2, unit="miles")
201
+ ```
202
+
203
+ ---
204
+
205
+ ### `env.Near(input_layer, near_layer, name=None, unit="km", output=None, max_distance=None, preview=False)`
206
+ Distance from each input feature to the nearest feature in another layer. Returns a `SpatialResult`.
207
+
208
+ It **adds a column and changes nothing else** — same geometry, same feature count, every input kept. So the result draws exactly like the input and can be styled, filtered or sorted by distance. Distances are measured in a projected CRS for true ground distance, then the result comes back in the input's own CRS.
209
+
210
+ - `name` — the column to write. Defaults to **`dist_<near layer>_<unit>`**, so measuring one layer against several gives you a column per comparison rather than a collision
211
+ - `unit` — `km` (default), `meters`, `miles`, `feet`, `nm`
212
+ - `max_distance` — in `unit`. Beyond it the column is **null**, not a sentinel value, and the result warns how many features had no neighbour in range
213
+
214
+ ```python
215
+ result = env.Near("wells.shp", "pipelines.gpkg", unit="miles", max_distance=5)
216
+ result = env.Near("wells.shp", "faults.gpkg", name="dist_to_fault_km")
217
+ result.preview("Wells by distance to pipeline")
218
+ ```
219
+
220
+ Where several near features tie for closest, the first is kept and the result says how many tied.
221
+
143
222
  ---
144
223
 
145
224
  ### `env.load(source)` — fluent spatial pipeline
@@ -166,7 +245,7 @@ results = await asyncio.gather(
166
245
  ---
167
246
 
168
247
  ### Async variants
169
- All three operations have async counterparts that run in a thread-pool executor. GDAL releases the GIL so multiple calls run truly in parallel via `asyncio.gather()`.
248
+ Every operation has an async counterpart — `clip_async`, `buffer_async`, `intersect_async`, `union_async`, `dissolve_async`, `spatial_join_async`, `near_async`, `morph_async` — running in a thread-pool executor. GDAL releases the GIL, so multiple calls run truly in parallel via `asyncio.gather()`.
170
249
 
171
250
  ```python
172
251
  import asyncio
@@ -185,13 +264,13 @@ r1, r2, r3 = await asyncio.gather(
185
264
  ---
186
265
 
187
266
  ### `SpatialResult`
188
- Returned by `Clip`, `Buffer`, `Intersect`, and the pipeline `.run()`. Exposes operation stats and chainable output methods.
267
+ Returned by every operation and by the pipeline's `.run()`. Exposes operation stats and chainable output methods.
189
268
 
190
269
  | Attribute / Method | Description |
191
270
  |---|---|
192
271
  | `input_count` | Feature count of the input layer |
193
272
  | `output_count` | Feature count of the result |
194
- | `dropped` | `input_count - output_count` |
273
+ | `dropped` | Features lost. Never negative — Union and Dissolve can grow the count |
195
274
  | `slivers` | Polygon features with suspiciously small area (intersection artifacts) |
196
275
  | `crs` | CRS of the output (e.g. `EPSG:4326`) |
197
276
  | `elapsed_s` | Wall-clock seconds for the operation |
@@ -309,6 +388,50 @@ print(f"{rows:,} rows written")
309
388
  ---
310
389
 
311
390
  ## Changelog
391
+ ### Unreleased
392
+
393
+ **Fixed**
394
+ - **The README documented three operations out of seven.** `Union`, `Dissolve`, `SpatialJoin`
395
+ and `Near` had no entry at all — for several releases, on the page PyPI renders as the front
396
+ door. All four are now documented with their real signatures and defaults, `Union` and
397
+ `Intersect` note that they take a list, and the async list names every variant instead of
398
+ claiming there are three.
399
+
400
+ Two corrections while writing them: `Near` was about to be described with ArcGIS's
401
+ `NEAR_DIST` / `-1` convention, which it does **not** use — it writes one column named
402
+ `dist_<near layer>_<unit>` and leaves it *null* beyond `max_distance` — and the Spatial Join
403
+ match list is all seven options from `SJ_MATCH`, not three and an "and the rest".
404
+
405
+ `docs/verify.py` now derives the expected list from `dir(env)`, so the next operation added is
406
+ either documented or the release fails. That is the actual fix; the prose was only the symptom.
407
+
408
+ ### v1.2.1 — 2026-08-17
409
+
410
+ **Added**
411
+ - **Union and Intersect take more than two layers.** Pass a list —
412
+ `env.Union("a.shp", ["b.shp", "c.shp", "d.shp"])` — and Atlas offers a checkbox list of layers
413
+ instead of one dropdown. They fold pairwise, which is the correct N-way answer, and every
414
+ input keeps its own `FID_` column through the whole fold, so the output still says which layer
415
+ made each piece. `POST /api/geoprocess` gained `other_layers`; the old `other_layer` still
416
+ works and the two are merged.
417
+ - `POST /api/export/pdf` builds a real PDF server-side. `POST /api/databricks/open-token-page`
418
+ and `POST /api/open-url` open a settings-derived address in the default browser.
419
+
420
+ **Changed**
421
+ - **The desktop window opens maximized** rather than at 1600x1000 — the whole
422
+ screen, but still a window: title bar, close button and taskbar all stay.
423
+ - **No browser dialogs anywhere in Atlas.** All twenty `alert`/`confirm`/`prompt` calls are now
424
+ themed Atlas panels with buttons that name the action. A native dialog says
425
+ *"localhost:8000 says"*, cannot be styled, and blocks the map from redrawing behind it.
426
+ - **No `window.open` either** — it opens nothing at all in a desktop window, which is why
427
+ **Find token** silently did nothing and *Export PDF* failed. Both go through the server now.
428
+
429
+ **Fixed**
430
+ - Intersect names the layer that emptied a multi-layer fold instead of reporting "0 features".
431
+ - A zero-area intersection (layers that touch rather than overlap) is warned about rather than
432
+ counted as a result.
433
+ - `FID_` column collisions in Union, where two layers shared a stem and the second silently won.
434
+
312
435
  ### v1.2.0 — 2026-08-13
313
436
 
314
437
  Symbology fixes. All of them the same mistake in several places: coercing on the wrong side of a