ldr 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,11 +1,14 @@
1
1
  # ldr
2
2
 
3
- High-performance LEGO® (LDraw) rendering for [three.js](https://threejs.org). Renders 100k-brick models at interactive frame rates by precompiling parts into a compact binary **parts pack** and drawing them with instancing, hidden-stud/tube culling, low-detail studs, single-sided faces and indexed edge lines.
3
+ **Render 100,000-brick LEGO® (LDraw) models in three.js at interactive frame rates.**
4
4
 
5
- - Spec-compliant LDraw reader: `.ldr`, `.mpd` (submodels), `.dat`; BFC, `STEP`, `ROTSTEP`, `BUFEXCHG`, direct colours, `~Moved to` aliases.
6
- - `createLdrObject()` gives you a `THREE.Group` to drop into any scene, plus a build-steps API.
7
- - `ldr pack` CLI builds parts packs from your LDraw library (zstd/gzip compressed for serving).
8
- - ESM, TypeScript types, tree-shakeable (`sideEffects: false`); `three` is a peer dependency.
5
+ `ldr` compiles the parts a model uses into a compact binary **parts pack** ahead of time. At runtime it draws them with GPU instancing, hidden-stud and underside culling, distance LOD (simplified meshes, sub-pixel studs dropped, per-cluster frustum culling), single-sided (BFC) faces and indexed edge lines. On an Apple M4 Pro that takes Datsville (392,514 bricks) from 410 ms to 27 ms per frame, and its load time from 24.7 s to 1.6 s.
6
+
7
+ [![npm](https://img.shields.io/npm/v/ldr.svg)](https://www.npmjs.com/package/ldr) · MIT · ESM + TypeScript · `three` is a peer dependency
8
+ - **Spec-compliant LDraw reader:** `.ldr`, `.mpd` and `.dat`, including MPD-embedded parts, geometry drawn directly in models, BFC, `STEP`/`ROTSTEP`, `!TEXMAP` and `!DATA`.
9
+ - **`createLdrObject()`** returns a `THREE.Group` you can drop into any scene, plus a build-steps API.
10
+ - **`ldr pack` CLI and `buildPack()`** build parts packs from your LDraw library, compressed with zstd or gzip for serving.
11
+ - **Tree-shakeable** (`sideEffects: false`). The browser entry is `ldr`; the filesystem tools are in `ldr/node`.
9
12
 
10
13
  ## Install
11
14
 
@@ -13,15 +16,41 @@ High-performance LEGO® (LDraw) rendering for [three.js](https://threejs.org). R
13
16
  bun add ldr three # or: npm i ldr three
14
17
  ```
15
18
 
19
+ You also need the LDraw parts library, which is not bundled, to *build* packs. Download `complete.zip` from <https://library.ldraw.org> and unzip it to an `ldraw/` folder containing `LDConfig.ldr`, `parts/` and `p/`. Apps only need the pack at runtime, not the library.
20
+
16
21
  ## Quickstart
17
22
 
18
- 1. **Build a parts pack** for your model(s) (one-time, at build time):
23
+ ### 1. Build a parts pack (CLI, once at build time)
19
24
 
20
25
  ```bash
21
26
  bunx ldr pack --ldraw ./ldraw -o public/car.pack.zst models/car.ldr
22
27
  ```
23
28
 
24
- 2. **Render it:**
29
+ ```
30
+ ldr pack --ldraw <dir> -o <out.bin|.zst|.gz> [model.ldr ...] [--part 3001.dat ...] [--all-parts]
31
+ [--studs low|full] [--prims full|low] [--no-weld] [--no-colors]
32
+ [--cond] [--textures] [--no-loose-geometry] [--no-lod] [--reorder]
33
+ ```
34
+
35
+ The pack holds only the parts your models reference, plus the LDConfig colour table. A `.zst` or `.gz` extension compresses the output.
36
+
37
+ ### 2. …or build it from code (Node / Bun)
38
+
39
+ ```ts
40
+ import { buildPack } from "ldr/node";
41
+
42
+ const { data, parts, missing } = buildPack({
43
+ ldrawRoot: "./ldraw",
44
+ models: ["models/car.ldr"], // paths, or { name, text }
45
+ studs: "low", // default
46
+ cond: false, // conditional lines (opt-in)
47
+ textures: false, // printed/sticker parts (opt-in)
48
+ });
49
+ await Bun.write("public/car.pack.bin", data); // raw; compress with zstd/gzip for serving
50
+ console.log(`${parts} parts`, missing.length ? `missing: ${missing}` : "");
51
+ ```
52
+
53
+ ### 3. Render it (browser)
25
54
 
26
55
  ```ts
27
56
  import * as THREE from "three";
@@ -29,97 +58,156 @@ import { parseModel, loadPack, createLdrObject } from "ldr";
29
58
 
30
59
  const [text, pack] = await Promise.all([
31
60
  fetch("/car.ldr").then((r) => r.text()),
32
- loadPack("/car.pack.zst"), // .zst / .gz / .br / raw .bin
61
+ loadPack("/car.pack.zst"), // .zst / .gz / .br / raw .bin
33
62
  ]);
63
+
34
64
  const model = createLdrObject(parseModel(text, "car.ldr"), pack, {
35
65
  edges: true, cullStuds: true, cullUndersides: true, side: "front", material: "plastic",
36
66
  });
37
- scene.add(model.object); // +Y up, 1 unit = 1 LDU (scale option available)
38
-
39
- // build steps
40
- model.setStep(0); // show step 0 only … model.stepCount steps total
41
- model.setStep(null); // show everything
42
- ```
43
-
44
- Add lights (or `scene.environment`) as for any `MeshStandardMaterial`/`MeshPhysicalMaterial`.
45
-
46
- ## Getting parts
47
-
48
- The LDraw parts library is **not bundled** in this package. Download it from <https://library.ldraw.org> (`complete.zip`, ~90 MB unzipped to an `ldraw/` folder containing `LDConfig.ldr`, `parts/`, `p/`).
49
- The LDraw library is licensed **CC BY 2.0** — if you ship packs built from it, credit "LDraw.org Parts Library" (CC BY 2.0) in your app.
67
+ scene.add(model.object); // +Y up, 1 unit = 1 LDU (see `scale`)
50
68
 
51
- Packs contain only the parts you ask for, so they're small: a 10k-brick Datsville section with 80 unique parts and the 100k section with 290 unique parts are 0.27 MB and 1.1 MB with zstd (1.3 / 3.3 MB gzip).
52
-
53
- ```bash
54
- ldr pack --ldraw <dir> -o <out.bin|.zst|.gz> [model.ldr ...] [--part 3001.dat ...] [--all-parts]
55
- [--studs low|full] [--prims full|low] [--no-weld] [--no-colors] [--cond] [--textures] [--no-loose-geometry]
56
- ```
57
-
58
- Or from code (Node/Bun):
59
-
60
- ```ts
61
- import { buildPack } from "ldr/node";
62
- const { data, parts, missing } = buildPack({ ldrawRoot: "./ldraw", models: ["car.ldr"], studs: "low" });
63
- await Bun.write("car.pack.bin", data);
69
+ // Build steps
70
+ model.setStep(0); // show step 0 … model.stepCount - 1
71
+ model.setStep(null); // show everything
72
+ const view = model.stepViews[3]; // ROTSTEP view for step 3 (or null)
64
73
  ```
65
74
 
66
- The pack embeds the LDConfig colour table by default, so no extra colour file is needed at runtime.
75
+ Light the scene (or set `scene.environment`) as you would for any `MeshStandardMaterial` or `MeshPhysicalMaterial`.
67
76
 
68
- ## API
77
+ ## API reference
69
78
 
70
79
  ### `ldr` (browser-safe)
71
80
 
81
+ **Parsing and model**
82
+
72
83
  | export | description |
73
84
  |---|---|
74
- | `parse(text, name?) → Doc` | Parse LDraw text into files/commands (MPD-aware). |
75
- | `flatten(doc, { resolveModel?, looseGeometry?, textures? }) → PartInstance[]` | Expand submodels into placed parts with `color`, 12-number matrix `m`, `step`, nested `steps`, `path`. MPD-embedded parts (e.g. Studio/OMR unofficial parts) are emitted as parts (`embedded: true`, see `fileKind`); a model file's own type 2-5 lines become synthetic parts `<file>#<step>` (`looseGeometry`, default `true`); with `textures: true`, parts wrapped by a model-level `!TEXMAP` get `texPart` (a synthetic textured part, `texWrapper`). Instances carry their step's ROTSTEP `view`. |
76
- | `parseModel(text, name?, opts?)` | `parse` + `flatten`. |
77
- | `sequenceSteps(instances)` | Global build-step index per instance (nested submodel steps sequenced), plus `views`: the ROTSTEP view per global step. |
78
- | `fileKind(doc, name)` / `looseSource` / `loosePartName` / `texWrapper` / `fileStepViews` / `rotStepMatrix` / `stepViewMatrix` | Spec helpers: part vs model rule for MPD files (header type; else own geometry), the text of a model's loose geometry per step, model-level TEXMAP wrapper parts, ROTSTEP views (MLCad: `REL`/`ADD`/`ABS`/`END`, ROTSTEP ends the step). |
79
- | `parseLDConfig(text)` | `!COLOUR` table → `Map<code, {name, hex, edge, alpha}>`. |
80
- | `loadPack(url) → Promise<Pack>` | Fetch + decompress (by extension) + decode a pack. |
81
- | `decompressPack(bytes)` / `decodePack(buffer)` | Same, from bytes you already have (zstd/gzip auto-detected). |
82
- | `createLdrObject(instances, pack, options?) → LdrObject` | High-level three.js object. |
83
- | `createInstancedEdges(pos, instancedMesh, material?)` | Welded, indexed edge lines sharing an InstancedMesh's matrices (for custom batching). |
84
- | `conditionalLines(pack, entry, which?)` / `createInstancedConditionalEdges(cond, instancedMesh, material?)` | Type-5 conditional lines from a `--cond` pack (stride 12: p0, p1, c0, c1), drawn by a vertex shader only where the control points fall on the same side of the projected line. |
85
- | `makeTexturedMaterial(side, texture, wrapU?)` / `packTexture(pack, i)` / `texmapUV` / `texmapTriangleUV` | Printed / sticker parts from a `--textures` pack: a `!TEXMAP` UV material (image alpha blends over the part colour, untextured outside [0,1]) and the decoded embedded PNG. UV conventions follow LDView (v = 0 is the image's top row). |
86
- | `buildLdrScene`, `computeCulling`, `makeMaterial` | Low-level renderer used by the benchmarks. |
85
+ | `parse(text, name?, { geometryText? }) → Doc` | Parses LDraw text into files and commands. MPD-aware: `0 FILE`/`0 NOFILE` and `0 !DATA` blocks. |
86
+ | `flatten(doc, options?) → PartInstance[]` | Expands submodels into placed parts. Each instance has `part`, `color`, a 12-number matrix `m`, `step`, nested `steps`, `path` and the ROTSTEP `view` of its step. |
87
+ | `parseModel(text, name?, options?)` | `parse` followed by `flatten`. |
88
+ | `sequenceSteps(instances) → { count, stepOf, views }` | Gives each instance a global build-step index, sequencing nested submodel steps. `views` holds the ROTSTEP view per global step. |
89
+ | `parseLDConfig(text) → Map<code, LDrawColor>` | Reads a `!COLOUR` table into `{ code, name, hex, edge, alpha }` entries. |
90
+ | `fileKind(doc, name)` | Decides whether an MPD file is a part or a model: by its header type, otherwise by whether it has its own geometry. |
91
+ | `looseSteps` / `looseFile` / `looseSource` / `loosePartName` | A model's own type 2–5 geometry, per step, as synthetic parts named `<file>#<step>`. |
92
+ | `rotStepMatrix` / `stepViewMatrix` / `fileStepViews` | ROTSTEP views, following MLCad: `REL`, `ADD`, `ABS`, `END`. A ROTSTEP ends its step. |
93
+ | `parseTexMap` / `texmapUV` / `texmapTriangleUV` / `transformTexMap` / `resolveTexture` / `texWrapper` | `!TEXMAP` support (`PLANAR`, `CYLINDRICAL`, `SPHERICAL`). UV conventions follow LDView: v = 0 is the image's top row. |
94
+ | `parseColor`, `isDirectColor`, `colorToken`, `mul`, `invert`, `IDENTITY`, `norm`, `smoothNormals`, `hash32` | Low-level helpers. |
95
+
96
+ `FlattenOptions`:
97
+
98
+ | option | default | description |
99
+ |---|---|---|
100
+ | `looseGeometry` | `true` | Emits a model file's own type 2–5 lines as synthetic parts `<file>#<step>`. LDraw allows geometry in any file. |
101
+ | `textures` | `false` | TEXMAP-aware view of the model. References wrapped by a model-level `!TEXMAP` get a `texPart`, and `!TEXMAP FALLBACK` references are skipped. Use with `textures: true` packs. |
102
+ | `resolveModel(name)` | – | Resolves an external reference to a `Doc`, such as a model loaded separately. Return `null` to treat it as a part. |
103
+ | `maxDepth` | `64` | Submodel nesting limit (cycle guard). |
104
+
105
+ MPD-embedded parts, such as Studio/OMR unofficial parts or stickers, become part instances (`embedded: true`) and override library parts of the same name.
106
+
107
+ **Packs and rendering**
87
108
 
88
- `LdrObjectOptions`: `edges` (default `true`), `cullStuds` (`true`), `cullUndersides` (`true`), `side` (`"front"` \| `"double"`, default `"front"`), `material` (`"plastic"` \| `"standard"`), `colors` (Map / record / function override), `fallbackColor`, `ldrawSpace` (keep -Y up), `scale`, `step`, `conditionalLines` (default `true`; only has an effect with packs built with `--cond`), `conditionalStudLines` (default `true`; `false` skips stud silhouettes, ~half the conditional-line GPU cost but most of their visible effect on stud-heavy scenes), `textures` (default `true`; only has an effect with packs built with `--textures`; `object.texturesReady` resolves when images are decoded).
109
+ | export | description |
110
+ |---|---|
111
+ | `loadPack(url) → Promise<Pack>` | Fetches, decompresses and decodes a pack. zstd and gzip are detected automatically. |
112
+ | `decompressPack(bytes)` / `decodePack(buffer)` | Same, from bytes you already have. |
113
+ | `createLdrObject(instances, pack, options?) → LdrObject` | High-level three.js object (see below). |
114
+ | `createInstancedEdges(pos, instancedMesh, material?)` | Welded, indexed edge lines that share an `InstancedMesh`'s instance matrices. |
115
+ | `conditionalLines(pack, entry, which?)` / `createInstancedConditionalEdges(cond, instancedMesh, material?)` / `conditionalLineVisible` | Type-5 conditional lines from a `cond` pack. A vertex shader keeps a segment only where both control points project to the same side of the line. |
116
+ | `makeTexturedMaterial(side, texture, wrapU?)` / `packTexture(pack, i)` | Printed and sticker parts from a `textures` pack: a UV material and the decoded embedded PNG. |
117
+ | `buildLdrScene`, `computeCulling`, `cullCore`, `makeMaterial` | Low-level renderer used by the benchmarks. `cullCore(instances, header, steps?)` is the typed-array culling core (with `steps`, also the step at which each stud and underside becomes hidden). |
118
+
119
+ `LdrObjectOptions`:
120
+
121
+ | option | default | description |
122
+ |---|---|---|
123
+ | `edges` | `true` | LDraw type-2 edge lines, indexed and sharing each part's instance matrices. |
124
+ | `cullStuds` | `true` | Hides studs fully covered by a part above. |
125
+ | `cullUndersides` | `true` | Hides undersides (tubes) sitting fully on studs below. |
126
+ | `side` | `"front"` | `"front"` draws single-sided BFC faces; `"double"` draws both sides. Uncertified parts are always double-sided. |
127
+ | `material` | `"plastic"` | `"plastic"` is `MeshPhysicalMaterial` with clearcoat; `"standard"` is `MeshStandardMaterial`, the cheapest option. |
128
+ | `conditionalLines` | `true` | Conditional (silhouette) lines. Only takes effect with `cond` packs, and follows `edges`. |
129
+ | `conditionalStudLines` | `true` | Conditional lines on studs. `false` roughly halves the conditional-line cost on large scenes. |
130
+ | `textures` | `true` | Renders `!TEXMAP` images from `textures` packs; `false` draws the FALLBACK geometry. `object.texturesReady` resolves once the images are decoded. |
131
+ | `colors` | pack table | Colour override: a `Map`, a record, or `(code) => color`. |
132
+ | `fallbackColor` | `#A0A5A9` | Colour used for unknown codes. |
133
+ | `ldrawSpace` | `false` | `true` keeps native LDraw space (-Y up). |
134
+ | `scale` | `1` | Uniform scale (1 = 1 LDU; 1 stud = 20 LDU). |
135
+ | `step` | all | Initial build step (inclusive, 0-based). |
136
+ | `lod` | `true` | Distance LOD, re-selected for each camera you render with. Batches are split into spatial clusters (off-screen ones are frustum-culled). Each cluster draws the coarsest simplified mesh (from `lod` packs) whose error stays under `lodPx`, drops studs whose 12-LDU disc is under `lodPx`, and hides edges where a stud spans less than `lodEdgePx`. |
137
+ | `lodPx` | `0.5` | LOD: maximum screen-space error in pixels. |
138
+ | `lodEdgePx` | `0.5` | LOD: draw edge and conditional lines only where one stud (20 LDU) spans at least this many pixels. `0` = always. |
139
+
140
+ `LdrObject`: `object`, `stats` (`instances`, `drawn`, `missing[]`, `studsHidden/Total`, `undersHidden/Total`, `buildMs`, `condBatches`, `texBatches`, `textures`, `clusters`), `stepCount`, `stepViews`, `texturesReady`, `setStep(n | null)`, `setEdges(on)`, `setConditionalLines(on)`, `setCulling(studs, undersides)`, `setSide(side)`, `setLod(on)`, `getBox(target?)`, `dispose()`.
141
+
142
+ `setStep` doesn't rebuild anything: it compacts instance buffers, and culling is step-aware (a stud covered only by a later step's part stays visible). `stats` reflect the shown step.
143
+
144
+ ### `ldr/node` (Node / Bun only)
145
+
146
+ Everything above, plus:
89
147
 
90
- `LdrObject`: `object`, `stats` (`instances`, `drawn`, `missing[]`, `studsHidden/Total`, `undersHidden/Total`, `buildMs`), `stepCount`, `stepViews` (ROTSTEP view per step, or `null`), `setStep(n | null)`, `setEdges(on)`, `setCulling(studs, undersides)`, `setSide(side)`, `getBox()`, `dispose()`.
148
+ | export | description |
149
+ |---|---|
150
+ | `buildPack(options) → { data, parts, studKinds, missing, warnings? }` | Builds a pack. Options: `ldrawRoot`, `models`, `parts`, `allParts`, `studs` (`"low"` \| `"full"`), `primitives` (`"full"` \| `"low"`), `weld` (`true`), `cond` (`false`), `textures` (`false`), `looseGeometry` (`true`), `colors` (`true`), `lod` (`true`: meshoptimizer-simplified LOD meshes, pack feature `lod`), `reorder` (`false`: vertex-cache order), `onProgress`. |
151
+ | `Library(root, unofficialRoot?)` | Filesystem LDraw resolver: `where`, `flatten`, `mesh`, `colors`, `texture`, `register`. Handles case-insensitive lookup and `~Moved to` aliases. |
152
+ | `Compiler`, `encodePack`, `writePack`, `loadColors` | Lower-level pack compiler. |
153
+ | `ResolverLibrary`, `splitModel`, `flattenWithLibrary`, `collectDeps` | The resolver that `buildPack` uses. |
91
154
 
92
- ### `ldr/node` (Node/Bun only)
155
+ ## Benchmarks
93
156
 
94
- Everything above, plus `buildPack(options)`, `Library` (filesystem LDraw resolver: `where`, `flatten`, `mesh`, `colors`), `Compiler`, `encodePack`, `writePack`.
157
+ All numbers below were measured with the harness in [`bench/`](https://github.com/EvanBacon/ldr/tree/main/bench/) and are taken from the result files there.
95
158
 
96
- ## Performance
159
+ - **Hardware and browser:** Apple **M4 Pro** MacBook Pro, Chrome with native GL (ANGLE → Metal), Bun 1.3.
160
+ - **Frame time:** the **median synced frame time** (`gl.finish` per frame) while orbiting the camera.
161
+ - **Load:** from fetch to the first rendered frame.
162
+ - **Baseline:** three.js geometry from `LDrawLoader` merged per part and drawn with `InstancedMesh`, which is the fastest stock approach. Stock `LDrawLoader` scenes crash the tab (OOM) at 50k bricks and above, and already on Datsville 10k.
163
+ - **ldr settings:** the defaults: LOD pack, distance LOD, low-detail studs, stud and underside culling, single-sided faces, no edges.
97
164
 
98
- Measured with the benchmark harness in [`bench/`](https://github.com/EvanBacon/ldr/tree/main/bench) (Chrome, synced frame time while orbiting). Apple M4 Pro, native GL (Metal via ANGLE):
165
+ Sources: `bench/results/latest-perf.md` (the regression gate, `bench/baselines/darwin-apple-m4-pro.json`), `latest-mac-ablation.md`, and `research.md` §7 and Round 19.
99
166
 
100
- | model | renderer | load | frame | tris/frame | draw calls |
167
+ | model | bricks | baseline frame | **ldr frame** | baseline load | **ldr load** |
101
168
  |---|---|---|---|---|---|
102
- | Datsville 100k bricks | three.js `LDrawLoader`-geometry instancing | 19.5 s | 105.7 ms (9 fps) | 142.7M | 290 |
103
- | Datsville 100k bricks | **ldr** (low studs, culling, single-sided) | **2.2 s** | **30.1 ms (33 fps)** | 59.1M | 506 |
104
- | Datsville 10k bricks | three.js instancing | 13.5 s | 45.9 ms | 60.5M | 80 |
105
- | Datsville 10k bricks | **ldr** (same) | **1.0 s** | **10.9 ms (92 fps)** | 21.0M | 131 |
106
- | 100k-brick grid | **ldr** + edges | 0.67 s | 15.7 ms (64 fps) | 4.0M | 24 |
107
- | 10k-brick grid | `LDrawLoader` | 6.6 s | 61.5 ms | 2.6M | 29,671 |
108
- | 10k-brick grid | **ldr** + edges | 0.15 s | 2.2 ms | 0.4M | 24 |
109
-
110
- **Demo app** (`demo/`, rAF fps on the M4 Pro with big-model defaults — shadows/AO/edges off above 50k bricks): Datsville 100k at **43.5 fps** (1.8 s load), full Datsville (392k bricks) at **9.9 fps**.
111
-
112
- Visual-diff studies show culling and single-sided faces are lossless (≤0.009% pixels); low-detail studs change only stud silhouettes. Stud culling hides ~39–51% of studs on real models.
113
-
114
- ## Notes & limitations
115
-
116
- - Transparent colours render opaque. Conditional (type-5) lines are drawn only from packs built with `--cond` (pack v2, opt-in: +8–14% zstd size on Datsville); default packs carry type-2 edges only. Printed parts show their `!TEXMAP` images only from packs built with `--textures` (pack v2, opt-in; the PNGs dominate the size); default packs draw the parts' FALLBACK geometry. Model-level `!TEXMAP` around part references renders from `--textures` packs (one synthetic part per distinct map). Parts embedded in an MPD (unofficial/custom parts, stickers) render as parts and override library parts of the same name; geometry drawn directly in a model file is packed as synthetic parts (`--no-loose-geometry` to skip it).
117
- - ROTSTEP is reported (`stepViews`, `instance.view`), not applied: rotating the camera per step is up to the app.
118
- - `setStep` rebuilds batches (fast for instruction-sized models; culling is recomputed for the partial build).
119
- - Coordinates: LDraw units (1 stud = 20 LDU); the object is rotated to +Y up unless `ldrawSpace: true`.
120
-
121
- LEGO® is a trademark of the LEGO Group, which does not sponsor, authorize or endorse this project.
122
-
123
- ## License
124
-
125
- MIT (code). Parts data you build from the LDraw library is CC BY 2.0 (LDraw.org).
169
+ | synthetic grid | 100,021 | 24.4 ms | **2.7 ms** | 0.2 s | 0.3 s |
170
+ | Datsville 10k | 10,000 | 45.9 ms | **2.2 ms** | 13.5 s | **0.34 s** |
171
+ | Datsville 100k | 100,000 | 105.7 ms | **6.2 ms** | 19.5 s | **0.79 s** |
172
+ | Datsville (full) | 392,514 | 409.6 ms | **27.2 ms** | 24.7 s | **1.6 s** |
173
+
174
+ How much each optimization contributes on Datsville 100k, in ms per frame: welding 108.1 → 64.6, stud culling → 50.8, underside culling → 41.7, low-detail studs → 31.0, single-sided faces → 30.1. Triangles per frame drop from 142.7M to 59.1M. Distance LOD (Round 19) then takes it to 6.2 ms and 6.6M triangles; ≤ 1.6% of pixels differ from full detail (mostly sub-pixel stud moiré).
175
+
176
+ - **Pack size:** Datsville 100k (290 unique parts) is 25.8 MB raw, 3.98 MB gzip and **1.49 MB zstd** with LOD meshes (22.4 MB / 1.08 MB zstd with `lod: false`). Decoding a zstd pack in the browser (fzstd) takes about **60 ms**.
177
+ - **Stock `LDrawLoader`:** loading the scene directly crashes the renderer (OOM) on the 50k, 100k and 250k grids and on Datsville 10k. The 10k grid loads in 6.6 s and draws at 61.5 ms per frame (29,671 draw calls), against 0.15 s and 2.2 ms with ldr + edges.
178
+ - **Demo app** (`demo/`; rAF frame median at 2880×1800 on the M4 Pro):
179
+ - These comparisons are between demo settings. "Before" has every effect on (shadows, AO, screen-space edges, PBR/IBL); "after" is the demo's big-model defaults, which turn those effects off above 50k bricks.
180
+ - Full Datsville: **1.7 → 9.9 fps**.
181
+ - Datsville 100k: **8 → 43.5 fps**.
182
+ - **Conditional lines** (opt-in `cond` packs) cost **+12–14 %** frame time on Datsville 10k and 100k (M4 Pro), and add 7–14 % to the zstd pack size.
183
+ - **Edges:** with LOD, edges cost Datsville 100k 6.2 → 9.2 ms and full Datsville nothing measurable (27.2 ms), because edges are hidden where bricks are sub-pixel (`lodEdgePx`). Without edge LOD they cost 35 ms (100k) and 153 ms (full).
184
+ - **Build steps:** `setStep` takes 66 ms on Datsville 100k and 29 ms on 10k (it was a full rebuild: 752 / 347 ms).
185
+ - **Visual fidelity:** pixel diffs show culling and single-sided faces are lossless (≤ 0.009 % of pixels differ). Low-detail studs only change stud silhouettes.
186
+
187
+ Reproduce: `cd bench && bun run setup && bun run perf` (regression gate) or `bun run bench` (see [`bench/README.md`](https://github.com/EvanBacon/ldr/blob/main/bench/README.md)).
188
+
189
+ ## LDraw spec compliance
190
+
191
+ - Line types 0–5. BFC: `CERTIFY`, `CCW`/`CW`, `INVERTNEXT`, `NOCLIP`, inverted matrices.
192
+ - MPD: `0 FILE` / `0 NOFILE`, and `0 !DATA` (base64 PNGs). An MPD file is a part or a model by its `!LDRAW_ORG` type, otherwise by whether it has its own geometry. Embedded parts override library parts.
193
+ - Geometry drawn directly in model files (type 2–5 lines outside parts), on by default.
194
+ - `0 STEP`, nested submodel step sequencing, `0 ROTSTEP` (`REL`/`ADD`/`ABS`/`END`), and `0 BUFEXCHG`.
195
+ - `!TEXMAP` `START`/`NEXT`/`FALLBACK`/`END`, with `PLANAR`, `CYLINDRICAL` and `SPHERICAL` projections (orientation matches LDView), and model-level `!TEXMAP` around part references.
196
+ - Colours: LDConfig `!COLOUR`, edge colours, main/edge colour inheritance (16/24), and direct colours (`0x2RRGGBB`).
197
+ - Case-insensitive, backslash-normalized file lookup; `~Moved to` alias parts.
198
+
199
+ ## Limitations
200
+
201
+ - **Transparency:** transparent colours render opaque.
202
+ - **Opt-in pack features:** conditional lines need packs built with `cond`. Printed parts show their images only from `textures` packs (the embedded PNGs dominate the pack size); default packs draw the FALLBACK geometry.
203
+ - **ROTSTEP** is reported (`stepViews`, `instance.view`), not applied. Rotating the camera per step is up to your app.
204
+ - **Packs are static:** they hold only the parts they were built for. A model that uses parts missing from the pack lists them in `stats.missing`.
205
+ - **Occlusion culling:** beyond hidden studs and undersides there is no occlusion culling (bricks enclosed by other bricks are still drawn). Frustum culling and LOD apply per spatial cluster.
206
+ - **LOD and edges:** at the default `lodEdgePx` edges disappear where bricks are a few pixels wide, which visibly lightens far-away views drawn with edges (4–7% of pixels on Datsville overviews). Set `lodEdgePx: 0` to keep every edge (much slower on large models), or `lod: false` to turn LOD off entirely.
207
+
208
+ ## Attribution and license
209
+
210
+ - **Code:** MIT © Evan Bacon.
211
+ - **LDraw parts:** packs you build contain geometry from the **LDraw.org Parts Library**, which is licensed **CC BY 2.0** (newer files CC BY 4.0, per file header). If you ship packs, credit "LDraw.org Parts Library (CC BY 2.0)" in your app. See <https://www.ldraw.org/legal-info>.
212
+ - **Datsville:** the benchmark model is by [mjhorvath/Datsville](https://github.com/mjhorvath/Datsville) (CC BY-SA 4.0). It is used for benchmarking only and is not redistributed.
213
+ - **Trademarks:** LEGO® is a trademark of the LEGO Group, which does not sponsor, authorize or endorse this project. LDraw™ is a trademark of the Estate of James Jessiman.
package/dist/cdn.d.ts ADDED
@@ -0,0 +1,62 @@
1
+ import { type Pack } from "./render.js";
2
+ import { type PartInstance } from "./model.js";
3
+ import { type LdrObject, type LdrObjectOptions } from "./scene.js";
4
+ /** Default parts CDN (LDraw library parts, CC BY 2.0 / 4.0, compiled by ldr; hosted with the demo site). */
5
+ export declare const DEFAULT_PARTS_CDN = "https://ldr-demo.vercel.app/cdn/v1/";
6
+ export interface PartsCdnIndex {
7
+ format: string;
8
+ release?: string;
9
+ license?: string;
10
+ archives: string[];
11
+ bytes: number;
12
+ colors: Record<string, string>;
13
+ parts: Record<string, [number, number, number]>;
14
+ }
15
+ export interface LoadPartsOptions {
16
+ /** Base URL of the parts CDN (ending in /). Default `DEFAULT_PARTS_CDN`. */
17
+ cdn?: string;
18
+ /** Merge two byte ranges into one request when the gap between them is at most this many bytes. Default 64 KB. */
19
+ gap?: number;
20
+ /** Max parallel Range requests. Default 6. */
21
+ concurrency?: number;
22
+ fetch?: typeof fetch;
23
+ }
24
+ /** Fetch (and cache) a parts CDN index. */
25
+ export declare function loadPartsIndex(cdn?: string, f?: typeof fetch): Promise<PartsCdnIndex>;
26
+ /** Merge several packs into one (parts and stud kinds; the first pack's definition wins on name clashes). */
27
+ export declare function mergePacks(packs: Pack[], extra?: Record<string, unknown>): Pack;
28
+ /**
29
+ * Fetch the given parts from a parts CDN and return them as one Pack (with the LDConfig colour table).
30
+ * Parts the CDN does not have are listed in `missing`.
31
+ */
32
+ export declare function loadParts(names: Iterable<string>, o?: LoadPartsOptions): Promise<{
33
+ pack: Pack;
34
+ missing: string[];
35
+ requests: number;
36
+ bytes: number;
37
+ }>;
38
+ export interface LoadModelOptions extends LdrObjectOptions, LoadPartsOptions {
39
+ /** Treat `source` as LDraw text instead of a URL. */
40
+ text?: boolean;
41
+ /** File name used for the main model (MPD resolution). Default: last URL segment. */
42
+ name?: string;
43
+ }
44
+ export interface LoadedModel extends LdrObject {
45
+ pack: Pack; /** The flattened model (part instances). */
46
+ instances: PartInstance[];
47
+ timings: {
48
+ fetchModel: number;
49
+ parse: number;
50
+ parts: number;
51
+ build: number;
52
+ total: number;
53
+ };
54
+ requests: number;
55
+ bytes: number;
56
+ }
57
+ /**
58
+ * One call from an `.ldr` / `.mpd` URL (or text) to a three.js object: fetches the model, fetches just the parts it uses from the
59
+ * parts CDN and builds an instanced `LdrObject`. No LDraw library or parts pack needed.
60
+ * Limits (v1): parts defined inside the model (MPD-embedded parts) and loose geometry in model files are skipped (listed in `stats.missing`).
61
+ */
62
+ export declare function loadModel(source: string, o?: LoadModelOptions): Promise<LoadedModel>;