Skip to content

Commit e9430a1

Browse files
author
Shane Wall
committed
Add weld-aware validation and map import cleanup
Introduce non-planar face and micro-gap detection in HFValidationSystem, along with configurable weld and planarity tolerances for imported or hand-edited geometry. Add vertex welding and planarity correction helpers, refresh derived face geometry after weld edits, and harden the spatial-hash implementation with 27-cell neighbor lookup so boundary-straddling pairs are not missed. Run a post-parse weld pass in MapIO for near-coincident imported .map vertices, add focused GUT coverage for validation, welding, planarity fixes, and MapIO integration, and update project documentation to describe the new behavior and test count.
1 parent 4f9c47e commit e9430a1

11 files changed

Lines changed: 870 additions & 10 deletions

CHANGELOG.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,46 @@ All notable changes to this project will be documented in this file.
44
The format is based on Keep a Changelog, and this project follows semantic versioning.
55

66
## [Unreleased]
7+
### Added
8+
- **Map import vertex welding** (Apr 2026): `MapIO.parse_map_text()` now runs a post-parse
9+
vertex welding pass on all parsed brush face points before constructing brush geometry.
10+
Near-coincident vertices (within `import_weld_tolerance`, default 0.01 units) are averaged
11+
to a shared position, closing micro-gaps caused by floating-point representation drift in
12+
legacy .map editors. Uses BFS over a spatial hash with 27-cell neighbor lookup so pairs
13+
straddling a snap-grid boundary are never missed. The tolerance is configurable via the
14+
static `MapIO.import_weld_tolerance` property; set to 0.0 to disable.
15+
16+
- **Non-planar face detection** (Apr 2026): `HFValidationSystem.check_bake_issues()` now
17+
flags faces with 4+ vertices where any vertex deviates from the face plane beyond
18+
`planarity_tolerance` (default 0.01 units). Reported as `type: "non_planar"`, severity 1.
19+
Adjustable per-instance via `val_sys.planarity_tolerance`.
20+
21+
- **Micro-gap detection** (Apr 2026): `check_bake_issues()` now detects near-coincident
22+
but not-exactly-equal vertices across different brushes that would cause seam tearing
23+
after bake. Reported as `type: "micro_gap"`, severity 1. Tolerance controlled by
24+
`val_sys.weld_tolerance` (default 0.001 units).
25+
26+
- **Vertex welding auto-fix** (Apr 2026): `HFValidationSystem.weld_brush_vertices(brush)`
27+
snaps all vertices within `weld_tolerance` of each other to their averaged position using
28+
BFS grouping over a 27-cell spatial hash. Calls `ensure_geometry()` on every modified face
29+
to refresh normals and bounds. Returns the count of welded vertices.
30+
31+
- **Planarity auto-fix** (Apr 2026): `HFValidationSystem.fix_non_planar_faces(brush)`
32+
projects drifting vertices back onto the best-fit plane defined by each face's first three
33+
vertices. Calls `ensure_geometry()` after correction. Returns the count of vertices fixed.
34+
35+
- **Configurable validation tolerances** (Apr 2026): `HFValidationSystem` gains two public
36+
properties — `weld_tolerance` (default 0.001) for vertex coincidence and `planarity_tolerance`
37+
(default 0.01) for face-plane deviation. These control the new checks and auto-fix methods.
38+
The `_edge_key()` function used by non-manifold/open-edge detection retains its fixed 0.001
39+
precision — it is intentionally decoupled from `weld_tolerance` so topology checks remain
40+
stable regardless of the weld knob setting.
41+
42+
21 new tests in `test_weld_and_planarity.gd`: non-planar detection (5), vertex welding (3),
43+
planarity fix (3), micro-gap detection (2), edge-key independence (1), boundary-straddling
44+
coverage (3), MapIO integration (2), MapIO unit (2).
45+
Total: **1299 tests across 73 files**.
46+
747
### Changed
848
- **Non-blocking face-mode bakes** (Apr 2026): Full bakes using the face-material path
949
(`bake_use_face_materials = true`) no longer freeze the editor. The bake system now operates in two

CONTRIBUTING.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ Thanks for helping improve HammerForge.
3838
- **Face winding convention**: All faces must use **CW vertex winding** as seen from outside the brush (Godot 4's front-face convention). `_compute_normal()` produces outward normals for CW faces automatically. Never negate normals manually after `ensure_geometry()`. When adding new face generators, verify normals point outward from the brush centroid.
3939
- **Polygon/path tools** create brushes via `root.brush_system.create_brush_from_info()` with a `faces` key containing serialized face data. Use `FaceData.from_dict()` / `to_dict()` for serialization. Face dicts include `winding_version: 1`; omitting this key triggers load-time migration.
4040
- **Spawn system** (`root.spawn_system`): use `get_active_spawn()` for primary-flag-aware spawn lookup, `validate_spawn()` for physics-based validation, `auto_fix_spawn()` to apply suggested fixes, `create_default_spawn()` for fallback creation. Quick Play flow calls these automatically. Debug visualisation via `show_validation_debug()` / `cleanup_debug()`. Spawn properties (`primary`, `angle`, `height_offset`) are defined in `entities.json` and auto-generated in the Entities dock.
41+
- **Validation tolerances**: `HFValidationSystem` has two configurable tolerances — `weld_tolerance` (default 0.001) for vertex coincidence in welding/micro-gap detection, and `planarity_tolerance` (default 0.01) for face-plane deviation. The `_edge_key()` function used by non-manifold/open-edge topology checks uses a **fixed** 0.001 precision and must NOT be coupled to `weld_tolerance` — changing `_edge_key` precision would mask real topology issues when users raise the weld knob. Keep new spatial-hash lookups distance-based with 27-cell neighbor search (see `_cell_keys()`) rather than single-bucket — bucket boundaries silently miss valid pairs. Always call `face.ensure_geometry()` after mutating `local_verts` so normals and bounds stay in sync.
4142
- **Incremental bake**: `bake_selected()` merges into the existing `baked_container` — never replace the container wholesale. `bake_dirty()` uses `_last_bake_success` to decide whether to clear dirty tags; failed bakes must retain all tags so they can be retried.
4243
- **Bake preview modes**: use the `PreviewMode` enum (FULL, WIREFRAME, PROXY). Wireframe must use `ShaderMaterial` with `render_mode wireframe``StandardMaterial3D` has no `wireframe` property in Godot 4.6.
4344
- **Quick Play variants**: `_on_quick_play_from_camera()` and `_on_quick_play_selected_area()` must follow the same severity ≥ 2 blocking, auto-create, and fix-dialog patterns as `_on_quick_play()`. Both must restore temporary state (spawn position/angle, cordon) on both success and error paths. Use `_restore_spawn()` helper and explicit type annotations (e.g. `var old_pos: Vector3 =`) to avoid GDScript `:=` inference failures with untyped spawn references.

DEVELOPMENT.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ addons/hammerforge/
9595
hf_paint_system.gd Floor + surface paint, layer CRUD
9696
hf_state_system.gd State capture/restore, settings, transactions
9797
hf_file_system.gd .hflevel/.map/.glTF I/O, threaded writes, autosave failure reporting
98-
hf_validation_system.gd Validation, dependency checks, bake issue detection (degenerate/floating/overlapping)
98+
hf_validation_system.gd Validation, dependency checks, bake issue detection (degenerate/floating/overlapping/non-planar/micro-gap), vertex welding + planarity auto-fix
9999
hf_visgroup_system.gd Visgroups (visibility groups) + brush/entity grouping
100100
hf_carve_system.gd Boolean-subtract carve (progressive-remainder box slicing)
101101
hf_io_visualizer.gd Entity I/O connection lines (Bézier curves, color-coded, highlight pulse)
@@ -246,6 +246,7 @@ Tests live in `tests/` and use the [GUT](https://github.com/bitwes/Gut) framewor
246246
| `test_reference_cleanup.gd` | 9 | Delete cleans group/visgroup membership, entity I/O cleanup_dangling_connections, preserves unrelated, no-crash on clean node |
247247
| `test_bake_system.gd` | 43 | build_bake_options, structural/trigger filtering, chunk_coord, bake_dry_run, warn_bake_failure, estimate_bake_time, preview mode helpers (+ recursive chunk wireframe/proxy/multimesh/full), _last_bake_success, dirty tag retention, wireframe ShaderMaterial |
248248
| `test_bake_issues.gd` | 10 | check_bake_issues: degenerate, oversized, floating subtract, overlapping subtracts, non-manifold/open-edge, clean level, entity skip |
249+
| `test_weld_and_planarity.gd` | 21 | Non-planar face detection (5), vertex welding + ensure_geometry refresh (3), planarity auto-fix (3), micro-gap detection (2), edge-key independence (1), boundary-straddling weld/gap/parse (3), MapIO integration (2), MapIO snap unit (2) |
249250
| `test_quick_play_modes.gd` | 12 | Severity blocking (0/1/2), cordon save/restore, dirty tag retention patterns, camera yaw via entity_data, spawn restore after camera play, spawn restore on error path |
250251
| `test_integration.gd` | 22 | End-to-end: brush lifecycle, paint + heightmap, entity workflow, visgroup cross-system, snap, bake cross-system, entity I/O cleanup, brush info round-trip |
251252
| `test_shortcut_dialog.gd` | 8 | Category assignment (tools, paint, axis lock, editing), action labels (known/unknown), get_all_bindings copy safety |

HammerForge_SPEC.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ All signals are defined on `LevelRoot`. Subsystems emit them via `root.<signal>.
9494
| `hf_paint_system.gd` | `HFPaintSystem` | Floor paint input, surface paint, paint layer CRUD, face selection |
9595
| `hf_state_system.gd` | `HFStateSystem` | State capture/restore, settings, paint layer serialization, transactions (begin/commit/rollback) |
9696
| `hf_file_system.gd` | `HFFileSystem` | .hflevel save/load, .map import/export, glTF export, threaded I/O, autosave failure reporting |
97-
| `hf_validation_system.gd` | `HFValidationSystem` | Validation, dependency checks, auto-fix helpers, bake issue detection (degenerate/floating/overlapping) |
97+
| `hf_validation_system.gd` | `HFValidationSystem` | Validation, dependency checks, auto-fix helpers (vertex weld, planarity fix), bake issue detection (degenerate/floating/overlapping/non-planar/micro-gap). Configurable `weld_tolerance` and `planarity_tolerance`. Edge-key topology hashing intentionally decoupled from weld knob |
9898
| `hf_visgroup_system.gd` | `HFVisgroupSystem` | Visgroups (visibility groups), brush/entity grouping |
9999
| `hf_carve_system.gd` | `HFCarveSystem` | Boolean-subtract carve (progressive-remainder box slicing) |
100100
| `hf_io_visualizer.gd` | `HFIOVisualizer` | Entity I/O connection lines in viewport (ImmediateMesh) |
@@ -326,7 +326,8 @@ Foliage Populator
326326
- **Bake Changed** (`bake_dirty()`): bakes only brushes with dirty tags (`_dirty_brush_ids`). Tags are cleared only when `_last_bake_success` is true; failed bakes retain all dirty tags.
327327
- **Preview modes** (`PreviewMode` enum: FULL, WIREFRAME, PROXY): `_apply_preview_visuals()` overrides material on baked meshes. Wireframe uses inline `ShaderMaterial` with `render_mode wireframe`. Proxy uses unshaded semi-transparent `StandardMaterial3D`.
328328
- **Bake time estimate** (`estimate_bake_time()`): ratio-based extrapolation from `_last_bake_duration_ms` and brush count.
329-
- **Bake issue detection** (`HFValidationSystem.check_bake_issues()`): returns Array of `{type, severity, message, node}` dicts. Checks: degenerate brush (sev=2), oversized (sev=1), floating subtract (sev=1), overlapping subtracts (sev=1).
329+
- **Bake issue detection** (`HFValidationSystem.check_bake_issues()`): returns Array of `{type, severity, message, node}` dicts. Checks: degenerate brush (sev=2), oversized (sev=1), floating subtract (sev=1), overlapping subtracts (sev=1), non-manifold edges (sev=2), open edges (sev=1), non-planar faces (sev=1), micro-gaps between brushes (sev=1). Non-planar detection uses `planarity_tolerance` (default 0.01). Micro-gap detection uses `weld_tolerance` (default 0.001). Both use 27-cell spatial hash neighbor lookup for boundary-safe distance checks. `_edge_key()` for topology (non-manifold/open-edge) uses fixed 0.001 precision, intentionally decoupled from `weld_tolerance`.
330+
- **Auto-fix helpers**: `weld_brush_vertices(brush)` snaps near-coincident vertices within `weld_tolerance` via BFS grouping + `ensure_geometry()` refresh. `fix_non_planar_faces(brush)` projects drifting vertices onto the best-fit plane from each face's first 3 vertices.
330331

331332
## Face Materials + Surface Paint
332333
Face data is stored per DraftBrush face with material assignment, UV projection, and optional paint layers.
@@ -479,6 +480,7 @@ Unit tests use the [GUT](https://github.com/bitwes/Gut) framework and run headle
479480
| `test_reference_cleanup.gd` | 9 | Delete cleans group/visgroup membership, entity I/O cleanup_dangling_connections |
480481
| `test_bake_system.gd` | 38 | build_bake_options, structural/trigger filtering, chunk_coord, bake_dry_run, warn_bake_failure, estimate_bake_time, preview modes, _last_bake_success, dirty tag retention, wireframe ShaderMaterial |
481482
| `test_bake_issues.gd` | 10 | check_bake_issues: degenerate, oversized, floating subtract, overlapping subtracts, clean level, entity skip |
483+
| `test_weld_and_planarity.gd` | 21 | Non-planar detection, vertex welding + ensure_geometry refresh, planarity auto-fix, micro-gap detection, edge-key independence, boundary-straddling coverage, MapIO integration + unit |
482484
| `test_quick_play_modes.gd` | 12 | Severity blocking (0/1/2), cordon save/restore, dirty tag retention, camera yaw via entity_data, spawn restore |
483485
| `test_integration.gd` | 22 | End-to-end: brush lifecycle, paint + heightmap, entity workflow, visgroup cross-system, snap, bake, I/O cleanup, info round-trip |
484486
| `test_shortcut_dialog.gd` | 8 | Category assignment (tools, paint, axis lock, editing), action labels, get_all_bindings copy safety |

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
<img src="https://img.shields.io/badge/Godot-4.6%2B-478cbf?logo=godot-engine&logoColor=white" alt="Godot 4.6+">
1010
<img src="https://img.shields.io/badge/License-MIT-green" alt="MIT License">
1111
<img src="https://img.shields.io/badge/Status-Early%20Alpha-red" alt="Early Alpha">
12-
<img src="https://img.shields.io/badge/Tests-1278%20passing-brightgreen" alt="1270 tests passing">
12+
<img src="https://img.shields.io/badge/Tests-1299%20passing-brightgreen" alt="1299 tests passing">
1313
<img src="https://img.shields.io/badge/GDScript-25k%2B%20lines-blueviolet" alt="25k+ lines">
1414
</p>
1515

@@ -40,7 +40,7 @@ HammerForge is a single `addons/` folder. No external tools, no custom builds, n
4040

4141
| | |
4242
|---|---|
43-
| **21 subsystems** + coordinator architecture | **1278 unit + integration tests** with CI on every push |
43+
| **21 subsystems** + coordinator architecture | **1299 unit + integration tests** with CI on every push |
4444
| **15 brush shapes** (box through dodecahedron) | **150 built-in prototype textures** for instant greyboxing |
4545
| **Quake `.map`** + **glTF `.glb`** export | **.hflevel** native format with threaded I/O |
4646
| **Customizable keymaps** (JSON) | **Plugin API** for custom tools |
@@ -180,7 +180,7 @@ Grid-based paint layers with chunked storage for large worlds:
180180
| **Material Atlas** | Pack albedo textures into a single atlas to reduce draw calls (face materials mode) |
181181
| **Bake Visible Only** | Skip hidden visgroups and invisible brushes |
182182
| **Unwrap UV0** | Per-vertex planar UV projection for surfaces without UVs |
183-
| **Check Issues** | Flag degenerate, floating, overlapping, non-manifold, and open-edge brushes |
183+
| **Check Issues** | Flag degenerate, floating, overlapping, non-manifold, open-edge, non-planar, and micro-gap brushes. Auto-fix: vertex weld + planarity correction |
184184
| **Bake estimate** | Time estimate with "Chunking recommended" tip for large levels |
185185
| **Validate** | Check level integrity before bake |
186186
| **.map export** | Classic Quake or Valve 220 format |
@@ -445,5 +445,5 @@ Run `godot --headless --import --path .` first, then re-run the test command.
445445

446446
<p align="center">
447447
<strong>MIT License</strong><br>
448-
<sub>Built for Godot 4.6+ | Last updated April 5, 2026</sub>
448+
<sub>Built for Godot 4.6+ | Last updated April 9, 2026</sub>
449449
</p>

addons/hammerforge/map_io.gd

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ const HFMapQuakeType = preload("map_adapters/hf_map_quake.gd")
1111
const DEFAULT_TEXTURE := "__default"
1212
const AXIS_THRESHOLD := 0.98
1313

14+
## Vertex snapping tolerance for imported .map geometry. Vertices closer than
15+
## this distance are welded to their average position to eliminate floating-point
16+
## drift from legacy editors. Set to 0.0 to disable.
17+
static var import_weld_tolerance: float = 0.01
18+
1419

1520
static func load_map(path: String) -> Dictionary:
1621
if path == "" or not FileAccess.file_exists(path):
@@ -70,6 +75,12 @@ static func parse_map_text(text: String) -> Dictionary:
7075
if kv.size() == 2:
7176
current_entity["properties"][kv[0]] = kv[1]
7277
continue
78+
# Weld near-coincident vertices across all parsed faces to close micro-gaps
79+
if import_weld_tolerance > 0.0:
80+
for entity in entities:
81+
for brush in entity.get("brushes", []):
82+
_snap_parsed_vertices(brush.get("faces", []), import_weld_tolerance)
83+
7384
var brushes: Array = []
7485
var entity_points: Array = []
7586
for entity in entities:
@@ -354,3 +365,85 @@ static func _format_vec3(v: Vector3) -> String:
354365

355366
static func _snapped(value: float) -> String:
356367
return String.num(value, 3)
368+
369+
370+
## Snap near-coincident vertices within a single parsed brush's face list.
371+
## Uses BFS over a spatial hash with 27-cell neighbor lookup so pairs straddling
372+
## a bucket boundary are never missed. Averages each cluster and writes
373+
## the canonical position back.
374+
static func _snap_parsed_vertices(faces: Array, tolerance: float) -> void:
375+
if tolerance <= 0.0:
376+
return
377+
# Collect all vertex references into a flat list + spatial hash
378+
var entries: Array = [] # Array of {fi: int, pi: int, pos: Vector3}
379+
var cells: Dictionary = {} # cell_key -> Array[int]
380+
for fi in range(faces.size()):
381+
var points: Array = faces[fi].get("points", [])
382+
for pi in range(points.size()):
383+
var idx: int = entries.size()
384+
var pos: Vector3 = points[pi]
385+
entries.append({"fi": fi, "pi": pi, "pos": pos})
386+
var key: String = _snap_cell_key(pos, tolerance)
387+
if not cells.has(key):
388+
cells[key] = []
389+
(cells[key] as Array).append(idx)
390+
# BFS grouping
391+
var group_of: PackedInt32Array = PackedInt32Array()
392+
group_of.resize(entries.size())
393+
group_of.fill(-1)
394+
var groups: Array = [] # Array of Array[int]
395+
for seed_idx in range(entries.size()):
396+
if group_of[seed_idx] >= 0:
397+
continue
398+
var gid: int = groups.size()
399+
var members: Array = [seed_idx]
400+
group_of[seed_idx] = gid
401+
var queue: Array = [seed_idx]
402+
while not queue.is_empty():
403+
var cur: int = queue.pop_front()
404+
var cur_pos: Vector3 = entries[cur]["pos"]
405+
for cell_key: String in _snap_cell_keys(cur_pos, tolerance):
406+
for neighbor_idx: int in cells.get(cell_key, []):
407+
if group_of[neighbor_idx] >= 0:
408+
continue
409+
if cur_pos.distance_to(entries[neighbor_idx]["pos"]) <= tolerance:
410+
group_of[neighbor_idx] = gid
411+
members.append(neighbor_idx)
412+
queue.append(neighbor_idx)
413+
groups.append(members)
414+
# Average each group and write back
415+
for members: Array in groups:
416+
if members.size() < 2:
417+
continue
418+
var avg := Vector3.ZERO
419+
for idx: int in members:
420+
avg += entries[idx]["pos"]
421+
avg /= float(members.size())
422+
var any_moved := false
423+
for idx: int in members:
424+
if (entries[idx]["pos"] as Vector3).distance_to(avg) > 0.0:
425+
any_moved = true
426+
break
427+
if not any_moved:
428+
continue
429+
for idx: int in members:
430+
var fi: int = entries[idx]["fi"]
431+
var pi: int = entries[idx]["pi"]
432+
faces[fi]["points"][pi] = avg
433+
434+
435+
static func _snap_cell_key(v: Vector3, tol: float) -> String:
436+
return "%s,%s,%s" % [snapped(v.x, tol), snapped(v.y, tol), snapped(v.z, tol)]
437+
438+
439+
## Return all 27 cell keys (self + 26 neighbors) for spatial hash lookup.
440+
static func _snap_cell_keys(v: Vector3, cell_size: float) -> Array:
441+
var cx: float = snapped(v.x, cell_size)
442+
var cy: float = snapped(v.y, cell_size)
443+
var cz: float = snapped(v.z, cell_size)
444+
var keys: Array = []
445+
for dx in [-cell_size, 0.0, cell_size]:
446+
for dy in [-cell_size, 0.0, cell_size]:
447+
for dz in [-cell_size, 0.0, cell_size]:
448+
keys.append("%s,%s,%s" % [cx + dx, cy + dy, cz + dz])
449+
return keys

0 commit comments

Comments
 (0)