Every point the build spec marked [DECISION], plus the deviations from the spec that this build made deliberately. Each entry states what was decided and why, so a later maintainer can overturn it on the merits rather than guessing.
Decision. src/core/readable.js wraps the five format* functions of
units.js. In SI they are units.js: same string, same grouping, same
honoring of precision. In English they round to three significant figures,
with a floor of one whole inch on lengths. The interface imports the wrappers;
the annotation engine keeps importing units.js, so a figure’s dimension
labels stay the reader’s own precision setting, and every data export stays
canonical.
Why. Every stored value is millimeters, kilograms, kilonewtons, kilopascals. SI can therefore show the cited number and English cannot: a dimension recorded in metric has no exact inch. Printing two decimals of one anyway made a 1854 mm track read 72.99 in, which claims a hundredth of an inch the data does not have and is also harder to read than 73.
Why three. Because three is what recovers the SOURCE’s own magnitude wherever the citation was itself converted from English — which in this library is most of it. 4572 mm comes back as 180 in (15 ft), 2591 mm as 102 in (the 23 CFR 658.15 width limit), 1372 mm as 54 in, 279 mm as 11 in, 1054 mm as 41.5 in, 44.5 kN as 10 kip, 13 608 kg as 30 000 lb. Swept over every length, load and weight the shipped library cites, nothing round in English is lost and the worst distortion is 0.4%. Both halves of that are re-measured in test/run.mjs §15, so a library that grows a dimension this rule would mangle fails there rather than in somebody’s report.
Where the citation really is metric the reading stays honest rather than tidy: a 300 mm tire section reads 11.8 in, not the data book’s nominal 12. Recovering the 12 would mean trusting the designation, and a designation is not a dimension — the 9.00 in a 9.00R20 is a series number, and that tire’s section is 10.2 in.
Why the floor. Length is the only family here that reaches four digits. A 75-ft double is 1124 in, and three figures alone would show it as 1120.
What else it forced. Five readouts turned out never to have been reached
by the unit switch at all, and were printing millimeters whatever the toolbar
said: the hover coordinates, the contact-patch tooltip, both structure-tree
tags and three lines of the wide-base report. The title block’s Units cell was
fixed text reading mm · kN · kPa. And the switch never re-lit itself, so a
session restored in English came back with every panel in inches and SI still
showing.
So the switch became setUnitSystem(), whose refresh list is now the
contract: every function that formats through UNIT_SYSTEMS is on it or is
exempt with a stated reason, and §15 asserts that. Two members of the list
exist only because of it. renderTree joined when its tags stopped being
hardcoded — and its tags are deliberately NOT rounded to the reader’s
dimension precision, because a tag sits directly above the properties panel
showing the same axle’s track in an editable field, and a tag reading 79 in
over a field reading 78.5 has no explanation on screen. (A panel differing
from the FIGURE does: there is a Precision control.) And
renderWideBaseReport was split out of applyWideBaseSwap because that panel
is a RECORD of one swap rather than a view of the document: it must not be
recomputed, but it does have to be re-printable, and writing it straight into
the DOM at click time made it neither.
One trap worth naming. plainLength exists because formatNumber groups
thousands with U+202F, which is not a valid value for an
<input type="number"> and silently blanks the field.
Decision. rim.js exports wheelStations(), and it is the only place that
answers where any part of a wheel sits. hub.js and tire.js both read it
rather than deriving the same figures from parameters of their own.
Why. Three defects, all of them invisible in code review and all of them
the same defect. The disc’s axial station was computed in rim.js from
offsetRatio and in hub.js from the boss length, which is not a function of
it; the two disagreed by 24 to 74 mm on every tire in the library and all ten
lug nuts were drawn inside the disc, where nothing could ever see them. The
hub boss was sized at 0.28 of the rim radius against a bore of 0.30, so the
part whose stated job is to close the bore left a 5.7 mm ring of daylight into
the barrel. And the tire’s bead was placed at the barrel’s half-width — a
number that has nothing to do with where a bead seats — so the two surfaces
interpenetrated into a ring of alternating rubber-and-rim teeth around every
wheel in every view.
What else it forced. The rim’s two bead seats had been at different
distances from their own flanges (a flange-height in on the inboard side, 0.255
of the width on the outboard). A tire is symmetric by construction — its
meridian is mirrored from a half, which is the guarantee tire.js is built on
— so no tire could seat on both. The seats are now symmetric and only the
drop-center well is offset, which is what a real rim does.
Rejected: fixing it in the assembly. Passing the same offsetRatio to both
builders removes today’s disagreement and nothing else; the next parameter one
of them invents is the next bug of this shape. A published table is what makes
the class of error impossible.
Decision. The meridian is a centripetal Catmull-Rom, subdivided adaptively until the polyline is within a sagitta tolerance of the curve, no chord is longer than a limit, and no vertex turns through more than 7 degrees.
Why centripetal. Uniform parameterization overshoots between control points that are unevenly spaced, and the meridian’s are deliberately uneven — the step from maximum section width to the top of the sidewall is 1.2% of the section half-width while the one across the crown is 60% of it. The carcass came out 0.6 to 1.1 mm wider than its section width on every tire in the library, and the true maximum sat 11 mm below the station the profile puts it at. Section width is a published dimension that the dimension engine draws and the footprint export writes out; a tire quietly a millimeter too wide is not cosmetic. The overshoot is now exactly zero, which the test asserts.
Why the maximum-width control point’s neighbors share an axial station. Centripetal parameterization alone did not remove the overshoot, because the cause was upstream of it: a Catmull-Rom tangent is proportional to the chord between a point’s neighbors, and while those were at 0.930 and 0.988 of the half-width the tangent at the widest point still had a positive axial component. The curve was heading outboard as it passed the widest control point and had to overshoot before turning back. Equal stations make that tangent purely radial. The asymmetry a real carcass has near its maximum is carried by the radii instead, which is where a maximum’s asymmetry belongs.
Why adaptive, and why an explicit angle. A fixed budget divided equally between spans gave the short, tightly-curved shoulder the same four points as the long, nearly flat crown. Vertex normals are averaged from the faces meeting at each row, so the resulting 31-degree facets became a terraced shading band around each sidewall — a stack of washers rather than one carcass, and the loudest artifact on a close render. A sagitta tolerance alone still left 13-degree creases at the draft profile detail, and it also has to be checked at the SPAN JOINTS: the curve is only tangent-continuous there, so two chords meeting at a knot are as collinear as they are short. Bounding the angle directly bounds the artifact. Worst case across the library is now under 10 degrees at every quality.
What it costs. Row counts rise by about two thirds — 81 to 103 at draft. 34 tires at draft is 0.78 M triangles against a 1.2 M ceiling the test holds.
Decision. The tire’s SIDEWALL, the rim barrel and the rim disc are drawn double-sided. The tread is not.
Why. Every one of them is a single-thickness pressing or casting that the viewer genuinely sees from both faces, and back-face culling made each of them vanish from the side it was supposed to be seen from. The wheel’s hand holes look through the wheel at the far sidewall from inside; culled to the front, that is a line of sight out of the back of the wheel, and the holes rendered as five white discs on a black tire on every wheel seen near end-on. The barrel’s inside is what you expect to find through those same holes. The disc’s dish is what closes the wheel when the near wheel of a dual pair is seen from inboard.
Why not the tread. It is the larger half of the tire’s mesh, it is closed by the sidewalls at both edges, and nothing ever sees its back. Culling is free performance wherever it is also correct.
What it replaced. The hand holes were briefly pulled in to 0.660 of the rim radius so that a much-enlarged brake drum could stand behind every one of them. That removed the white discs and was the wrong cause: on a real wheel the holes DO reach past the drum, and what stops the light is the inside of the tire. The ratios are back at the real proportions and the drum is a 16.5 in drum in a 22.5 in wheel, which is what it should always have been — at the old 0.48 it was a small cylinder lost inside the wheel.
Decision. assembly.setWheelFilter announces every isolation change, and
the viewport refits the lighting rig — and therefore the shadow camera and the
shadow catcher — to assembly.visibleBounds().
Why. A shadow map is a fixed number of texels spread over the shadow camera’s frustum, so its resolution on the ground is set entirely by how large that frustum is. Fitted once to the whole unit, an isolated axle of a class 9 was shaded by a map covering 27 m of pavement — roughly 13 mm per texel, with a PCF radius of several texels on top, which is a penumbra wider than the tread casting it. Every shadow in every isolated view was a soft gray smear. Fitting to the visible extent is a four- to tenfold gain in exactly the views where the shadow is being looked at, and costs one Box3 per isolation change.
The shadow catcher had to move too. It was left at the world origin, which was invisibly fine only because the rig was vehicle-sized and so was the plane. Fitted to an isolated rear axle five meters down a semitrailer, a three-meter plane at the origin catches nothing, and the axle rendered with no shadow at all. It is centered on the fit.
What is NOT refitted. The grid and the camera. The grid is a scale reference and must not resize as parts are hidden, and re-framing the camera on every isolation change would take the view out from under the reader.
Decision. chooseOffsetDirection still ranks the four candidate directions
by projected length, but multiplies that by three terms: the offset must take
the line further from the unit (away, supplied per dimension by
autoDimensions, which is the only place that knows where the unit is), must
not put it below the pavement, and must not put it on the FAR side of the unit.
Why. Projected length asks whether an offset is legible and says nothing about whether it lands anywhere sensible. On a class 9 the axle spacings were pushed 420 mm inboard of the left tires — the direction the comment above them says they must avoid — and the tandem spacing was stood at hub height and printed across the tandem it was measuring.
Why the far-side term is needed at all. The annotation layer is SVG over the canvas and has no depth buffer, so a dimension standing off the far side of the unit is drawn on top of the unit rather than behind it. Correcting the offset direction without it moved the line outboard and left the label exactly where it was.
Why away is a field and not a computation. The chooser sees one
dimension, not a vehicle. The vertical center of a running gear is a tire
radius up, not on the ground, and nothing local to a dimension can know that —
taking the ground as the center is what made “up” look like an escape.
Also fixed here. Spacings are drawn between distinct longitudinal STATIONS rather than between axles. On a truck the two are the same; on an aircraft a main gear is a left and a right axle at one station, so walking the axle list drew a zero-length dimension between them and then drew the nose-to-main distance twice — once as a spacing and once as the outer bridge. Where the aircraft wheelbase coincides with a spacing, the named quantity wins and the anonymous one steps aside.
Decision. The viewport renders into a buffer far larger than itself — 3840 pixels wide at the default Ultra tier — and drops to a 1.25x buffer for the duration of any pointer or wheel interaction, restoring the full frame 220 ms after the last input.
Why supersample at all. min(devicePixelRatio, 2) sounds like a cap and is
in fact a floor: on an ordinary 1x monitor it evaluates to one, so the
viewport was being rasterized at its CSS size, around 0.7 megapixels. MSAA
antialiases geometry edges and does nothing for specular shimmer on a rim lip
or for the sub-pixel detail in a tread groove. The export path has supersampled
2x since v1.1 for exactly this reason; the live view simply never did.
Why adaptive rather than always-on. A 10 MP frame costs about 7 ms here, which is nothing for an image that is going to sit on screen and far too much to pay 60 times a second during an orbit drag. Dropping for the interaction and restoring on settle puts the whole cost on the still frame — the one anybody actually looks at.
Why the tier also sets a geometry floor. Raising either resolution or segment count alone is wasted. A 4K buffer does not hide a 112-segment silhouette, it resolves the faceting more clearly than 1x ever could; and 352 segments behind a 1x buffer are never seen. One control moves both, plus the shadow map, whose radius is rescaled with it so the softness the user chose looks the same at every tier.
Why the number is printed. The ratio depends on the viewport’s CSS width, the display’s own pixel ratio and what the GL context will allocate, so “Ultra” is not the same number of pixels on two machines. This app is a measurement instrument; a reader who asked for UHD should be able to read back what they got rather than take it on trust. It appears in the Rendering panel and in the status strip.
The shadow map is sized from the BUFFER, not the tier. A tier names an aspiration; the ratio cap decides what is actually allocated. On a phone Ultra lands at roughly a 1400-pixel buffer, and handing that the tier’s nominal 4096 map spends ~67 MB of VRAM to shade 2.7 megapixels — a plausible way to lose the context on a mid-range mobile GPU. The map is the NEAREST power of two to the buffer’s long edge, bounded by the tier. Nearest rather than next-up because ceiling jumps at 2049, which would hand a 2376-pixel tablet buffer a 4096 map for the sake of 328 pixels. This is device-appropriate without sniffing the device, which is the only kind of device adaptation worth writing.
Where. RENDER_TIERS, targetRatio(), markInteracting(),
_syncShadowMap() and renderResolution() in src/scene/renderer.js;
setShadowMapSize() in lighting.js; the minLevel argument to
pickQuality().
Decision. The render loop decides whether the camera moved by comparing
position, orientation, target and zoom against the previous frame. Nothing is
hung off cameras.onChange.
Why. OrbitControls.update() returns true on every frame when damping
is enabled, even on a camera that has not moved at all: its settle test compares
quaternions against a 1e-6 epsilon, and the numerical jitter from calling
lookAt() each update sits right on that threshold. The loop took that return
value as “the camera moved”, so an idle Gear3D had been redrawing at 60 fps
since v1.0 — a renderer documented as on-demand, and advertised as such in its
own header comment, that had never once been on demand.
How it surfaced. It did not surface on its own; it was invisible except as battery drain. It only became a defect when v1.10 hung the resolution drop off the same signal, at which point the viewport never settled and never rendered above the interactive ratio. A bug that costs only power is easy to carry for eight releases; the lesson is that “is this event telling me what I think it is” is worth asking of a library callback before building on it.
Result. Idle is now zero renders per second, measured. Interaction is
detected instead from pointerdown, pointermove with a button held, and
wheel on the canvas, which is unambiguous in a way the change event is not.
Where. _cameraMoved() and the start() loop in src/scene/renderer.js.
Decision. src/core/gearcode.js parses FAA Order 5300.7 names from the
convention’s own rules. GEAR_CODES in schema.js is generated from it, and a
designation is validated by parsing rather than by membership of a list.
Why. The convention is explicitly open-ended. Figure 2’s caption says
increase numeric value for additional tandem axles, so 4S, 9Q and 2D/4D3
are all legal names, and a table can only ever contain the ones someone thought
to type. The hand-maintained table this replaced held twelve codes and had
already drifted out of the Order’s vocabulary — it described 2D as “Dual
wheel, tandem”, which is the phrasing §6d retired when it made T mean triple
instead of tandem. A third copy of the same list sat in
src/data/aircraft/index.json. Three copies of a table is three places for it
to be wrong; a parser is one place, and it answers questions no table can.
What this buys, concretely. Table 3 publishes a wheel count for eighteen configurations. The parser derives all eighteen from the names alone, which is a real test of whether the grammar was understood — and it caught the two rules that are easiest to invert:
2D/D1 twelve
wheels instead of ten.2D/D is therefore refused rather than
read as a synonym for 2D/D1, which it is not.The parser throws rather than returning a partial result. A half-understood gear name is worse than a refusal, because every wheel position downstream would be derived from the half that was guessed.
Where. src/core/gearcode.js; GEAR_CODES and isValidGearCode() in
src/core/schema.js; group 12b of the test suite.
Decision. The sixteen FAA Order 5300.7 configurations ship as normal
aircraft units — they validate, resolve and export as aircraft — but carry
kind: "schematic", a separate Domain in the picker, an amber panel notice and
a Schematic flag in the title block that outranks both other flags.
Why not a fourth domain. domain is truck | aircraft throughout the
schema, the layout engine and the exporters. A third value would have meant a
third branch in every one of them to express something that is not true: these
are aircraft gear. The separation belongs in the picker, which is where the
confusion would otherwise happen, and nowhere else.
Why the flag outranks the others. Whether a drawing was edited away from its reference, or how many of its values were assumed, is a second-order question next to whether it is a drawing at all. A reader who takes one flag off a figure should take that one.
The sourcing runs opposite to the real aircraft, which is why no schematic
states an outer width. On a 737 the FAA’s published main gear outer width is
the datum and the track is derived from it (D14). Here the track is the
measured quantity — FAARFIELD publishes per-wheel coordinates — and the outer
width would fall out of a nominal tire, because no consulted source gives a
Tire and Rim Association designation for these aircraft. Publishing one would
dress a placeholder up as a datum, so mainGearOuterWidth is null on all
sixteen and the validator’s closure check correctly skips them. A test asserts
that no schematic ever states one.
Two honest nulls fell out of this, and both are corroborated rather than
tolerated. The B-52 has no nose gear at all — Figure 18 ignores the wingtip
outriggers — so it has no wheelbase, the quantity being measured from a nose
gear that does not exist, and no percentOnMainGear, that figure existing to
split load between nose and main gear. The tests that used to demand both now
demand that a unit stating null genuinely has no nose gear, and that the null
propagates: no tire may carry a load derived from a split that was never
stated. That is strictly stronger than what they asserted before.
Where. src/data/aircraft/faa-5300-7.json; renderTitleBlock(),
renderAssumptionNotice() and poolFor() in main.js.
Decision. The wing-to-body gear longitudinal offset on the DC-10-30 and A340-600 schematics is taken from the FAARFIELD 2.1.1 library, with the body gear aft of the wing gear.
Why this needed proving. FAARFIELD stores an aircraft’s belly gear as a separate library entry, and it is not obvious that the two share a longitudinal origin. They visibly do not always: the DC-10-30’s wing bogie sits at Y 762 mm while the KC-10’s — essentially the same gear — sits at 0. This build initially read the offset as putting the body gear forward, which is the wrong side, and would have put a body bogie ahead of the wing gear on two aircraft.
What settles it. The 747-400. Its FAARFIELD wing gear sits at Y 3073.4 mm,
and Boeing’s own ACAP §7.2.1 footprint figure — read separately, for
boeing.json, from a wholly independent document — puts the 747 body gear
3073 mm aft of the wing gear. Agreement to 0.4 mm fixes both the datum
(shared, origin at the body gear) and the sign (positive Y forward) at once.
The direction then also agrees with the A380, with Figures 10–12 and 16, and
with the 747 entry already in this library.
The general lesson. A cross-check is only worth something when the two sides are independent. This one is worth something precisely because the ACAP figure was transcribed for a different aircraft, for a different file, before this question was asked.
Where. faa-5300-7.json, the WLG-L and BLG source strings on
faa-2d-d1 and faa-2d-2d1.
Decision. Dimensions follow ISO 129-1, not ANSI Y14.5.
Why. The audience is international pavement and airfield engineering, where ISO is the prevailing drafting convention, and the “value above an unbroken line” form survives rescaling better — an ANSI broken line with a value in the gap degrades badly when a typesetter shrinks a figure.
Where. src/annotate/dimensions.js, drawDimension().
Spec asked for Vite, with three.js bundled so the app is reproducible offline.
Decision. Ship as plain ES modules served directly, with three.js pinned at
0.160.1 through an import map — matching Cross-Section Studio and LEAPS.
Why. The site is GitHub Pages + Jekyll with no build step. Introducing Vite
would mean either committing a dist/ directory (so the repository stops being
the source of truth) or adding a CI build (so a one-file content fix stops
being a one-file content fix). Neither is worth it for an app whose entire
dependency list is one library. Native ESM works in every browser this app
targets, and the module graph is small enough that request count is not a real
cost.
Cost, stated plainly. Offline reproducibility is weaker: the app needs the
CDN on first load. Mitigations: the version is pinned exactly, so the bytes
cannot drift under us; and vendoring is a two-line change (drop
three.module.js into vendor/ and repoint the import map) if the tradeoff
ever stops being acceptable.
ui/ treeSpec asked for vanilla ES modules — agreed, no argument needed — and a
ui/ directory of panels/, tree.js, inputs.js, shell.js.
Decision. No framework. The UI lives in a single main.js controller.
Why. The state is one document tree plus a handful of view flags, which
src/core/store.js covers in about 150 lines. Splitting the controller across
six files would spread one flow of control over six places without making any
of them independently testable, because none of them can be tested without a
DOM anyway. The logic that genuinely benefits from isolation — coordinates,
units, tire parsing, layout resolution, the bridge formula, contact models,
projection and decluttering — already lives under src/core, src/contact and
src/annotate, none of which import three.js or touch the DOM. That is where
the test suite gets its purchase.
Other consolidations, same reasoning:
| Spec file | Where it lives now |
|---|---|
views/modes.js |
scene/cameras.js (view modes are camera state) |
annotate/declutter.js |
annotate/projection.js (pure, tested together) |
annotate/callouts.js, annotate/scalebar.js |
annotate/dimensions.js |
io/tiledRender.js |
io/exportRaster.js (the tiled path is the fallback branch of one function) |
geometry/chassis.js |
not built — see D9 |
Decision. HUANG_K = 0.24 + 0.09π = 0.5227433…, with the literature’s
0.5227 kept alongside as HUANG_K_PUBLISHED for captions.
Why. The shape is a 0.4L × 0.6L rectangle plus two semicircles of radius 0.3L. That integrates to exactly 0.24 + 0.09π. The universally quoted 0.5227 is that number rounded to four decimals. Using the rounded constant makes the reported area disagree with the drawn outline by about 1 part in 12 000 — small, but exactly the kind of quiet inconsistency a figure caption should never carry. The test suite asserts that the reported area equals the area of the shape’s own parts.
Note on two sources that are wrong. The build spec renders the formula as
A = 0.5227 L² + 0.4227 L², which is not an equation. A widely-circulated web
summary renders it as π(0.3L)² + (0.4L)(0.3L), which sums to 0.4027 L², not
0.5227 L². Neither reproduces the canonical coefficient; the form implemented
here does.
Decision. Kept the spec’s axes exactly: the Front view looks along −x, with +y to screen-right and +z up.
Consequence, stated so nobody thinks it is a bug. Because +x is positive rearward, looking along −x means looking in the direction of travel — that is, standing behind the vehicle looking forward. That is what makes the vehicle’s right-hand side (+y) appear on the right of the screen. A true front elevation would mirror the image left-to-right.
Why keep it. The transverse dimension set (track width, dual spacing, overall width) is symmetric, so the choice does not affect any measurement, and the spec’s axes are what the rest of the pipeline was specified against. Changing the handedness to get a “true” front elevation would put +y on the left, which reads wrong in a plan/front pair.
Related. The Plan view’s up-axis was corrected during the build. The spec
implied +y downward; a right-handed camera basis with +x to screen-right forces
+y to screen-up. The test locked views form right-handed screen bases
pins this.
Full rationale and the self-critique are in DESIGN.md. In summary: six
tokens, a system-font stack with tabular figures as a functional requirement,
and one signature element (the datum tick on panel headings, which reuses
the extension-line mark the dimension engine draws in the viewport). The
instrument-red signal color is reserved exclusively for the live measurement
and the current selection.
Decision. Where a truck’s longitudinal layout was not fixed by a cited document, the trailer group position is set to the smallest realistic spread that satisfies 23 CFR 658.17 over every consecutive-axle subset at the vehicle’s stated legal loads — not just the outer bridge, which is rarely the binding one.
Why. It replaces “a plausible-looking number” with a reproducible
derivation that a reader can check. test/run.mjs re-derives the check
independently and fails if any subset violates it, so the data and the stated
basis cannot drift apart.
Margin. Positions carry deliberate margin rather than sitting exactly on the limit. The class 9 trailer tandem was moved from 16 062 mm to 16 200 mm for this reason: at the original position the binding A2–A5 subset cleared the formula by −0.4 lb, which is compliant in arithmetic and fragile in practice.
Decision. Units carry federalBridgeFormula: "compliant" | "permit" |
"exempt". The class 13 turnpike double declares permit; the transit bus
declares exempt. The test suite checks compliance only for units claiming it,
and separately asserts that the turnpike double genuinely fails the federal
formula.
Why. A 129 000 lb turnpike double does not satisfy the bridge formula — that is precisely why it runs under state turnpike permits. Silently labeling it compliant, or quietly excluding it from the check, would have been the easy lie. The negative assertion means that if someone later “fixes” its loads or geometry to make it pass, the suite fails and asks why.
The spec’s own build order says ship M0–M6 as v1.0 with aircraft (M8) to
follow. This build honors that, and the data model, schema, layout
resolver, dimension sets and renderer all handle the aircraft domain today —
dropping src/data/aircraft/*.json in is picked up with no code changes.
Why the aircraft data specifically was not shipped. The spec is explicit that FAARFIELD-consistent values win because the audience will compare against FAARFIELD, and any mismatch destroys trust. During this build the FAA Aircraft Characteristics Database returned HTTP 403 and every manufacturer ACAP PDF exceeded the fetch size limit, so no aircraft gear geometry could be verified against a primary source. Shipping plausible-looking gear spacings with confident citations would have been worse than shipping nothing: it would have been the exact failure the spec warns about, wearing the costume of provenance.
Also not built: chassis silhouettes (the unit isolation level currently
renders the same as running-gear), the 2×2 quad view (the gear matrix export
covers the comparison use case), and glTF/OBJ scene export.
windowwindow.gear3d exposes the live app state.
Why. The app’s whole claim is that its numbers are real. Being able to read the resolved layout, the patch table and the camera state straight out of the console is how a skeptical user checks that without having to trust the UI. It carries no secrets and enables nothing that is not already in the project file.
Decision. In quad view, plan, side and front are fitted to a single shared scale — the largest any of them needs. 3D keeps its own.
Why. Fitted independently each pane is correctly framed and the sheet is useless. A 22 m truck’s front elevation comes out about seven times larger than its side elevation, so nothing can be compared between panes by eye and a dimension cannot be carried down from the plan to the side beneath it — which is the entire reason those two views are placed in one column. Drafting practice puts the orthographic views of a drawing at one scale; a check sheet that does not is decoration.
3D is excluded because it is a pictorial reference rather than an elevation: nothing is measured off it, so matching its scale to the others would only waste pane area.
Found on the way. The half-height was a single value fitted for whichever
mode happened to be active when fit() last ran, so even in single view
switching Plan → Front framed the front elevation using the plan’s extents.
It is now stored per mode and every mode is fitted at once.
Tiled export offsets ONE projection matrix. Quad view has four, each confined to a scissor rect, and a frame-level offset does not map onto them. Asked for a quad export above the GPU’s single-pass limit the app therefore raises a message naming the limit and the ways round it, rather than emitting a sheet that is quietly wrong. Single-view tiling is untouched.
--g3-muted shipped from v1.0 to v1.7 at 2.72:1 against the light theme’s
panel. WCAG AA wants 4.5:1 for text that size. It failed on every background
in that theme — 2.50:1 on a raised surface, 2.32:1 on an inset — and it colors
55 elements: the title-block labels, every definition term in the unit stats,
the tree’s tags, the panel notes. The dark theme was marginal at 4.06:1 rather
than badly wrong, which is probably why it survived: the app is usually looked
at in dark mode.
Both are raised. Light muted goes to #5a6774 (4.56:1 at worst, on --g3-inset,
which is the darkest surface it is drawn on — not the panel, which is the one
you would naively check). Dark goes to #7d8f9d. All 16 uses of the token are
color:, so nothing decorative moved.
Raising muted alone would have flattened the hierarchy. Light graphite sat
at 5.05:1 and muted would have landed at 5.34:1 — the “secondary” tier would
have out-weighted the primary one. Graphite is darkened to #465564 (7.06:1)
to keep the two tiers a clear step apart, and a test asserts that gap stays.
Gear3D’s teal 3D sits on .g3-tb-mark, the inset tile,
which is darker than the panel behind it — 2.66:1 by the panel’s reckoning,
and the first correction computed against the panel still left it at 3.89:1.
It is #107271 now, 4.51:1 against the tile it is actually on. WCAG exempts
logotypes, so this is legibility rather than compliance: at 2.27:1 the 3D
faded next to Gear and read as a rendering fault rather than as branding.<b> was
missed: --g3-datum-hover manages 3.88:1 on white in the light theme and
2.88:1 in the dark one, because it is tuned for panels. There is now a
--g3-fig-datum (#138483, 4.51:1) alongside the other figure tokens.A token is one edit from regressing and the regression is invisible to every
other check in the suite — and to the eye, which is exactly the problem: 2.72:1
does not look broken, it looks slightly soft. test/run.mjs now parses
styles.css, extracts each theme’s custom properties, and asserts every text
token against every surface it is drawn on, plus the figure tokens against the
figure. Verified by reverting the old value: the suite fails with
--g3-muted (#8b98a5) on --g3-surface (#f4f6f8) is 2.72:1, needs 4.5.
Measured across the whole interface afterward: 55 failures to 0, in both themes.
The tree is correctly marked up — role="tree", role="treeitem" — but every
node carried tabIndex = 0. A 5-axle truck put 19 tab stops in that panel and
an A380 puts 28, all of which a keyboard user had to walk through to reach
anything after the tree. The ARIA practice for a tree is the opposite: the
whole tree is a single tab stop and the arrow keys move within it.
So: roving tabindex. Exactly one node is tabbable — the selected row if there is one, otherwise the first — and Up/Down/Home/End move focus and the tab stop together. The handler is bound to the container, not to each node, because the tree is rebuilt wholesale on every selection change and per-node handlers would be re-attached each time.
Nodes also now carry aria-selected. Selection had been communicated by color
alone, so a screen reader user could move through the tree without ever being
told which row was current.
The viewport shows publication white in both themes. That is deliberate and it
is not going to change: the annotation halo in annotate/dimensions.js is
drawn in the figure’s background color so that a figure exported on white
while the app runs dark does not get a dark halo eating its own text. The
viewport is a preview of the figure, so the figure’s background is what it
shows.
The consequence had not been followed through. The HUD, the axis badge and the progress overlay were styled from the theme’s tokens, so in the dark theme they became near-black pills sitting on white paper — blocks with more visual weight than the drawing they annotate, which is precisely backward.
So .g3-viewport now declares --g3-fig-paper, --g3-fig-ink, --g3-fig-rule
and --g3-fig-muted, and everything drawn over the plate takes its color from
those. The rule is: if it sits on the figure, it follows the figure; if it
sits on the interface, it follows the theme. The halo already worked this
way; the rest of the chrome now does too.
The white plate in a dark interface is then handled as a composition rather than apologized for. It gets an edge, a mat and a lift, so it reads as drafting film lying on a desk instead of a hole punched through the UI.
Never add padding to .g3-viewport. The SVG annotation overlay is
inset: 0, which resolves against the element’s PADDING box, while the canvas
fills its CONTENT box. Padding would leave the two boxes different sizes, and
every dimension line would be drawn a few pixels off the geometry it measures.
That failure looks exactly like a projection bug — it would be hunted for in
the math, not in the stylesheet. The mat is therefore a box-shadow ring,
which does not affect layout. There is a comment saying so at the rule itself.
Labels are rotated to lie along their own dimension line, which is ISO 129-1
and correct. declutter() and overlaps() in annotate/projection.js are
axis-aligned box tests, and they were being handed the unrotated glyph box.
A 70 x 12 label at 45 degrees occupies a 58 x 58 square. Tested as 70 x 12 it was treated as a thin horizontal sliver, so it could overlap almost anything it was not parallel to — and a three-quarter view is nothing but non-parallel dimension lines. Values sat on top of each other and on the tires, in the one part of the app whose whole job is to be read.
rotatedBox(w, h, angle) now supplies the true axis-aligned footprint, and the
declutter pass is given that instead. Measured in the browser afterward, a
five-dimension truck goes from labels overlapping at the default camera to zero
overlapping pairs at four different orbits, and the 22-tire A380 is clean too.
Two things worth noting about the fix:
declutter separates two boxes would have passed throughout.projection.js is pure precisely so this is testable under Node, and it had
no coverage at all. It does now, including the case that distinguishes the
two behaviors: two labels 20 px apart that do not overlap as flat boxes
and do overlap once rotated.The 747-400, 747-8 and A380-800 were deferred from v1.2 to v1.5 with a reason that stayed accurate the whole time: a single main-gear outer width closes a two-strut layout, but it cannot close a four-bogie one. The body gear’s offset from the wing gear is a free parameter, and putting a number on it without a source would have been inventing geometry — the one thing this library exists not to do.
What changed is not the modeling. It is that the number was found. It is stated outright in the manufacturers’ own airport planning figures (Boeing ACAP §7.2, Airbus AC §7-2-0), which publish the track, both gear positions and every spacing. Neither the FAA database nor FAARFIELD carries it: FAARFIELD analyzes one gear at a time and stores the wing gear and body gear as separate entries, so it has the bogie geometry but not the distance between the two. Three releases of “deferred” were three releases of not having looked in the right document.
So these three are sourced the other way round from D-, 2D- and 3D-gear aircraft. There, the outer width is authoritative and the track is derived from it (the trap in SOURCES.md §5.2). Here, the track is published and the outer width becomes an independent check — which closes to 0.2, 5 and 5 mm, the residuals being the figures’ own rounding to the nearest inch or 0.01 ft.
Two sourcing methods in one library is a cost, and it is worth paying. The
derivation exists because the track is unpublished for the original four; where
a manufacturer states the track directly, deriving it from a coarser number
instead would be throwing away the better datum to preserve a uniform method.
Both index.json and boeing.json say which method applies to which aircraft,
because a reader comparing two entries would otherwise have no way to tell.
The corroboration is what makes this trustworthy. FAARFIELD 2.1.1 stores explicit per-wheel coordinates, derived from neither source used here, and it reproduces every published spacing to the millimeter — including the A380 body bogie’s 20 mm wider middle axle (±764.54 and ±774.70 mm against the figure’s 1530 and 1550). Its per-strut load percentages independently reproduce the 95 % on the main gear from a third direction.
Adding an aircraft whose bogies are not all alike found two latent bugs. Both had been correct on every aircraft in the library, which is what makes this class of bug expensive:
mains[0]. Outer width is measured
at the outermost tires, so the dual spacing and tire that close the sum are
the outermost strut’s. On the A380 the body bogie is the wider one (1530
against 1350 mm), so taking the first-listed main gear would have misjudged
the width by 180 mm — while looking entirely reasonable.A test now asserts the A380 case specifically, including that the tire-weighted centroid differs from the plain mean — otherwise a regression to the simpler formula would pass everything.
Its body bogie is a dual-tridem whose middle axle is 20 mm wider than the outer
two. That is 1.3 % on a 5.3 m track and invisible on screen, and it is
published and confirmed by two sources, so it is carried in dualSpacingByRow
rather than averaged. The app exports footprint coordinates for FEM
pre-processing; a dimension that is real, known and quietly rounded away is
precisely the kind of thing that has no business being silently wrong in a file
someone meshes.
Two places, both recorded in the data files:
The FAA database remains authoritative for the four two-strut aircraft, where its outer width is the only published constraint on the track. It is used as a cross-check, not an input, wherever the manufacturer figure is more specific.
glTF’s stated convention is meters, Y-up. The export deliberately does not follow it: it writes millimeters in the engineering frame — x longitudinal positive rearward, y transverse positive right, z vertical positive up.
Both departures are the same decision. This app already emits
footprint.csv, the Abaqus patch table and every printed dimension in
millimeters in that frame. If the .glb alone came out in meters in the
render frame, the two files describing the same truck would disagree with
each other by a factor of a thousand and by a rotation. Someone would open
them side by side in a pre-processor, and the failure mode is not a visible
error — it is a mesh that looks plausible and is wrong.
The trade is real and it is not close. Following the convention costs a
correctness trap that is expensive to notice; breaking it costs a viewer
showing a 22 000-unit truck, which is an inconvenience you see immediately
and fix with one scale factor. So the convention loses, and the file says so
about itself: the unit and the full axis definition are written into the
glTF extras and into the OBJ header, because a geometry export whose scale
can only be recovered from a README is a geometry export somebody will
import wrongly.
The transform itself lives in core/coords.js, next to engToRender, not
in the exporter. It is pure arithmetic and it is the inverse of the function
the whole scene is built through, so the two must never be allowed to drift
apart — and keeping it three-free is what lets the test suite check it,
including that its determinant is positive so no normal is inverted.
Two things this cost during implementation, both worth recording:
applyMatrix4
calls premultiplies, giving instance x world rather than
world x instance. That applies the assembly’s 1/1000 scale before a
translation already expressed in millimeters, and the export comes out a
thousand times too large. It is now composed explicitly with
multiplyMatrices, and verified by checking that every exported tire node
lands on its layout coordinate exactly, not approximately..glb, which shares one mesh between all of them.The bug. serializeProject re-enumerated the view fields that the caller
had already assembled. Every view flag added after that point was written in
one place and dropped in the other, so annotations, showGrid and the
material overrides were all silently lost on save — switch the grid off, save,
reopen, and it is back on.
Why it survived. Nothing failed loudly. The project file was valid, it just quietly held less than the app had put in it, and the defaults filled the gaps on reopen so the result looked plausible.
Fix. view: { ...state.view }. The caller is the single authority on what
the view state is; two whitelists for one object is a bug generator. Backed
by a test that round-trips every view flag rather than a sampled few,
because the failure mode is specifically “the one nobody remembered”.
Decision. The “Full unit” isolation level draws a translucent schematic envelope with picked-out edges, not a modeled truck.
Why. Gear3D has no sourced body dimensions — it knows axle positions, track widths and overall length, and nothing about cab shape, trailer height or frame depth. Modeling a body would mean inventing dimensions, which is the one thing this app exists not to do. So the silhouette is built from bounds that are themselves citable:
| Bound | Source |
|---|---|
| Overall length | the unit’s own cited overallLength |
| Overall width | min(actual outer width, 2591 mm) — 23 CFR 658.15 |
| Overall height | 4115 mm (13 ft 6 in) — the limit most US states apply |
| Axle positions | cited, straight from the unit |
Only the internal subdivision — where the cab ends, how deep the frame sits, how the non-axle length splits between front and rear overhang — is representative, and the app names those in an on-screen notice rather than burying them here. The silhouette is excluded from picking and from measurement snapping, so no number can ever be taken off it.
Aircraft get nothing. No sourced dimension constrains a fuselage, and the notice says exactly that instead of quietly showing bare gear.
Motorcycles get nothing either: an envelope around a motorcycle conveys nothing its two wheels do not already show.
The bug. Changing the Class or Domain dropdown repopulated the Model list but never loaded anything, so the Model dropdown displayed one vehicle while the viewport still held the previous one. The app looked frozen.
Why my own testing missed it. My verification fired change on the Model
select manually after changing the Class — so it exercised the code path I
had written rather than the interaction a user performs. Confirming a
mechanism is not the same as confirming a feature. The corrected check drives
only the control the user actually touches and asserts that the loaded unit
matches what the dropdown displays.
Fix. syncUnits({ autoLoad }) loads the first matching unit whenever the
currently loaded one is filtered out. The flag defaults to false because
syncUnitSelectors() runs after a load and would otherwise re-enter
loadUnitById.
[hidden] needs an explicit rule, and the cost of not having one (v1.3)The bug. The stylesheet had no [hidden] rule. The UA stylesheet declares
[hidden] { display: none } at the same specificity as a class selector, and
an author rule beats a UA rule outright — so every .g3-thing { display: flex }
silently un-hid its own element the moment the app set .hidden = true.
Why it mattered so much. .g3-progress is absolutely positioned over
inset: 0 of the viewport with a translucent backdrop and backdrop-filter:
blur(2px). Permanently displayed, it put a gray haze over every render and
swallowed every pointer event — no orbit, no click-to-select. The app
looked unfinished and behaved like a static image. The modified badge, the
aircraft assumption notice and the custom export fields were also all
permanently on screen.
The lesson worth recording. This was visible in screenshots from the very
first build and I explained it away as a headless-compositing artifact, because
exports looked perfect — renderToCanvas draws the WebGL scene directly and
never touches the DOM overlay, so the one output I was checking most carefully
was precisely the one that could not show the fault. When a rendering path and
an interactive path disagree, the disagreement is the finding; it should not be
attributed to the tooling until the tooling has been ruled out.
Fix. .g3-app [hidden] { display: none !important; }, stated once with the
reasoning attached, so no future display: rule can reintroduce it.
Decision. Only longitudinal dimensions and the scale bar are on at load.
A toolbar toggle (A) clears all annotation in one action, and a ground grid
(G) is on by default.
Why. With every set enabled a class 9 carries roughly twenty dimension lines across an 18 m model, and the gear — the thing the app exists to show — becomes unreadable behind its own measurements. The numbers are the product, but they are a product the reader should be able to ask for rather than one imposed on every view. The grid earns its place by being a readable scale in its own right: its pitch is a round number in the display unit, so a viewer can count squares, and it is part of the scene so it exports exactly as framed.
Decision. Aircraft units store the FAA’s mainGearOuterWidth as the
transverse datum and derive the centerline track from it:
track = outerWidth − (wheelsAcross − 1)·dualSpacing − sectionWidth.
Why. The FAA Aircraft Characteristics Database’s own data dictionary defines the field as “Distance between outer tires in the main landing gear.” It is an outside dimension. Treating it as the track — the obvious mistake, because both are informally called “main gear width” — displaces every main wheel outboard by half a dual spacing plus half a tire. On a 777 that is close to a meter per side, and the resulting figure looks entirely plausible.
The derivation is validated twice: validateUnit fails if the stored gear
positions do not reproduce the stated outer width, and a separate test checks
the derived track against each manufacturer’s independently published tread
(agreement is 10–26 mm across four aircraft, which is corroboration rather than
circularity because the tread figures play no part in the derivation).
Decision. Aircraft units carry a required assumedFields[] array. Schema
validation fails when it is absent — an empty array is valid and means “nothing
was assumed”. The app renders an amber notice naming the assumed quantities
whenever such a unit is loaded.
Why. Two aircraft quantities — nose gear dual spacing, and tandem spacing on 2D/3D gears — are not constrained by anything published. The alternatives were to omit the aircraft entirely (a second deferral, with the code paths already built and most of the data authoritative) or to record the values silently and let them pass as sourced.
Neither is right. A modeling assumption that is declared is legitimate engineering; the same number presented as a measurement is not. Putting the declaration in the schema makes it unskippable, and putting it in the interface means the person comparing output against FAARFIELD sees which two numbers to check before concluding the app is wrong. The dual spacing is also the input the user is most likely to know, and editing it re-derives the track, so correcting it keeps the authoritative envelope intact.
Decision. Image-based lighting comes from a studio environment built at runtime and PMREM-filtered, not from an HDRI file.
Why. An HDRI would be one more asset that can 404, one more third-party license living inside a public academic repository, and several megabytes on a site whose other E-Labs apps ship as single files. A generated environment keeps the app’s existing promises — no asset dependency, deterministic output — and has the added benefit that its softbox positions can follow the key light, so reflections and cast shadows agree.
Asked for: download free 3D assets to build a mesh library.
Decision. The asset-slot loader was implemented; no meshes were committed.
Why. Two independent reasons, either sufficient on its own.
ASSETS.md §8.1: CC0 preferred, CC-BY acceptable with the
attribution carried in the manifest, nothing whose license cannot be
stated. This is a judgment about the owner’s exposure, not about
convenience.What this costs. Nothing structural. The procedural path is the reference implementation and is what the test suite exercises; meshes were always an appearance enhancement. The loader, the manifest schema, the resolution chain and the 1.6× distortion cap are all in place and tested by inspection, so adding an asset later is a manifest entry and a file.
Decision. The Abaqus export emits commented patch rectangles, pressures and
an area_ratio column — not a generated *DSLOAD block.
Why. A generated load block has to assume a mesh, a surface naming scheme and a step definition. A wrong assumption there is expensive to notice and easy to miss, and the failure is silent. Handing the pre-processor exact numbers and leaving the model author in control is the honest division of responsibility. The header states the uniform-pressure idealization in full, including that it is not adequate for near-surface analysis.