Skip to content

LIDAR terrain drives RF path loss / occlusion (PointCloudGround, TerrainObstacleLoss, ground-mesh render)#1120

Open
tabgab wants to merge 17 commits into
topic/inet-vsgfrom
vsg-lidar-pathloss
Open

LIDAR terrain drives RF path loss / occlusion (PointCloudGround, TerrainObstacleLoss, ground-mesh render)#1120
tabgab wants to merge 17 commits into
topic/inet-vsgfrom
vsg-lidar-pathloss

Conversation

@tabgab

@tabgab tabgab commented Jul 6, 2026

Copy link
Copy Markdown

Makes a LIDAR point cloud (or any PLY terrain) the physical ground of a wireless simulation: real surface elevation feeds two-ray path loss, and buildings in the scan occlude non-line-of-sight links — with a matching VSG rendering so the displayed surface is exactly the one the radio models sample. All opt-in; no default behavior changes; the physics layer is headless (Cmdenv) and has no VSG dependency.

What it adds

Shared data layer (always compiled, no VSG include):

  • PlyPointCloudReader — PLY parser (ascii + binary_little_endian, property-name driven), extracted from the VSG terrain loader so the physics and the visualizer read the tile the same way.
  • Heightfield — a 2.5D digital-surface grid (max-z rasterization, hole fill, spike removal); bilinear elevation, normals, and elevation profiles along a segment.

Physics (under the existing PhysicalEnvironment feature; no new deps):

  • PointCloudGround : IGround — rasterizes the tile into a Heightfield and answers ground-projection/normal queries, so TwoRayGroundReflection (and any IGround consumer) works over the real surface with zero changes to it. Transform modes: metric (physics-exact), fit (matches the visualizer's display transform), manual.
  • TerrainObstacleLoss : IObstacleLoss — occlusion from the ground heightfield, with three modes: los (binary line-of-sight), fresnel (graded ITU-R P.526 single knife-edge on the worst first-Fresnel-zone intrusion), diffraction (multi-edge Deygout, summing edges along the profile). Emits obstaclePenetrated events so the existing tracing obstacle-loss visualizers mark obstructions; markerStyle selects a depth gauge or a ray chord. Terrain events carry no physical object (world-frame coordinates) — the canvas/OSG/VSG renderers now handle that case.
  • TwoRayInterference — optional physicalEnvironmentModule: measures antenna heights above the ground model instead of assuming flat ground at z=0 (default preserves the legacy behavior exactly). Fixes a silent error over non-flat terrain.

Visualization (VSG):

  • VsgUtils::createTerrainFromPLY refactored onto the shared reader (render byte-identical).
  • createTerrainMeshFromHeightfield + a lit terrain-mesh pipeline: renders a ground module's heightfield as a shaded, elevation-colored surface in its own simulation coordinates, so the displayed ground is provably the one the physics samples. Wired via SceneVsgVisualizerBase.groundModel.

Testing

  • Unit tests: PlyPointCloudReader_1, Heightfield_1, TerrainObstacleLoss_1 (the ITU-R J(v) curve and the Deygout construction — single-edge equivalence, clear path, second-ridge superadditivity, edge cap).
  • Headless regressions (vsglidar, 60 s, seed 1): two-ray over terrain vs. binary occlusion give 597 vs 279 echo round trips; fresnel/diffraction differ from binary occlusion in ~200 radio-level scalars (graded loss active) even where end-to-end counts coincide on this dense tile.
  • Visual: terrain render, ground-mesh render, and live occlusion (data-link arrows break as drones fly behind buildings) verified in Qtenv/VSG.
  • No default NED/ini changes; existing fingerprints untouched.

Demos (examples/visualizer/vsglidar/omnetpp.ini)

TerrainTwoRay, TerrainOcclusion, TerrainFresnel, TerrainDiffraction, TerrainTwoRayInterference, TerrainMesh (shaded surface), TerrainMeshOcclusion (surface + live occlusion + moving swarm). [General] stays byte-identical.

Notes

🤖 Generated with Claude Code


Open in Devin Review

tabgab and others added 16 commits July 3, 2026 14:52
Companion to the omnetpp-dev change that makes cScene3DNode a
renderer-neutral handle rather than an alias of osg::Node. Wrap OSG scene
roots with omnetpp::createScene3DNode() when calling cOsgCanvas::setScene(),
and recover the typed root via getOsgRoot()/getVsgRoot() instead of casting
cScene3DNode directly to a scene-graph type.

Updated both visualizer trees:
  - OSG:  SceneOsgVisualizer, SceneOsgEarthVisualizer, SceneOsgVisualizerBase,
          OsgScene, and GeographicCoordinateSystem (osgEarth map node lookup)
  - VSG:  SceneVsgVisualizer, VsgScene

Lets INET build and run against an OMNeT++ that contains both 3-D backends,
with the renderer selected per simulation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Corrects the "VisualizationOsg and VisualizationVsg are mutually exclusive"
note (true only before the backend-neutral cScene3DNode change) and documents
that an OMNeT++ configured with both WITH_OSG=yes and WITH_VSG=yes contains
both 3D backends, selected per simulation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The feature description said VisualizationOsg and VisualizationVsg are
mutually exclusive; that is no longer true after the backend-neutral
cScene3DNode change. There is no `conflicts` between them, so both can be
enabled together in an OMNeT++ built with WITH_OSG + WITH_VSG, each scene
rendering on the backend its visualizer creates. (VisualizationVsg is still
initiallyEnabled=false, so enable it explicitly for coexistence.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nehandle.h

The five OSG source files adopted in this PR #include
"qtenv/osg/osgscenehandle.h", which lives under $(OMNETPP_ROOT)/src. That
include path was only added by the VisualizationVsg makefrag block (line 81),
so an OSG-only build (VisualizationOsg enabled, VisualizationVsg disabled —
the common existing configuration) could not find the header and failed to
compile. Add -I$(OMNETPP_ROOT)/src to the VisualizationOsg block, mirroring
the VSG block.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ndwork)

PlyPointCloudReader: the PLY parser extracted from the VSG scene visualizer
(ascii + binary_little_endian, x/y/z + optional rgb located by property name,
arbitrary scalar types/order, list properties skipped), returning retained
point arrays + bounding box so non-visualizer code can consume LIDAR tiles.

Heightfield: regular 2.5D grid rasterized from a point cloud with max-z-per-
cell (DSM) semantics; O(1) bilinear elevation lookup, surface normals,
elevation profiles along a segment (for future line-of-sight/diffraction
models), bounded hole filling, isolated-spike clamping (stray LIDAR returns),
peak/extent queries, and a maxCells guard. Order-independent rasterization
keeps results deterministic across platforms.

Both are pure C++ with no visualizer/VSG dependency; covered by
tests/unit/{PlyPointCloudReader_1,Heightfield_1}.test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Behavior-preserving refactor: createTerrainFromPLY now reads the point cloud
via PlyPointCloudReader (colors arrive normalized) and keeps the display
transform (bbox recenter + aspect-fit into the scene bounds) and elevation
coloring unchanged, so the physical terrain models and the visualization
consume LIDAR data through one implementation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New IGround implementation rasterizing a PLY point cloud (e.g. a LIDAR scan)
into a heightfield, so ground-aware radio models use the real surface
elevation. Fulfills the environment/ground/__TODO wishes for a renderer-
independent, file-loaded ground.

Transform modes: "metric" (default; translate-only, 1 PLY unit = 1 sim meter,
physically exact), "fit" (reproduces the VSG scene visualizer's recenter +
aspect-fit display transform so physics matches the displayed terrain; logs a
scaling warning), "manual" (explicit scale/offsets). Out-of-extent queries
return outOfBoundsElevation (NaN by default per the IGround contract). Init
log reports grid size, extent and peak location for placing nodes.

vsglidar example: new TerrainTwoRay config ([General] untouched) — a gcs
ground station on an open street near the tallest building (289m tower at
(563,277)), five drones flying among the buildings and reporting to it, and
TwoRayGroundReflection computing path loss over the real surface. The
physicalEnvironment space is pinned to the scene box because the scene
visualizer derives its bounds from it once a PhysicalEnvironment module
exists (NaN space otherwise shifts the displayed terrain).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…(LOS)

New IObstacleLoss implementation: a transmission is blocked entirely (loss
factor 0) when the terrain surface — the PointCloudGround heightfield — rises
above the straight line between transmitter and receiver, and is unaffected
otherwise. The ray is sampled at the heightfield cell size (capped at 4096
samples); endpoints are excluded so surface-mounted antennas don't occlude
themselves; out-of-extent samples don't block. The ground is resolved lazily
since the physical environment may initialize after the radio medium.
Composes multiplicatively with any path loss via the analog model — no core
changes. This is the binary "los" mode of the planned terrain occlusion
support; Fresnel-zone attenuation and knife-edge diffraction remain future
work pending design review.

Observability: logs per-node-pair line-of-sight transitions (established /
lost / re-established / cannot be established), with node names resolved
from positions (logLinkEvents parameter, on by default).

vsglidar: new TerrainOcclusion config on top of TerrainTwoRay, with the
traffic reshaped into a star: each drone pings the gcs (now a UdpEchoApp)
twice a second, so live drone<->gcs connections hold a data-link arrow pair
that fades ~1.5s after terrain occludes the drone — connectivity at a
glance. Verified: 60s same-seed run delivers 597 echo round trips without
occlusion vs 279 with it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
omnetpp topic/VSG commit fc5a453385 removed the namespace osg { class
Node; } forward declaration from the omnetpp headers (it belonged to the
dead refOsgNode/unrefOsgNode chain). These three headers hold an
osg::ref_ptr<osg::Node> but only included <osg/ref_ptr>, silently relying
on that leaked declaration, and stopped compiling against the updated
omnetpp. Include <osg/Node> directly, like the sibling OSG visualizer
headers already do.

Same change as PR #1118 (fix/osg-node-includes); duplicated here so this
branch builds standalone against current topic/VSG, deduplicates on merge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ghts

The model derived both antenna heights from raw z coordinates, silently
assuming flat ground at z=0 — over a terrain ground model (e.g. LIDAR
tiles whose streets sit at z≈40-55 m) the heights were off by the full
terrain elevation. New optional physicalEnvironmentModule parameter:
empty by default (byte-identical legacy Veins behavior); when set,
heights are measured above the environment's ground model via
computeGroundProjection(), mirroring TwoRayGroundReflection, with a
fallback to raw z where the ground is undefined (NaN contract).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ss tracing

mode="fresnel": instead of the binary line-of-sight cliff, the worst
first-Fresnel-zone intrusion along the terrain profile is mapped through
the ITU-R P.526 single knife-edge curve J(v) — no loss with >~0.55 r1
clearance, ~6 dB with terrain right at the direct ray, deepening smoothly
(and frequency-dependently) into the shadow. J(v) is a public static for
unit testing (tests/unit/TerrainObstacleLoss_1.test). mode="los" (the
default) is behavior-identical to before.

The module now extends TracingObstacleLossBase and emits
obstaclePenetrated events at the critical terrain point, so the existing
tracing obstacle loss visualizers display where links are obstructed.
Terrain has no associated physical object: the event carries nullptr and
world-frame coordinates (documented in ITracingObstacleLoss.h), and the
canvas/osg/vsg renderers now handle that with identity rotation and no
offset.

vsglidar demos: TerrainFresnel (graded loss + intersection markers),
intersection markers on TerrainOcclusion, and TerrainTwoRayInterference
(the terrain-aware TwoRayInterference over the same scene). Headless
60s/seed-1 check: TerrainTwoRay and TerrainOcclusion app-level counts
unchanged (597/279); fresnel differs from binary occlusion in 342
radio-level scalars while app counts coincide — at sharp roof edges the
graded transition shell is thin, so end-to-end ping outcomes quantize
the same way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…metry

New markerStyle parameter for the emitted obstaclePenetrated events:
- "depth" (default): vertical segment from the direct ray up to the
  terrain surface at the worst point — its length shows how deeply the
  link is buried below the skyline.
- "ray": the chord of the direct ray below the terrain surface (the
  contiguous obstructed sample run around the worst point), matching the
  look of physical-object obstacle loss models; near-grazing links in
  fresnel mode draw a one-sample tick along the ray at the pinch point.

The geometry is chosen by the emitter, so all tracing visualizers
(canvas/OSG/VSG) support both styles unchanged. Physics untouched:
same-seed 60 s TerrainFresnel/TerrainOcclusion app counts identical to
before. Demo configs show one of each style.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
mode="diffraction": extends the single knife-edge "fresnel" mode to
several ridges via the Deygout construction — charge the dominant edge
along the profile, then recurse into the sub-paths on either side of it,
summing J(v) over up to maxDiffractionEdges (default 3) edges. Behind a
row of buildings this predicts a deeper, more realistic shadow than the
single dominant edge, while still fading rather than cutting off like
"los". The dominant edge reuses the geometry the fresnel scan already
computes, so fresnel is exactly Deygout truncated to one edge.

computeDeygoutLoss is a static free-standing routine, unit-tested in
tests/unit/TerrainObstacleLoss_1.test (single-edge equivalence to J(v),
clear path, second-ridge superadditivity, edge-count cap).

Demo config TerrainDiffraction. Verified headless: los/fresnel/two-ray
regressions unchanged (597/279); diffraction differs from fresnel in 196
radio-level scalars (extra loss on multi-ridge paths) while app-level
echo counts coincide on this single-tower tile.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New createTerrainMeshFromHeightfield() + a lit terrain-mesh pipeline
(position + colour + normal, TRIANGLE_LIST, two-sided): builds an
elevation-colored, diffuse-shaded triangle surface from a Heightfield in
its own simulation coordinates, so the displayed ground is exactly the
DSM the radio models sample — no separate display transform that could
drift from the physics. Cells with no data drop the triangles that touch
them.

Wired via a new SceneVsgVisualizerBase.groundModel parameter naming a
PointCloudGround module (built by INITSTAGE_LAST, when the scene floor is
created); it takes precedence over the sceneModel point cloud. Demo
config TerrainMesh renders the vsglidar tile's physics heightfield.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The de-spike previously clamped only cells taller than EVERY neighbor by
the threshold, which left two-cell spikes (each shielded by the other) and
any spike shallower than the threshold above the local grade. Compare each
cell to its SECOND-highest neighbor instead, over a few erosion passes:
this catches isolated and two-cell spikes and shallower ones, while genuine
structures are preserved — any cell on a real surface or edge has at least
two neighbors at its height, so its second-highest neighbor is high and it
is left alone. Verified against the vsglidar tile: the real buildings
(e.g. 154 m / 146 m / the 289 m tower) are byte-identical across thresholds
0/20/50, while stray returns are pulled to the surrounding grade.

vsglidar demo drops despikeThreshold to 20 m to also clear the shallower
plain spikes; the swarm physics regressions are unchanged (the noise was
never on a link path).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…usion)

Combines the shaded ground-mesh render with binary line-of-sight blocking
and the moving swarm, so occlusion is watchable: as a drone passes behind a
building its data-link arrow to the gcs breaks and fades, then returns when
it comes back into view. TerrainMesh alone extends TerrainTwoRay (no
obstacle loss), so there the arrows draw straight through the buildings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

Open in Devin Review

Comment on lines +219 to +236
else {
double d1 = totalDistance * t;
double d2 = totalDistance - d1;
double v = (groundZ - rayZ) * std::sqrt(2 * totalDistance / (waveLength * d1 * d2));
if (v > worstV) {
worstV = v;
worstIndex = i;
worstGroundZ = groundZ;
}
}
}
double lossFactor;
if (mode == LOS)
lossFactor = blocked ? 0 : 1;
else {
double lossDb;
if (mode == DIFFRACTION)
lossDb = computeDeygoutLoss(profile, horizontalDistance / (numSamples - 1), waveLength, 0, numSamples - 1, maxDiffractionEdges);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 FRESNEL and DIFFRACTION modes use different distance metrics for the diffraction parameter

In computeObstacleLoss, the FRESNEL mode computes the diffraction parameter v using totalDistance (3D slant distance, line 220-222), while the DIFFRACTION mode's computeDeygoutLoss receives horizontalDistance / (numSamples - 1) as unitDistance (line 236). This means the same terrain profile produces slightly different v values depending on the mode, especially for paths with significant elevation difference between endpoints. The Deygout construction traditionally uses horizontal (great-circle) distance for profile-based analysis, so the DIFFRACTION mode is arguably more correct, but the inconsistency between the two graded modes could surprise users comparing their outputs on the same link.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

- Heightfield: FlatGround-equivalence check over flat terrain (constant
  elevation + (0,0,1) normal at many points, grid-aligned or not) — the
  flat-PLY == FlatGround cross-check at the layer PointCloudGround delegates
  to; plus a two-cell spike case for the second-highest-neighbor de-spike.
- TerrainObstacleLoss: a hand-derived two-edge Deygout worked example
  (profile [0,30,0,22,0], lambda 0.06 m) asserting the total equals
  J(v1)+J(v') to 1e-6 (~76 dB), with the derivation documented, and the
  one-edge cap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant