ldr 0.4.1 → 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
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Render 100,000-brick LEGO® (LDraw) models in three.js at interactive frame rates.**
4
4
 
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, low-detail studs, single-sided (BFC) faces and indexed edge lines. On an Apple M4 Pro that takes Datsville (392,514 bricks) from 410 ms to 97 ms per frame, and its load time from 24.7 s to 3.4 s.
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
6
 
7
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
8
  - **Spec-compliant LDraw reader:** `.ldr`, `.mpd` and `.dat`, including MPD-embedded parts, geometry drawn directly in models, BFC, `STEP`/`ROTSTEP`, `!TEXMAP` and `!DATA`.
@@ -29,7 +29,7 @@ bunx ldr pack --ldraw ./ldraw -o public/car.pack.zst models/car.ldr
29
29
  ```
30
30
  ldr pack --ldraw <dir> -o <out.bin|.zst|.gz> [model.ldr ...] [--part 3001.dat ...] [--all-parts]
31
31
  [--studs low|full] [--prims full|low] [--no-weld] [--no-colors]
32
- [--cond] [--textures] [--no-loose-geometry]
32
+ [--cond] [--textures] [--no-loose-geometry] [--no-lod] [--reorder]
33
33
  ```
34
34
 
35
35
  The pack holds only the parts your models reference, plus the LDConfig colour table. A `.zst` or `.gz` extension compresses the output.
@@ -114,7 +114,7 @@ MPD-embedded parts, such as Studio/OMR unofficial parts or stickers, become part
114
114
  | `createInstancedEdges(pos, instancedMesh, material?)` | Welded, indexed edge lines that share an `InstancedMesh`'s instance matrices. |
115
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
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`, `makeMaterial` | Low-level renderer used by the benchmarks. |
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
118
 
119
119
  `LdrObjectOptions`:
120
120
 
@@ -133,8 +133,13 @@ MPD-embedded parts, such as Studio/OMR unofficial parts or stickers, become part
133
133
  | `ldrawSpace` | `false` | `true` keeps native LDraw space (-Y up). |
134
134
  | `scale` | `1` | Uniform scale (1 = 1 LDU; 1 stud = 20 LDU). |
135
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. |
136
139
 
137
- `LdrObject`: `object`, `stats` (`instances`, `drawn`, `missing[]`, `studsHidden/Total`, `undersHidden/Total`, `buildMs`, `condBatches`, `texBatches`, `textures`), `stepCount`, `stepViews`, `texturesReady`, `setStep(n | null)`, `setEdges(on)`, `setConditionalLines(on)`, `setCulling(studs, undersides)`, `setSide(side)`, `getBox(target?)`, `dispose()`.
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.
138
143
 
139
144
  ### `ldr/node` (Node / Bun only)
140
145
 
@@ -142,7 +147,7 @@ Everything above, plus:
142
147
 
143
148
  | export | description |
144
149
  |---|---|
145
- | `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`), `onProgress`. |
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`. |
146
151
  | `Library(root, unofficialRoot?)` | Filesystem LDraw resolver: `where`, `flatten`, `mesh`, `colors`, `texture`, `register`. Handles case-insensitive lookup and `~Moved to` aliases. |
147
152
  | `Compiler`, `encodePack`, `writePack`, `loadColors` | Lower-level pack compiler. |
148
153
  | `ResolverLibrary`, `splitModel`, `flattenWithLibrary`, `collectDeps` | The resolver that `buildPack` uses. |
@@ -155,30 +160,31 @@ All numbers below were measured with the harness in [`bench/`](https://github.co
155
160
  - **Frame time:** the **median synced frame time** (`gl.finish` per frame) while orbiting the camera.
156
161
  - **Load:** from fetch to the first rendered frame.
157
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.
158
- - **ldr settings:** low-detail studs, welded pack, stud and underside culling, single-sided faces, no edges.
163
+ - **ldr settings:** the defaults: LOD pack, distance LOD, low-detail studs, stud and underside culling, single-sided faces, no edges.
159
164
 
160
- Sources: `bench/results/latest-mac-ablation.md`, `latest-mac.md`, and `research.md` §7.
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.
161
166
 
162
167
  | model | bricks | baseline frame | **ldr frame** | baseline load | **ldr load** |
163
168
  |---|---|---|---|---|---|
164
- | synthetic grid | 100,021 | 24.4 ms | **3.6 ms** | 0.2 s | 0.6 s |
165
- | Datsville 10k | 10,000 | 45.9 ms | **10.9 ms** | 13.5 s | **1.0 s** |
166
- | Datsville 100k | 100,000 | 105.7 ms | **30.1 ms** | 19.5 s | **2.2 s** |
167
- | Datsville (full) | 392,514 | 409.6 ms | **97.2 ms** | 24.7 s | **3.4 s** |
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** |
168
173
 
169
- 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.
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é).
170
175
 
171
- - **Pack size:** Datsville 100k (290 unique parts) is 22.4 MB raw, 3.13 MB gzip and **1.08 MB zstd**. Decoding the zstd pack in the browser (fzstd) takes **58 ms**.
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**.
172
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.
173
178
  - **Demo app** (`demo/`; rAF frame median at 2880×1800 on the M4 Pro):
174
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.
175
180
  - Full Datsville: **1.7 → 9.9 fps**.
176
181
  - Datsville 100k: **8 → 43.5 fps**.
177
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.
178
- - **Edges:** turning `edges` on costs more than that on dense scenes: Datsville 100k goes from 30.1 to 98.2 ms per frame. For very large models, consider `edges: false`.
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).
179
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.
180
186
 
181
- Reproduce: `cd bench && bun run setup && bun run bench` (see [`bench/README.md`](https://github.com/EvanBacon/ldr/blob/main/bench/README.md)).
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)).
182
188
 
183
189
  ## LDraw spec compliance
184
190
 
@@ -195,9 +201,9 @@ Reproduce: `cd bench && bun run setup && bun run bench` (see [`bench/README.md`]
195
201
  - **Transparency:** transparent colours render opaque.
196
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.
197
203
  - **ROTSTEP** is reported (`stepViews`, `instance.view`), not applied. Rotating the camera per step is up to your app.
198
- - **`setStep`** rebuilds batches. That is fast for instruction-sized models, and culling is recomputed for the partial build.
199
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`.
200
- - **Occlusion culling and LOD:** there is no occlusion culling and no distance LOD yet. Only three.js frustum culling applies, per batch.
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.
201
207
 
202
208
  ## Attribution and license
203
209
 
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>;
package/dist/cli.js CHANGED
@@ -1,18 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // src/cli.ts
3
+ // ../../../../../../../tmp/ldr-build-qIdKSv/src/cli.ts
4
4
  import fs4 from "node:fs";
5
5
  import zlib from "node:zlib";
6
6
 
7
- // src/node/build-pack.ts
7
+ // ../../../../../../../tmp/ldr-build-qIdKSv/src/node/build-pack.ts
8
8
  import fs3 from "node:fs";
9
9
  import path3 from "node:path";
10
10
 
11
- // src/node/resolver.ts
11
+ // ../../../../../../../tmp/ldr-build-qIdKSv/src/node/resolver.ts
12
12
  import fs from "node:fs";
13
13
  import path from "node:path";
14
14
 
15
- // src/spec.ts
15
+ // ../../../../../../../tmp/ldr-build-qIdKSv/src/spec.ts
16
16
  var norm = (s) => s.trim().toLowerCase().replace(/\\/g, "/").replace(/\/+/g, "/");
17
17
  function parseColor(tok) {
18
18
  if (/^0x/i.test(tok))
@@ -662,7 +662,7 @@ function fileStepViews(f) {
662
662
  return out;
663
663
  }
664
664
 
665
- // src/model.ts
665
+ // ../../../../../../../tmp/ldr-build-qIdKSv/src/model.ts
666
666
  function flatten(doc, opts = {}) {
667
667
  const out = [];
668
668
  const maxDepth = opts.maxDepth ?? 64;
@@ -784,7 +784,7 @@ function parseLDConfig(text) {
784
784
  return out;
785
785
  }
786
786
 
787
- // src/node/resolver.ts
787
+ // ../../../../../../../tmp/ldr-build-qIdKSv/src/node/resolver.ts
788
788
  var TOP_STUD_RE = /^(stud|studa|stud2|stud2a|stud6|stud6a|stud10|stud15|stud2s|stud2s2|stud-logo\d*|stud2-logo\d*)\.dat$/i;
789
789
  var UNDER_STUD_RE = /^(stud3|stud3a|stud4.*|stud12|stud16.*|stud18a)\.dat$/i;
790
790
  var norm2 = (ref) => ref.trim().replace(/\\/g, "/").toLowerCase();
@@ -1054,9 +1054,118 @@ function collectDeps(lib, refs) {
1054
1054
  return out;
1055
1055
  }
1056
1056
 
1057
- // src/node/compile.ts
1057
+ // ../../../../../../../tmp/ldr-build-qIdKSv/src/node/compile.ts
1058
1058
  import fs2 from "node:fs";
1059
1059
  import path2 from "node:path";
1060
+
1061
+ // ../../../../../../../tmp/ldr-build-qIdKSv/src/node/lod.ts
1062
+ import { MeshoptSimplifier, MeshoptEncoder } from "meshoptimizer";
1063
+ await Promise.all([MeshoptSimplifier.ready, MeshoptEncoder.ready]);
1064
+ function reorderMesh(P, N, C, I) {
1065
+ const idx = Uint32Array.from(I);
1066
+ const n = P.length / 3;
1067
+ const [remap] = MeshoptEncoder.reorderMesh(idx, true, false);
1068
+ const P2 = new Float32Array(P.length), N2 = new Float32Array(N.length), C2 = new Float32Array(C.length);
1069
+ for (let v = 0;v < n; v++) {
1070
+ const r = remap[v];
1071
+ if (r === 4294967295)
1072
+ continue;
1073
+ P2[r * 3] = P[v * 3];
1074
+ P2[r * 3 + 1] = P[v * 3 + 1];
1075
+ P2[r * 3 + 2] = P[v * 3 + 2];
1076
+ N2[r * 3] = N[v * 3];
1077
+ N2[r * 3 + 1] = N[v * 3 + 1];
1078
+ N2[r * 3 + 2] = N[v * 3 + 2];
1079
+ for (let k = 0;k < 4; k++)
1080
+ C2[r * 4 + k] = C[v * 4 + k];
1081
+ }
1082
+ return { P: P2, N: N2, C: C2, I: idx };
1083
+ }
1084
+ var TARGETS = [0.25, 0.08];
1085
+ var MIN_TRIS = 64;
1086
+ function flatMesh(tris) {
1087
+ const map = new Map;
1088
+ const P = [], N = [], C = [], I = [];
1089
+ for (const { p, i: idx, col } of tris)
1090
+ for (let t = 0;t < idx.length; t += 3) {
1091
+ const a = idx[t] * 3, b = idx[t + 1] * 3, c = idx[t + 2] * 3;
1092
+ const ux = p[b] - p[a], uy = p[b + 1] - p[a + 1], uz = p[b + 2] - p[a + 2], vx = p[c] - p[a], vy = p[c + 1] - p[a + 1], vz = p[c + 2] - p[a + 2];
1093
+ let nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
1094
+ const l = Math.hypot(nx, ny, nz);
1095
+ if (!(l > 0.000000001))
1096
+ continue;
1097
+ nx /= l;
1098
+ ny /= l;
1099
+ nz /= l;
1100
+ for (const o of [a, b, c]) {
1101
+ const k = `${p[o].toFixed(3)},${p[o + 1].toFixed(3)},${p[o + 2].toFixed(3)},${nx.toFixed(3)},${ny.toFixed(3)},${nz.toFixed(3)},${col.join()}`;
1102
+ let j = map.get(k);
1103
+ if (j === undefined) {
1104
+ j = P.length / 3;
1105
+ map.set(k, j);
1106
+ P.push(p[o], p[o + 1], p[o + 2]);
1107
+ N.push(nx, ny, nz);
1108
+ C.push(...col);
1109
+ }
1110
+ I.push(j);
1111
+ }
1112
+ }
1113
+ return { P: new Float32Array(P), N: new Float32Array(N), C: new Float32Array(C), I: new Uint32Array(I) };
1114
+ }
1115
+ function lodChain(P, C, I) {
1116
+ const nTris = I.length / 3;
1117
+ if (nTris < MIN_TRIS)
1118
+ return [];
1119
+ const groups = new Map;
1120
+ for (let t = 0;t < I.length; t += 3) {
1121
+ const v = I[t] * 4;
1122
+ const col = [C[v], C[v + 1], C[v + 2], C[v + 3]];
1123
+ const k = col.join();
1124
+ let g = groups.get(k);
1125
+ if (!g)
1126
+ groups.set(k, g = { col, tri: [] });
1127
+ g.tri.push(I[t], I[t + 1], I[t + 2]);
1128
+ }
1129
+ const welded = [...groups.values()].map(({ col, tri }) => {
1130
+ const map = new Map;
1131
+ const pos = [];
1132
+ const idx = new Uint32Array(tri.length);
1133
+ for (let i = 0;i < tri.length; i++) {
1134
+ const o = tri[i] * 3;
1135
+ const k = `${P[o].toFixed(3)},${P[o + 1].toFixed(3)},${P[o + 2].toFixed(3)}`;
1136
+ let j = map.get(k);
1137
+ if (j === undefined) {
1138
+ j = pos.length / 3;
1139
+ map.set(k, j);
1140
+ pos.push(P[o], P[o + 1], P[o + 2]);
1141
+ }
1142
+ idx[i] = j;
1143
+ }
1144
+ return { col, p: new Float32Array(pos), i: idx };
1145
+ });
1146
+ const out = [];
1147
+ let prevTris = nTris;
1148
+ for (let li = 0;li < TARGETS.length; li++) {
1149
+ let err = 0;
1150
+ const parts = [];
1151
+ for (const g of welded) {
1152
+ const target = Math.max(3, Math.floor(g.i.length * TARGETS[li] / 3) * 3);
1153
+ const [idx, e] = MeshoptSimplifier.simplify(g.i, g.p, 3, target, 1e9, li ? ["ErrorAbsolute", "Prune"] : ["ErrorAbsolute"]);
1154
+ err = Math.max(err, e);
1155
+ if (idx.length)
1156
+ parts.push({ p: g.p, i: idx, col: g.col });
1157
+ }
1158
+ const m = flatMesh(parts);
1159
+ const t = m.I.length / 3;
1160
+ if (t > prevTris * 0.7 || !t)
1161
+ continue;
1162
+ out.push({ ...m, err: +Math.max(err, 0.001).toFixed(3) });
1163
+ prevTris = t;
1164
+ }
1165
+ return out;
1166
+ }
1167
+
1168
+ // ../../../../../../../tmp/ldr-build-qIdKSv/src/node/compile.ts
1060
1169
  function loadColors(root) {
1061
1170
  const map = new Map;
1062
1171
  for (const l of fs2.readFileSync(path2.join(root, "LDConfig.ldr"), "latin1").split(/\r?\n/)) {
@@ -1473,7 +1582,8 @@ function encodePack(parts, studKinds, weld, extra, options = {}) {
1473
1582
  return r;
1474
1583
  };
1475
1584
  let lastPos = [];
1476
- const bucket = (b) => {
1585
+ let anyLod = false;
1586
+ const bucket = (b, lod = false) => {
1477
1587
  const n = b.pos.length / 3;
1478
1588
  lastPos = [];
1479
1589
  if (!n)
@@ -1512,9 +1622,20 @@ function encodePack(parts, studKinds, weld, extra, options = {}) {
1512
1622
  }
1513
1623
  I.push(j);
1514
1624
  }
1515
- const vcount = P.length / 3;
1516
- lastPos = P;
1517
- return { pos: put(new Float32Array(P)), nrm: put(new Float32Array(N)), col: put(new Float32Array(C)), idx: put(vcount < 65536 ? new Uint16Array(I) : new Uint32Array(I)), tris: n / 3 };
1625
+ let m = { P, N, C, I };
1626
+ if (options.reorder && I.length >= 3)
1627
+ m = reorderMesh(P, N, C, I);
1628
+ const vcount = m.P.length / 3;
1629
+ lastPos = m.P;
1630
+ const out = { pos: put(Float32Array.from(m.P)), nrm: put(Float32Array.from(m.N)), col: put(Float32Array.from(m.C)), idx: put(vcount < 65536 ? Uint16Array.from(m.I) : Uint32Array.from(m.I)), tris: n / 3 };
1631
+ if (lod && options.lod) {
1632
+ const chain = lodChain(P, C, I).map((l) => options.reorder ? { ...l, ...reorderMesh(l.P, l.N, l.C, l.I) } : l);
1633
+ if (chain.length) {
1634
+ anyLod = true;
1635
+ out.lod = chain.map((l) => ({ pos: put(l.P), col: put(Uint8Array.from(l.C, (x) => Math.round(x * 255))), idx: put(l.P.length / 3 < 65536 ? new Uint16Array(l.I) : l.I), tris: l.I.length / 3, err: l.err }));
1636
+ }
1637
+ }
1638
+ return out;
1518
1639
  };
1519
1640
  let anyCond = false;
1520
1641
  const condl = (a, pools) => {
@@ -1605,18 +1726,18 @@ function encodePack(parts, studKinds, weld, extra, options = {}) {
1605
1726
  });
1606
1727
  return { tbody: bucket(c.tbody), tex };
1607
1728
  };
1608
- const enc = (c) => {
1609
- const body = bucket(c.body), bodyP = lastPos, under = bucket(c.under), underP = lastPos;
1729
+ const enc = (c, lod = false) => {
1730
+ const body = bucket(c.body, lod), bodyP = lastPos, under = bucket(c.under, lod), underP = lastPos;
1610
1731
  const edges = c.edges.length ? put(new Float32Array(c.edges)) : null;
1611
1732
  const condlB = condl(c.cond, [bodyP, underP, c.edges]), condlU = condl(c.condUnder, [bodyP, underP, c.edges]);
1612
1733
  return { body, under, edges, cond: c.condLines, condl: condlB, condlu: condlU, studs: c.studs.map((s) => [s.kind, s.color, ...s.m.map((x) => +x.toFixed(4))]), bbox: c.bbox, fullBottom: c.fullBottom, uncert: !!c.uncertified, mask: c.bottomMask ? [c.bottomMask.nx, c.bottomMask.nz, c.bottomMask.bits.join("")] : null, ...encTex(c) };
1613
1734
  };
1614
- const encParts = Object.fromEntries([...parts].map(([k, c]) => [k, enc(c)])), encKinds = Object.fromEntries([...studKinds].map(([k, c]) => [k, enc(c)]));
1735
+ const encParts = Object.fromEntries([...parts].map(([k, c]) => [k, enc(c, true)])), encKinds = Object.fromEntries([...studKinds].map(([k, c]) => [k, enc(c)]));
1615
1736
  const textures = texNames.map((name) => {
1616
1737
  const b = options.texture?.(name);
1617
1738
  return b ? { name, mime: "image/png", data: put(b) } : { name, missing: true };
1618
1739
  });
1619
- const features = [...anyCond ? ["cond"] : [], ...texNames.length ? ["tex"] : []];
1740
+ const features = [...anyCond ? ["cond"] : [], ...texNames.length ? ["tex"] : [], ...anyLod ? ["lod"] : []];
1620
1741
  const header = { version: features.length ? 2 : 1, ...features.length ? { features } : {}, weld, parts: encParts, studKinds: encKinds, ...texNames.length ? { textures } : {}, ...extra };
1621
1742
  const hj = new TextEncoder().encode(JSON.stringify(header));
1622
1743
  const hpad = (4 - (4 + hj.length) % 4) % 4;
@@ -1627,7 +1748,7 @@ function encodePack(parts, studKinds, weld, extra, options = {}) {
1627
1748
  return Buffer.concat([head, ...chunks.map((c) => new Uint8Array(c.buffer, c.byteOffset, c.byteLength))]);
1628
1749
  }
1629
1750
 
1630
- // src/node/build-pack.ts
1751
+ // ../../../../../../../tmp/ldr-build-qIdKSv/src/node/build-pack.ts
1631
1752
  function buildPack(o) {
1632
1753
  const lib = new Library(o.ldrawRoot);
1633
1754
  const refs = new Map;
@@ -1687,10 +1808,10 @@ function buildPack(o) {
1687
1808
  return b ? new Uint8Array(Buffer.from(b, "base64")) : comp.texture(n);
1688
1809
  };
1689
1810
  const warnings = [...lib.conflicts].map((n) => `${n}: defined differently by two models; the first definition is used`);
1690
- return { data: encodePack(parts, kinds, comp.opts.weld, extra, { texture }), parts: parts.size, studKinds: kinds.size, missing: [...missing], ...warnings.length ? { warnings } : {} };
1811
+ return { data: encodePack(parts, kinds, comp.opts.weld, extra, { texture, lod: o.lod !== false, reorder: !!o.reorder }), parts: parts.size, studKinds: kinds.size, missing: [...missing], ...warnings.length ? { warnings } : {} };
1691
1812
  }
1692
1813
 
1693
- // src/cli.ts
1814
+ // ../../../../../../../tmp/ldr-build-qIdKSv/src/cli.ts
1694
1815
  var HELP = `ldr pack — build a parts pack for the ldr three.js renderer
1695
1816
 
1696
1817
  Usage:
@@ -1707,6 +1828,8 @@ Options:
1707
1828
  --cond Keep conditional (type-5) lines for curved-surface outlines (pack v2, ~+8-14% zstd)
1708
1829
  --textures Keep printed/sticker parts' !TEXMAP geometry + UVs and embed their PNGs (pack v2; packs without
1709
1830
  textured parts are unchanged). Textures come from model !DATA blocks, then <ldraw>/parts/textures
1831
+ --no-lod Skip distance-LOD meshes (simplified bodies drawn only where their error is under a pixel)
1832
+ --reorder Reorder triangles/vertices for vertex-cache reuse (~-3% frame time without LOD, +1% size)
1710
1833
  --no-colors Don't embed the LDConfig colour table
1711
1834
  --no-loose-geometry Skip a model file's own type 2-5 lines (drawn in the model, not in a part). By default they become
1712
1835
  synthetic parts "<file>#<step>" so the model renders as the LDraw spec says
@@ -1729,6 +1852,8 @@ var all = false;
1729
1852
  var cond = false;
1730
1853
  var textures = false;
1731
1854
  var loose = true;
1855
+ var lod = true;
1856
+ var reorder = false;
1732
1857
  for (let i = 0;i < a.length; i++) {
1733
1858
  const k = a[i];
1734
1859
  const v = () => {
@@ -1761,6 +1886,10 @@ for (let i = 0;i < a.length; i++) {
1761
1886
  loose = false;
1762
1887
  else if (k === "--all-parts")
1763
1888
  all = true;
1889
+ else if (k === "--no-lod")
1890
+ lod = false;
1891
+ else if (k === "--reorder")
1892
+ reorder = true;
1764
1893
  else if (k.startsWith("-")) {
1765
1894
  console.error(`unknown option ${k}
1766
1895
 
@@ -1778,7 +1907,7 @@ if (!fs4.existsSync(ldraw + "/LDConfig.ldr")) {
1778
1907
  process.exit(1);
1779
1908
  }
1780
1909
  var t0 = performance.now();
1781
- var r = buildPack({ ldrawRoot: ldraw, models, parts, allParts: all, studs, primitives: prims, weld, colors, cond, textures, looseGeometry: loose });
1910
+ var r = buildPack({ ldrawRoot: ldraw, models, parts, allParts: all, studs, primitives: prims, weld, colors, cond, textures, looseGeometry: loose, lod, reorder });
1782
1911
  var data = r.data;
1783
1912
  if (out.endsWith(".gz"))
1784
1913
  data = zlib.gzipSync(data, { level: 9 });
package/dist/index.d.ts CHANGED
@@ -2,8 +2,10 @@ export { parse, parseColor, isDirectColor, mul, I as IDENTITY, norm, smoothNorma
2
2
  export type { M as Matrix12, Doc, LFile, Cmd, RotStep, BufExchg, TexMap, TexMapCmd, TexMapMethod, TexAnno, DataBlock, PartMesh, FileKind, StepView } from "./spec.js";
3
3
  export { flatten, parseModel, sequenceSteps, parseLDConfig } from "./model.js";
4
4
  export type { PartInstance, FlattenOptions, LDrawColor } from "./model.js";
5
- export { loadPack, decodePack, buildLdrScene, makeMaterial, createInstancedEdges, computeCulling, conditionalLines, createInstancedConditionalEdges, conditionalLineVisible } from "./render.js";
6
- export type { Pack, LdrParams, Inst } from "./render.js";
5
+ export { loadPack, decodePack, buildLdrScene, makeMaterial, createInstancedEdges, computeCulling, cullCore, forEachStud, conditionalLines, createInstancedConditionalEdges, conditionalLineVisible } from "./render.js";
6
+ export type { Pack, LdrParams, Inst, CullResult } from "./render.js";
7
7
  export { createLdrObject } from "./scene.js";
8
8
  export type { LdrObject, LdrObjectOptions, LdrStats, ColorSource } from "./scene.js";
9
9
  export { decompressPack } from "./pack-io.js";
10
+ export { loadModel, loadParts, loadPartsIndex, mergePacks, DEFAULT_PARTS_CDN } from "./cdn.js";
11
+ export type { LoadModelOptions, LoadedModel, LoadPartsOptions, PartsCdnIndex } from "./cdn.js";