<Model3D> — glTF mesh viewer (hive spec)
Orbit a 3D mesh (glTF/glb) rendered straight from photogrammetry output — CBA's Phase-3
Model3DViewer. Plug-and-play, importable into any app, publicly showcased.
Update (operator, 2026-08-08): drone-hub carries no gating of any kind — every
reference below to middleware.ts, lib/gate.ts, GATED_PATH_PREFIXES, or a passcode
gate describes an architecture that no longer exists in this repo (kept as historical
record). This component's showcase page was always public/ungated, so nothing about its
behavior changed — the correction is that NOTHING else in this repo is gated either now.
Design discussion (RESOLVED architecture — read first):
.pHive/epics/model3d/docs/design-discussion.md — this doc documents those decisions,
it does not re-derive them.
Why (operator intent — honor this)
CBA's target pipeline (nadir passes → WebODM/OpenDroneMap → orthomosaic + DSM + point
cloud + glTF mesh) produces a mesh alongside the ortho <LayerViewer> drapes on the map.
<Model3D> is the component that renders that mesh on its own — "view an interactive 3D
model (point cloud + mesh), orbit it" from CLAUDE.md's Hammer Missions reference list.
This epic ships the glTF/mesh half of that (CBA's Phase 3); point-cloud rendering
(potree) is a later phase, not built here.
This epic also shipped a second, cross-cutting thing alongside <Model3D> itself: the
component-framework showcase site (app/(showcase)/components/) — a public,
shadcn-style page-per-component pattern, proven against <Model3D> (built fresh) and
retrofitted onto <VideoTour>/<LayerViewer> (already shipped) in the same epic. See
.pHive/CONTEXT.md's "Showcase-site pattern" entry for that as a standing convention —
this doc covers <Model3D> the component; the showcase pattern itself is documented
there since it applies to every component, not just this one.
The model — ModelDef, deliberately minimal
export interface ModelDef {
id: string;
url: string;
title: string;
/** How many raw glTF units equal one real-world meter, for the measure
* tool's distance label. Optional, absent by default (including for the
* sample duck) — see "Measure tool + controls legend" below. */
unitsPerMeter?: number;
}
export interface Model3DProps {
model: ModelDef;
/** Fired if the glTF fails to load (bad url, network error, parse error). */
onLoadError?: (message: string) => void;
className?: string;
}
That's the whole registry entry — {id, url, title}, plus one narrow, additive
exception added by the brand-theming-and-viewer-polish epic's measure tool
(unitsPerMeter?, covered below). This matches what's actually shipped in
components/Model3D/Model3D.tsx; there is no separate manifest file for P1 (a single
sample model, hardcoded inline on the showcase page — see "Sample data" below), unlike
<LayerViewer>'s PropertyLayers/<VideoTour>'s Tour, which both do have a
manifest-file convention. A single-model P1 scope didn't warrant one; if a future epic
needs to list multiple models per property, that's the point to add one.
unitsPerMeter is still not a scale/position/geo-anchoring transform field — it
doesn't move, resize, or reorient the mesh in any scene. It only feeds the measure
tool's distance-label formatting (see below). The "no scale/position/geo-anchoring on
ModelDef" rule from the original epic (next section) is about placement, and stays
intact.
Deliberately deferred: scale, position, geo-anchoring
ModelDef intentionally does not carry scale, position, or any geo-anchoring
field (lat/lon/alt). This was flagged by grill during planning and resolved explicitly
in design-discussion.md §2.3 / §6.2, and repeated as a hard do_not in
model3d-component.yaml:
Do not add scale/position/geo-anchoring fields to ModelDef in this epic — explicitly deferred to the land-overlay epic's own type, per the design discussion.
Why: a position field could mean two genuinely different things — scene-space
placement (where this mesh sits within its own free-floating <Canvas>, e.g. offsetting
multiple meshes in one scene) or geo-space anchoring (lat/lon/alt draping the mesh
onto <LayerViewer>'s map, CBA's 2.5D-drape direction). Which one a future epic actually
needs isn't decided yet — the land-overlay epic (next in the confirmed priority order
after this one, per design-discussion.md §0) is where that gets resolved, with its own
new type built for whichever semantics it turns out to need. Guessing now risks shipping
the wrong shape and having to break ModelDef's consumers to fix it. P1's viewer only
ever renders one free-floating mesh in its own scene, auto-framed by <Bounds> — there's
no placement decision to make yet that guessing on ModelDef would actually serve.
Behavior
- Mounts a
@react-three/fiber<Canvas>with a fixed default camera (position: [3, 3, 3],fov: 50) and a small three-light rig (ambient + two directional) — enough to read an arbitrary untextured or lightly-textured mesh without per-model lighting configuration. - Loads the glTF/glb at
model.urlvia drei'suseGLTFinside a<Suspense>boundary (GltfScenerendersgltf.sceneas a single<primitive>, so it works for both a single-mesh glTF and a multi-node hierarchy without assuming a shape). While the fetch/decode is in flight,<Suspense>'s fallback renders a small wireframe box placeholder (LoadingPlaceholder) inside the canvas — DOM can't render inside a<Canvas>'s r3f tree, so this has to be r3f JSX, not a spinner overlay. - Auto-frames the mesh with drei's
<Bounds fit clip observe margin={1.2}>wrapping<GltfScene>— fits the camera to the mesh's bounding box on mount (fit), pushes the camera's near/far planes so the mesh isn't clipped (clip), re-fits if the mesh's own bounds change later, e.g. a swappedmodel.url(observe), and leaves a little breathing room around the mesh rather than a tight crop (margin={1.2}). This is what makes an arbitrary glTF of unknown scale/units/origin render "reasonably framed" withoutModelDefneeding ascale/positionfield to compensate. - Orbit/zoom/pan via drei's
<OrbitControls makeDefault />.makeDefaultregisters these controls as the r3f store's active controls instance, which is what<Bounds>'s internal camera-fit animation targets throughuseThree().controls— without it,<Bounds>'s auto-fit and<OrbitControls>fight over the camera instead of cooperating on mount. - Load/parse failures are caught, not fatal to the page.
useGLTF's Suspense-throw only covers the pending-promise case, not a rejected one (bad url, 404, malformed file) —ModelErrorBoundary, a class component (error boundaries have no hook equivalent), catches that and callsonLoadError, rendering nothing rather than unmounting the whole viewer with an uncaught error.
Measure tool + controls legend
Shipped by the brand-theming-and-viewer-polish epic's model3d-measure-tool-and- legend story. This corrects README.md/app/page.tsx's pre-existing "orbit-and-
measure" copy, which had been describing an unbuilt feature since <Model3D> first
shipped — see design-discussion.md §2's research note for that history. It also
corrects this doc's own prior claim that measure was "explicitly deferred" — that was
true for the original model3d epic (P1, glTF viewer only) but is no longer true of
the component as it exists today.
- A "Measure" toggle button lives in the on-canvas controls legend (see below).
Clicking it flips
measureModeon/off; the button's own label and styling reflect the state ("Measure"vs."Measure: On", accent-filled when active). - Click-to-place two points, via raycasting against the loaded mesh.
MeasureController(a<Canvas>-level component with no visual output of its own) listens for nativepointerdown/pointerupon the canvas's own DOM element — not r3f's per-object synthetic pointer events — and only registers a placement when the gesture qualifies as a genuine click: less thanCLICK_MAX_MOVEMENT_PX(6px) of on-screen movement between down and up, completed withinCLICK_MAX_DURATION_MS(500ms). A drag (or a long press) is ignored, so measure mode can be left on without ever fighting<OrbitControls>for the same drag gesture —MeasureControllernever callsstopPropagation/preventDefault, soOrbitControls' own native listeners on that same canvas element see every drag event untouched. A qualifying click converts the pointer position to normalized device coordinates, raycasts against the loaded scene graph (reported up viaGltfScene'sonSceneReady), and reports the first hit's world-space point up to<Model3D>'s own state. - The distance line + midpoint label. Once two points are placed,
MeasureOverlayrenders a small accent-colored sphere at each point, an accent-coloredLineconnecting them (drei's<Line>), and a floating label at the segment's midpoint (drei's<Html>, a DOM portal) showing the distance. The label is deliberately "X.XX units", not a fabricated real-world unit —formatDistance()(components/Model3D/Model3D.tsx) only prints"X.XX m"if the caller'sModelDefsuppliesunitsPerMeter(a raw-glTF-units-per-real-meter scale hint); absent, as it is for the sample duck (there is no known real-world scale for it, and none in general for<Model3D>until a real photogrammetry pipeline supplies one), it stays labeled"units". This matches this repo's established honesty-about-precision convention — the same reasoningCLAUDE.md's "visual property-intelligence, not survey-grade" framing applies to<LayerViewer>'s ortho output, and the same reasoning behind every synthetic sample layer'slegend: "synthetic placeholder — not real ..."wording: never imply a precision or ground-truth the data doesn't actually have. - A grill-flagged placement constraint: measure geometry renders OUTSIDE
<Bounds>, as a<Canvas>-level sibling, never as its child.<Bounds fit clip observe margin={1.2}>recomputes the camera's fit from the bounding box of everything it wraps; placingMeasureOverlay's markers/line inside it would grow that box with every new point and trigger an unwanted camera re-frame/zoom mid-measurement — exactly the "camera doesn't unexpectedly re-frame" requirementdesign-discussion.md §3ccalls out explicitly.<Bounds>'s own wrapping<group>applies no transform of its own (verified by reading@react-three/drei'sBounds.jsdirectly, not assumed), so world-space points raycast against the mesh inside<Bounds>still line up correctly with markers rendered outside it — no coordinate conversion needed. Live-verified via Playwright: placing two points produced a distance label with the duck staying pixel-identical in position/scale (camera did not move). - Clearing a measurement — two ways, both real. Placing a third point (once two are
already placed) discards the old pair and starts a fresh measurement at the new
point (
handlePlacePoint'sprev.length >= 2 ? [point] : [...prev, point]reset). Independently, the legend's "Clear measurement" button (visible only while measure mode is on, disabled/greyed when there are no points to clear) resets the same state on demand without requiring a third click. Both call the samesetPointsreset under the hood — there is no separate "undo one point" affordance, only "start over." - The on-canvas controls legend. A small fixed-corner panel (top-right, absolutely
positioned within
<Model3D>'s own container, not a separate page element — "a little legend on the side," per the operator's own request) always lists the always-available controls (Drag to orbit,Scroll to zoom) plus the Measure toggle button, and — only while measure mode is active —Click two points to measureand the Clear button described above. Styled via this epic's design tokens (bg-surface/90,border-border,text-foreground,text-accentfromapp/globals.css's@themeblock — see.pHive/CONTEXT.md's design-token entry), with abackdrop-blurso it stays legible over an arbitrary mesh/background. The legend's outer wrapper ispointer-events-none(only the panel itself ispointer-events-auto), so it never intercepts orbit/measure clicks over the rest of the canvas.
The marker-sphere radius (markerRadius state) is computed from the loaded mesh's own
bounding-box diagonal (handleSceneReady, 1% of the diagonal, falling back to a fixed
0.02 for an empty/degenerate box) rather than hardcoded, so a marker reads sensibly
whether the glTF is duck-scale (tens of units) or a future real photogrammetry mesh at a
totally different scale.
Tech
@react-three/fiber(^9.7.0) — the WebGL/Canvas React renderer.@react-three/drei(^10.7.8) —useGLTF,<Bounds>,<OrbitControls>(both locked dependencies, unused until this epic — its first real exercise).three(^0.169.0) — unchanged; satisfies both packages' peer range.next/dynamic({ ssr: false })—<Model3D>is a heavy client-only viewer (WebGL canvas, texture/geometry decoding) like every other viewer in this stack; no server-only APIs are touched at module scope, but it must still not attempt to render on the server. Every real usage in this repo (the showcase page; the now-deleted dev-preview route) mounts it this way.- Not used by this epic despite being CBA's eventual target: potree / point-cloud
rendering (COPC/LAZ) — CBA phases that in after the glTF-mesh path is proven; nothing
in
components/Model3D/assumes or blocks it.
r3f/drei dependency correction — why the version bump
model3d-component.yaml scaffolded this epic against @react-three/fiber ^8.17.0 +
@react-three/drei ^9.114.0 (the versions locked in package.json when the epic was
planned). Both were bumped during model3d-component to @react-three/fiber ^9.7.0 +
@react-three/drei ^10.7.8. Reason, verified empirically (not assumed): @react-three/ fiber v8 bundles its own react-reconciler host config written against pre-React-19
internals — it crashes the instant <Canvas> mounts under React 19
(Cannot read properties of undefined (reading 'ReactCurrentOwner')), and this can't be
patched by pointing it at a newer react-reconciler either, because reconciler 0.31+
needs a resolveUpdatePriority host-config hook that v8's compiled renderer never
implements. No combination of react-reconciler version satisfies both React 19's
internals and v8's host config at once. @react-three/fiber v9 is pmndrs' from-scratch
React-19 rewrite (its own bundled reconciler, no external react-reconciler dependency)
and is what actually renders and accepts <OrbitControls> input under react ^19.0.0 —
confirmed via live Playwright verification, not just "it type-checks." drei v10 is the
matching major for @react-three/fiber v9 (drei versions its majors alongside fiber's).
three ^0.169.0 didn't need to move — it already satisfied both packages' peer ranges.
This correction is recorded in package.json's own _model3d_component_note field as
well as here.
.npmrc's legacy-peer-deps=true (already present from the video-tour-app-shell- scaffold story's @react-three/fiber-vs-React-19 workaround) covers drei's peer
constraints too — re-verified during this epic, not just assumed to carry over.
Sample data — public/model3d-samples/duck/model.glb
A small (~120KB) public-domain glTF from Khronos's own glTF-Sample-Models repo
(Duck.glb, CC0-equivalent license per that repo) — chosen because it's small, a real
binary glTF (not a synthetic placeholder), and exercises the exact loader path
(useGLTF → <primitive object={gltf.scene}>) a real WebODM-exported mesh will use
later. No rights issue: not property photogrammetry, not tied to any address or person.
There is no manifest file alongside it — the showcase page hardcodes the ModelDef
inline ({ id: "duck", url: "/model3d-samples/duck/model.glb", title: "Duck (sample glTF)" }), per the "no manifest file for a single-model P1 scope" decision above.
The showcase page — public, not gated
<Model3D> is demoed at /components/model3d
(app/(showcase)/components/model3d/page.tsx), part of the app/(showcase)/components/
route group — deliberately outside lib/gate.ts's GATED_PATH_PREFIXES
(["/tours", "/properties"]) and middleware.ts's config.matcher
(["/tours/:path*", "/properties/:path*"]). No passcode redirect; the page loads and
renders the sample duck directly. This is safe specifically because the sample glTF is
public-domain and carries no property/rights sensitivity — see .pHive/CONTEXT.md's
public-safe-demo-data rule for why this isn't a blanket license for every showcase page
to skip gating without its own rights check.
import { Model3D } from "@/components/Model3D";
<Model3D
model={{ id: "duck", url: "/model3d-samples/duck/model.glb", title: "Duck (sample glTF)" }}
/>
Acceptance criteria
The first block below is scoped to the original model3d epic (P1: <Model3D> on a
single sample glTF, plus the showcase-site pattern) — point-cloud rendering and
scale/position/geo-anchoring placement were, and remain, explicitly out of scope, see
Phase fit. Measure is no longer out of scope — the original P1 scoping here read
"Measure... explicitly deferred"; that was accurate when this doc was first written but
is now stale. The real, shipped measure tool + controls legend (from the
brand-theming-and-viewer-polish epic) is documented above under "Measure tool +
controls legend," with its own acceptance criteria in the second block below.
- Given a glTF/glb url, when
<Model3D>mounts, then the mesh renders visibly in the canvas, reasonably framed (not off-screen/invisibly tiny/huge). Verified:Model3D.tsxwraps<GltfScene>in drei's<Bounds fit clip observe margin={1.2}>, which auto-fits the camera to the loaded mesh's bounding box on mount and re-fits on bounds changes — not a fixed camera guessing at an arbitrary model's scale.model3d-component's Playwright pass confirmed the duck sample renders visibly and centered against the real running app (WebGL needs a real browser context; this can't be verified under jsdom — seeModel3D.test.tsx's header comment). - Given mouse drag/scroll on the canvas, when performed, then
<OrbitControls>actually rotates/zooms the view (verified live, not just that the prop is wired). Verified:<OrbitControls makeDefault />is mounted inside the same<Canvas>;makeDefaultregisters it as the r3f store's active controls instance, which is what<Bounds>'s internal camera-fit animation targets viauseThree().controls— without it the two would fight over the camera instead of cooperating.model3d-component's Playwright pass exercised drag-to-orbit and scroll-to-zoom against the real duck model and confirmed the camera actually moved (live browser verification, not a prop-wiring assertion under jsdom). - Given
next/dynamic({ ssr: false }), when<Model3D>is imported anywhere, then it's compatible with that wrapping (no server-only APIs; confirmed via a real usage in a later story). Verified:Model3D.tsx's header comment documents that no server-only APIs are touched at module scope (@react-three/fiber's<Canvas>and drei's hooks only touchwindow/WebGL once mounted). Two real usages confirm this in practice:app/(showcase)/components/model3d/page.tsx(the shipped showcase page) and the now-deletedapp/dev-preview-model3d/page.tsx(superseded by the showcase page, removed by this closeout story) both mount<Model3D>viadynamic(() => import("@/components/Model3D")..., { ssr: false }), and both build/run clean. - Given
npm run build, when run, then it passes with no regressions. Verified: cleannpm run buildre-run during this closeout story (after deletingapp/dev-preview-model3d/),/components/model3dpresent as a static route in the output. - Given the
<Model3D>showcase page visited with no passcode, when loaded, then it renders the sample glTF and is orbit-controllable, with no redirect to/enter-passcode. Verified:app/(showcase)/components/model3d/page.tsxis not under anyGATED_PATH_PREFIXESentry — confirmed by readinglib/gate.tsandmiddleware.tsdirectly (GATED_PATH_PREFIXES = ["/tours", "/properties"],config.matcher = ["/tours/:path*", "/properties/:path*"], neither matches/components/*).model3d-showcase-pages's Playwright pass loaded the page with no passcode cookie set and confirmed no redirect occurred. - Given
ModelDef, when inspected, then it carries noscale/position/ geo-anchoring transform fields. Verified:components/Model3D/Model3D.tsx'sModelDefinterface is{id: string; url: string; title: string; unitsPerMeter?: number}. The one field added since this criterion was first written (unitsPerMeter, by thebrand-theming-and-viewer-polishepic) is a measure-tool label-formatting hint, not a transform — it doesn't move, resize, or reorient the mesh in any scene. Still matches thedo_notinmodel3d-component.yamland the reasoning indesign-discussion.md§2.3, reproduced above under "Deliberately deferred."
Measure tool + controls legend — acceptance criteria
Scoped to the brand-theming-and-viewer-polish epic's model3d-measure-tool-and-legend
story. See "Measure tool + controls legend" above for the full behavior description.
- Given the Measure toggle is on, when the user clicks two distinct points on the
mesh, then a connecting line and a distance-labeled midpoint marker render, and the
label reads "X.XX units" (not a fabricated real-world unit) unless the
ModelDefsuppliesunitsPerMeter. Verified:distanceBetweenPoints/formatDistance(components/Model3D/Model3D.tsx) are pure, WebGL-independent functions with direct unit coverage inModel3D.test.tsx. Live-verified via Playwright against the real running app: toggling Measure and placing two points on the sample duck produced a distance line and a"0.50 units"label. - Given measure mode is active, when the user drags to orbit, then no measure point
is placed (orbit and measure don't conflict).
Verified:
MeasureController's click-vs-drag threshold (CLICK_MAX_MOVEMENT_PX,CLICK_MAX_DURATION_MS) gates point placement onpointerup, and it never callsstopPropagation/preventDefault, so<OrbitControls>'s own native drag listeners on the same canvas element are unaffected either way. Live-verified via Playwright (drag-to-orbit while measure mode is on does not add a point). - Given two points already placed, when a third point is clicked, then the old pair
clears and a fresh measurement starts at the new point; independently, the
"Clear measurement" button resets the same state on demand.
Verified:
handlePlacePoint'sprev.length >= 2 ? [point] : [...prev, point]reset (third-click behavior) and the legend'shandleClearMeasurement/"Clear measurement" button (disabledwhenpoints.length === 0) both call the samesetPointsreset — read directly incomponents/Model3D/Model3D.tsx. - Given a point is placed, when the camera is observed, then it does not
unexpectedly re-frame or zoom.
Verified:
MeasureOverlay's markers/line render as<Canvas>-level siblings of<Bounds fit clip observe margin={1.2}>, never as its children —<Bounds>'s own wrapping<group>applies no transform of its own (confirmed by reading@react-three/drei'sBounds.jsdirectly), so measure geometry can't grow the bounds computationobservereacts to. Live-verified via Playwright: placing two points left the duck pixel-identical in position/scale before and after. - Given the showcase page, when it loads, then an on-canvas controls legend is
visible, listing orbit/zoom and the Measure controls, themed via this epic's
design tokens.
Verified:
Model3D.tsx's legend panel (bg-surface/90,border-border,text-foreground,text-accent) renders unconditionally in the returned JSX, not gated behind any loaded/ready state. Live-verified via Playwright: the "Controls" panel with a "Measure" toggle is visible on/components/model3dby default.
Phase fit
- P1 (this epic):
<Model3D>renders a single free-floating glTF mesh, auto-framed, orbit-controllable, shown on its own public showcase page.ModelDefdeliberately minimal ({id, url, title}). - P2 (land-overlay epic, next in priority order): geo-anchored placement — a NEW type
(not a field bolted onto
ModelDef) that anchors a mesh onto<LayerViewer>'s map by lat/lon/alt, resolving the scene-space-vs-geo-spacepositionquestion this epic deliberately left open. CBA's Phase 2/2.5D drape work (MeasureTool,AnnotationLayer,CompareSwipe,AlignControl) is a separate,<LayerViewer>-side track, not owned by<Model3D>. - P3 (per CBA): point-cloud rendering (potree/COPC) as an alternative or companion data source to the glTF mesh path this epic ships.
- Later (per the operator's 2026-08-07 vision expansion,
CLAUDE.md):<Model3D>is the confirmed foundation for the 3D-on-land overlay, the Minecraft voxelizer/content engine, and telemetry-driven video overlay work queued after it.
Real data update (2806 Prado, real georeferenced-fix story)
The showcase's primary sample swapped from the Khronos duck to a REAL textured mesh:
public/model3d-samples/prado/model.glb, the operator's own 2806 Prado St nadir-grid
photogrammetry (OpenDroneMap odm_texturing_25d output). Pipeline: obj2gltf (raw OBJ →
glTF) → @gltf-transform/cli resize (textures to 512px) → a small custom axis-correction
script (rotates the raw ODM export's Z-up convention to glTF's standard Y-up — needed
because useGLTF/OrbitControls assume Y-up; without it the mesh rendered edge-on by
default, confirmed live via Playwright) → @gltf-transform/cli draco (mesh compression).
Net: ~29MB raw export → ~2MB committed asset, geometry/vertex counts unchanged
(gltf-transform inspect confirmed before/after).
Read this before assuming the default view looks "broken": the mesh is REAL, low
--pc-quality/--feature-quality ODM output from a NADIR-ONLY flight (no oblique/
side-facing passes) — solid, coherent surface data when viewed top-down, but no
reconstructed vertical walls, so a 3/4 diagonal angle (the fixed default camera at
Canvas's camera={{position:[3,3,3]}}) shows sparse, jagged fragments where you're
seeing through gaps between disconnected patches. Drag to orbit toward a top-down angle for
the real, coherent view. This is an honest characteristic of the source capture
(CLAUDE.md's own "visual property-intelligence, NOT survey-grade" framing made visible),
not a rendering bug — verified by comparing gltf-transform inspect's vertex/face counts
before and after compression (identical) and by live-orbiting the actual showcase page.
The original public/model3d-samples/duck/model.glb stays in the repo — <LandOverlay>'s
showcase still uses it (see that component's own doc for why the real mesh wasn't swapped
in there too).
v2 update — nadir + oblique orbit, real tree geometry
The "sparse, jagged fragments" characteristic described above is fixed as of v2 — not by
better processing settings (reprocessing the same nadir-only source at high quality
produced no meaningful change), but by adding a real oblique orbit clip (0023, gimbal
tilted ~45-60°, ~3 min circling the property) to the same reconstruction. A nadir-only
camera structurally cannot resolve a tree taller than the flight altitude — it only ever
sees the canopy top, never the sides — so no amount of point-cloud density fixes that. The
oblique pass gives the reconstruction real side-view data for the first time.
Practical fallout worth knowing if you're extending this pipeline: the oblique orbit swept
a much wider area than just the property (the raw reconstruction's bounding box roughly
doubled in each direction), and a handful of far outlier vertices from neighboring
properties were enough to make the actual house a speck once <Bounds> auto-framed the
whole thing. Fixed by cropping the mesh to the property's real-world bounds by material
group, in the reconstruction's own local coordinate frame (via trimesh, since
gltf-transform has no spatial-crop command) — before the resize/simplify/Draco pipeline,
not after. Final asset: ~10MB (up from v1's ~2MB — more materials, more real geometry, a
genuinely bigger reconstruction, not just recompressed).
Two other real bugs were found and fixed getting to v2, unrelated to the mesh itself: a Docker Desktop default-memory-allocation limit causing intermittent segfaults in OpenDroneMap's dense-reconstruction step under x86-on-ARM64 emulation, and an ODM dataset-cache footgun (partially clearing cache files between reruns breaks the dataset stage rather than triggering a clean rescan — wipe the whole project directory instead).