ldr 0.32.0 → 0.33.1

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
@@ -139,7 +139,7 @@ MPD-embedded parts, such as Studio/OMR unofficial parts or stickers, become part
139
139
  | `ldrawSpace` | `false` | `true` keeps native LDraw space (-Y up). |
140
140
  | `scale` | `1` | Uniform scale (1 = 1 LDU; 1 stud = 20 LDU). |
141
141
  | `step` | all | Initial build step (inclusive, 0-based). |
142
- | `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`. |
142
+ | `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`, swaps closed studs to a 10-triangle square stud then a flat disc, and drops them once their 12-LDU disc is under `lodPx`, and hides edges where a stud spans less than `lodEdgePx`. |
143
143
  | `lodPx` | `0.5` | LOD: maximum screen-space error in pixels. |
144
144
  | `lodEdgePx` | `0.5` | LOD: draw edge and conditional lines only where one stud (20 LDU) spans at least this many pixels. `0` = always. |
145
145
  | `lodEdgeMinPx` | `0.4` | LOD: draw only edge segments at least this many pixels long on screen. `0` = every segment. |
@@ -190,7 +190,7 @@ Sources: `bench/results/2026-10-18T18-10-46-518Z-falcon-vs-stock.md` (the Falcon
190
190
  | Millennium Falcon ×19 | 100,054 | 54.2 ms | **19.6 ms** | 2.04 s | **0.38 s** |
191
191
  | Millennium Falcon ×75 | 394,950 | 204.7 ms | **29.2 ms** | 2.40 s | **1.03 s** |
192
192
 
193
- The benchmark model is a fleet: the LDraw OMR model of set 10179 (UCS Millennium Falcon, 5,266 parts, 251 unique) placed 2, 19 or 75 times on a grid (`apps/demo-site/scripts/build-fleet.ts`). It is a hard case for ldr: dense, detailed ships stay large on screen, so distance LOD can't drop much and the overview still draws ~27M triangles (32M before Round 62 measured LOD distance from each cluster's box); on two ships ldr only matches stock instancing per frame (it still loads 14× faster). Datsville, the benchmark until Round 60 (a flat town, 392,514 bricks, mostly baseplates), went from 409.6 to 12.5 ms per frame and 24.7 to 1.6 s load.
193
+ The benchmark model is a fleet: the LDraw OMR model of set 10179 (UCS Millennium Falcon, 5,266 parts, 251 unique) placed 2, 19 or 75 times on a grid (`apps/demo-site/scripts/build-fleet.ts`). It is a hard case for ldr: dense, detailed ships stay large on screen, so distance LOD can't drop much and the overview still draws ~22M triangles (32M before Round 62's cluster-box distances, 27M before Round 63's square-stud mid LOD); on two ships ldr only matches stock instancing per frame (it still loads 14× faster). Datsville, the benchmark until Round 60 (a flat town, 392,514 bricks, mostly baseplates), went from 409.6 to 12.5 ms per frame and 24.7 to 1.6 s load.
194
194
 
195
195
  How much each optimization contributed on Datsville 100k (Rounds 1–20, before the Falcon fleets), 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 (Rounds 19–20: simplified and bounding-box levels with a silhouette-coverage error, sub-pixel studs dropped, undersides skipped where they can't be seen) then takes it to 4.4 ms and 4.0M triangles; ≤ 1.5% of pixels differ from full detail (mostly sub-pixel stud moiré).
196
196
 
@@ -209,7 +209,7 @@ Reproduce: `cd bench && bun run setup && bun run perf` (regression gate) or `bun
209
209
 
210
210
  ## LDraw spec compliance
211
211
 
212
- - Line types 0–5. BFC: `CERTIFY`, `CCW`/`CW`, `INVERTNEXT`, `NOCLIP`, inverted matrices.
212
+ - Line types 0–5. BFC (ldraw.org article 415): `CERTIFY`/`NOCERTIFY` (decided by the first BFC statement before the file's first type 1-5 line, so `0 BFC CCW` alone certifies and a late `CERTIFY` doesn't), `CCW`/`CW`, `CLIP`/`NOCLIP` (polygons drawn while culling is off are visible from both sides, including in subfiles referenced then, which can't turn it back on), `INVERTNEXT` (the immediately following subfile reference only), inverted matrices.
213
213
  - 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.
214
214
  - Geometry drawn directly in model files (type 2–5 lines outside parts), on by default.
215
215
  - `0 STEP`, nested submodel step sequencing, `0 ROTSTEP` (`REL`/`ADD`/`ABS`/`END`), and `0 BUFEXCHG`.
package/dist/cli.js CHANGED
@@ -1,18 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // ../../../../../../../tmp/ldr-build-MGO8Dx/src/cli.ts
3
+ // ../../../../../../../tmp/ldr-build-78cdvu/src/cli.ts
4
4
  import fs4 from "node:fs";
5
5
  import zlib from "node:zlib";
6
6
 
7
- // ../../../../../../../tmp/ldr-build-MGO8Dx/src/node/build-pack.ts
7
+ // ../../../../../../../tmp/ldr-build-78cdvu/src/node/build-pack.ts
8
8
  import fs3 from "node:fs";
9
9
  import path3 from "node:path";
10
10
 
11
- // ../../../../../../../tmp/ldr-build-MGO8Dx/src/node/resolver.ts
11
+ // ../../../../../../../tmp/ldr-build-78cdvu/src/node/resolver.ts
12
12
  import fs from "node:fs";
13
13
  import path from "node:path";
14
14
 
15
- // ../../../../../../../tmp/ldr-build-MGO8Dx/src/spec.ts
15
+ // ../../../../../../../tmp/ldr-build-78cdvu/src/spec.ts
16
16
  var norm = (s) => s.trim().toLowerCase().replace(/\\/g, "/").replace(/\/+/g, "/");
17
17
  var COLOUR_ID_RE = /^[A-Za-z][A-Za-z0-9_]*$/;
18
18
  var namedCodes = new Map;
@@ -723,7 +723,7 @@ function fileStepViews(f) {
723
723
  return out;
724
724
  }
725
725
 
726
- // ../../../../../../../tmp/ldr-build-MGO8Dx/src/model.ts
726
+ // ../../../../../../../tmp/ldr-build-78cdvu/src/model.ts
727
727
  var CALLOUT_END = 999999;
728
728
  var stepKey = (steps) => steps.map((x) => String(x).padStart(6, "0")).join(".") + "~";
729
729
  var defLine = new WeakMap;
@@ -1138,7 +1138,7 @@ function blendedColourHex(code) {
1138
1138
  return "#" + [0, 1, 2].map((k) => (a[k] + b[k] >> 1).toString(16).padStart(2, "0")).join("").toUpperCase();
1139
1139
  }
1140
1140
 
1141
- // ../../../../../../../tmp/ldr-build-MGO8Dx/src/node/resolver.ts
1141
+ // ../../../../../../../tmp/ldr-build-78cdvu/src/node/resolver.ts
1142
1142
  var TOP_STUD_RE = /^(stud|studa|stud2|stud2a|stud6|stud6a|stud10|stud15|stud2s|stud2s2|stud-logo\d*|stud2-logo\d*)\.dat$/i;
1143
1143
  var UNDER_STUD_RE = /^(stud3|stud3a|stud4.*|stud12|stud16.*|stud18a)\.dat$/i;
1144
1144
  var norm2 = (ref) => ref.trim().replace(/\\/g, "/").toLowerCase();
@@ -1465,11 +1465,11 @@ function collectDeps(lib, refs) {
1465
1465
  return out;
1466
1466
  }
1467
1467
 
1468
- // ../../../../../../../tmp/ldr-build-MGO8Dx/src/node/compile.ts
1468
+ // ../../../../../../../tmp/ldr-build-78cdvu/src/node/compile.ts
1469
1469
  import fs2 from "node:fs";
1470
1470
  import path2 from "node:path";
1471
1471
 
1472
- // ../../../../../../../tmp/ldr-build-MGO8Dx/src/node/lod.ts
1472
+ // ../../../../../../../tmp/ldr-build-78cdvu/src/node/lod.ts
1473
1473
  import { MeshoptSimplifier, MeshoptEncoder } from "meshoptimizer";
1474
1474
  await Promise.all([MeshoptSimplifier.ready, MeshoptEncoder.ready]);
1475
1475
  function reorderMesh(P, N, C, I) {
@@ -1661,7 +1661,55 @@ function lodChain(P, C, I) {
1661
1661
  return out;
1662
1662
  }
1663
1663
 
1664
- // ../../../../../../../tmp/ldr-build-MGO8Dx/src/node/compile.ts
1664
+ // ../../../../../../../tmp/ldr-build-78cdvu/src/node/compile.ts
1665
+ class BfcState {
1666
+ accum;
1667
+ ccw = true;
1668
+ certified = null;
1669
+ local = true;
1670
+ inv = false;
1671
+ constructor(accum = true) {
1672
+ this.accum = accum;
1673
+ }
1674
+ meta(t) {
1675
+ const o = t.split(/\s+/).slice(2);
1676
+ this.inv = false;
1677
+ if (this.certified === null)
1678
+ this.certified = !o.includes("NOCERTIFY");
1679
+ if (!this.certified)
1680
+ return;
1681
+ for (const w of o) {
1682
+ if (w === "CW")
1683
+ this.ccw = false;
1684
+ else if (w === "CCW")
1685
+ this.ccw = true;
1686
+ else if (w === "CLIP")
1687
+ this.local = true;
1688
+ else if (w === "NOCLIP")
1689
+ this.local = false;
1690
+ else if (w === "INVERTNEXT")
1691
+ this.inv = true;
1692
+ }
1693
+ }
1694
+ line(type) {
1695
+ if (type !== "0" && this.certified === null)
1696
+ this.certified = false;
1697
+ if (type !== "1")
1698
+ this.inv = false;
1699
+ }
1700
+ takeInvert() {
1701
+ const v = this.inv;
1702
+ this.inv = false;
1703
+ return v;
1704
+ }
1705
+ get cull() {
1706
+ return this.accum && this.local;
1707
+ }
1708
+ }
1709
+ var windOrder = (n, flip, cull) => {
1710
+ const f = n === 3 ? [0, 1, 2] : [0, 1, 2, 0, 2, 3], b = n === 3 ? [0, 2, 1] : [0, 2, 1, 0, 3, 2];
1711
+ return cull ? flip ? b : f : [...f, ...b];
1712
+ };
1665
1713
  function loadColors(root) {
1666
1714
  const map = new Map;
1667
1715
  for (const l of fs2.readFileSync(path2.join(root, "LDConfig.ldr"), "latin1").split(/\r?\n/)) {
@@ -1731,54 +1779,48 @@ class Compiler {
1731
1779
  return this.cache.get(ck);
1732
1780
  const out = { body: { pos: [], col: [] }, under: { pos: [], col: [] }, edges: [], condLines: 0, cond: this.opts.cond ? [] : undefined, condUnder: this.opts.cond ? [] : undefined, studs: [], bbox: [Infinity, Infinity, Infinity, -Infinity, -Infinity, -Infinity], fullBottom: false };
1733
1781
  const colorOf = (code) => code === "16" ? [1, 1, 1, 0] : [...this.colors.get(code) || (code.startsWith("0x2") ? [parseInt(code.slice(3, 5), 16) / 255, parseInt(code.slice(5, 7), 16) / 255, parseInt(code.slice(7, 9), 16) / 255] : blendedRgb(code) ?? [1, 0, 1]), 1];
1734
- const walk = (fk, M, color, invert, target, depth, inh = null, scope0 = null) => {
1782
+ const walk = (fk, M, color, invert, target, depth, inh = null, scope0 = null, cull = true) => {
1735
1783
  if (depth > 40)
1736
1784
  return;
1737
- let ccw = true, certified = false, invertNext = false, scope = scope0;
1785
+ const bfc = new BfcState(cull);
1786
+ let scope = scope0;
1738
1787
  for (const raw of this.lib.read(fk).split(/\r?\n/)) {
1739
1788
  const t = raw.trim();
1740
1789
  if (!t)
1741
1790
  continue;
1742
1791
  const c = t[0];
1792
+ if (c === "0" && /^0\s+BFC(\s|$)/.test(t)) {
1793
+ bfc.meta(t);
1794
+ continue;
1795
+ }
1796
+ bfc.line(c);
1743
1797
  if (c === "0") {
1744
1798
  if (t.includes("!COLOUR") && COLOUR_RE.test(t)) {
1745
1799
  scope = defineColour(t, scope);
1746
1800
  continue;
1747
1801
  }
1748
- if (/^0\s+BFC/.test(t)) {
1749
- if (/CERTIFY/.test(t) && !/NOCERTIFY/.test(t))
1750
- certified = true;
1751
- if (/\bCW\b/.test(t))
1752
- ccw = false;
1753
- if (/\bCCW\b/.test(t))
1754
- ccw = true;
1755
- if (/INVERTNEXT/.test(t))
1756
- invertNext = true;
1757
- }
1758
1802
  continue;
1759
1803
  }
1760
1804
  if (c === "1") {
1805
+ const invertNext = bfc.takeInvert();
1761
1806
  const r = parseType1(t);
1762
1807
  if (!r)
1763
1808
  continue;
1764
1809
  const base = r.name.trim().replace(/\\/g, "/").split("/").pop().toLowerCase();
1765
1810
  const isTop = TOP_STUD_RE.test(base), isUnder = UNDER_STUD_RE.test(base);
1766
1811
  const ck2 = this.resolve(r.name, isTop);
1767
- if (!ck2) {
1768
- invertNext = false;
1812
+ if (!ck2)
1769
1813
  continue;
1770
- }
1771
1814
  const childColor = r.color === "16" ? color : r.color, childInh = r.color === "16" ? inh : this.local(r.color, scope);
1772
1815
  const Mc = compose(M, r.m);
1773
1816
  const inv = invert !== invertNext !== det3(r.m) < 0;
1774
- invertNext = false;
1775
1817
  if (isTop && separateStuds) {
1776
1818
  out.studs.push({ kind: ck2, color: childInh ? this.studToken(childColor, childInh) : childColor, m: Mc });
1777
1819
  fullKind.set(out.studs.length - 1, this.lib.resolve(r.name));
1778
1820
  growBox(out.bbox, Mc, this.compile(ck2, false).bbox);
1779
1821
  continue;
1780
1822
  }
1781
- walk(ck2, Mc, childColor, inv, isUnder ? "under" : target, depth + 1, childInh, scope);
1823
+ walk(ck2, Mc, childColor, inv, isUnder ? "under" : target, depth + 1, childInh, scope, bfc.cull);
1782
1824
  continue;
1783
1825
  }
1784
1826
  const tok = t.split(/\s+/);
@@ -1806,22 +1848,40 @@ class Compiler {
1806
1848
  pts.push(xf(M, v.slice(i * 3, i * 3 + 3)));
1807
1849
  for (const p of pts)
1808
1850
  grow(out.bbox, p);
1809
- let order = n === 3 ? [0, 1, 2] : [0, 1, 2, 0, 2, 3];
1810
- if (!ccw !== invert)
1811
- order = n === 3 ? [0, 2, 1] : [0, 2, 1, 0, 3, 2];
1812
- const rgba = this.rgbaAt(tok[1], color, inh, scope, colorOf);
1813
- const b = out[target];
1814
- for (const i of order) {
1851
+ const flip = !bfc.ccw !== invert, rgba = this.rgbaAt(tok[1], color, inh, scope, colorOf), b = out[target];
1852
+ for (const i of windOrder(n, flip, true)) {
1815
1853
  b.pos.push(...pts[i]);
1816
1854
  b.col.push(...rgba);
1817
1855
  }
1856
+ if (bfc.cull)
1857
+ culled++;
1858
+ else {
1859
+ const bb = back[target];
1860
+ for (const i of windOrder(n, !flip, true)) {
1861
+ bb.pos.push(...pts[i]);
1862
+ bb.col.push(...rgba);
1863
+ }
1864
+ }
1818
1865
  }
1819
1866
  }
1820
- if (!certified)
1867
+ if (!bfc.certified)
1821
1868
  out.uncertified = true;
1822
1869
  };
1823
1870
  const fullKind = new Map;
1871
+ let culled = 0;
1872
+ const back = { body: { pos: [], col: [] }, under: { pos: [], col: [] } };
1824
1873
  walk(key, IDENT, "16", false, "body", 0);
1874
+ if (back.body.pos.length || back.under.pos.length) {
1875
+ if (!culled)
1876
+ out.uncertified = true;
1877
+ if (!out.uncertified)
1878
+ for (const t of ["body", "under"]) {
1879
+ for (const x of back[t].pos)
1880
+ out[t].pos.push(x);
1881
+ for (const x of back[t].col)
1882
+ out[t].col.push(x);
1883
+ }
1884
+ }
1825
1885
  seatStuds(out, fullKind, (k) => this.compile(k, false).bbox);
1826
1886
  if (this.opts.tex && this.hasTex(key))
1827
1887
  this.texPass(key, out, separateStuds, colorOf);
@@ -1906,10 +1966,11 @@ class Compiler {
1906
1966
  }
1907
1967
  texPass(key, out, separateStuds, colorOf) {
1908
1968
  const tbody = { pos: [], col: [] }, texb = new Map;
1909
- const walk = (fk, M, color, invert, target, depth, texIn, inh = null, scope0 = null) => {
1969
+ const walk = (fk, M, color, invert, target, depth, texIn, inh = null, scope0 = null, cull = true) => {
1910
1970
  if (depth > 40)
1911
1971
  return;
1912
- let ccw = true, invertNext = false, scope = scope0;
1972
+ const bfc = new BfcState(cull);
1973
+ let scope = scope0;
1913
1974
  let stack = [];
1914
1975
  let next = null;
1915
1976
  for (const raw of this.lib.read(fk).split(/\r?\n/)) {
@@ -1920,7 +1981,13 @@ class Compiler {
1920
1981
  if (t[0] === "0") {
1921
1982
  const m = /^0\s+(\S+)\s*(.*)$/.exec(t);
1922
1983
  const kw = (m?.[1] ?? "").toUpperCase(), rest = m?.[2] ?? "";
1984
+ if (m?.[1] === "BFC") {
1985
+ bfc.meta(t);
1986
+ next = null;
1987
+ continue;
1988
+ }
1923
1989
  if (kw === "!TEXMAP") {
1990
+ bfc.line("0");
1924
1991
  const op = (rest.split(/\s+/)[0] || "").toUpperCase();
1925
1992
  next = null;
1926
1993
  if (op === "START" || op === "NEXT") {
@@ -1944,25 +2011,23 @@ class Compiler {
1944
2011
  next = null;
1945
2012
  t = rest.trim();
1946
2013
  colon = true;
1947
- if (!t || t[0] === "0")
2014
+ if (!t || t[0] === "0") {
2015
+ bfc.line("0");
1948
2016
  continue;
2017
+ }
1949
2018
  } else {
2019
+ bfc.line("0");
1950
2020
  if (kw === "!COLOUR")
1951
2021
  scope = defineColour(t, scope);
1952
- if (kw === "BFC") {
1953
- if (/\bCW\b/.test(t))
1954
- ccw = false;
1955
- if (/\bCCW\b/.test(t))
1956
- ccw = true;
1957
- if (/INVERTNEXT/.test(t))
1958
- invertNext = true;
1959
- }
1960
2022
  if (kw === "STEP")
1961
2023
  stack = [];
1962
2024
  next = null;
1963
2025
  continue;
1964
2026
  }
1965
2027
  }
2028
+ const c = t[0];
2029
+ bfc.line(c);
2030
+ const invertNext = c === "1" && bfc.takeInvert();
1966
2031
  let tex = texIn;
1967
2032
  if (next) {
1968
2033
  tex = next;
@@ -1977,7 +2042,6 @@ class Compiler {
1977
2042
  tex = top.map;
1978
2043
  }
1979
2044
  }
1980
- const c = t[0];
1981
2045
  if (c === "1") {
1982
2046
  const r = parseType1(t);
1983
2047
  if (!r)
@@ -1985,15 +2049,12 @@ class Compiler {
1985
2049
  const base = r.name.trim().replace(/\\/g, "/").split("/").pop().toLowerCase();
1986
2050
  const isTop = TOP_STUD_RE.test(base), isUnder = UNDER_STUD_RE.test(base);
1987
2051
  const ck2 = this.resolve(r.name, isTop);
1988
- if (!ck2) {
1989
- invertNext = false;
2052
+ if (!ck2)
1990
2053
  continue;
1991
- }
1992
2054
  const inv = invert !== invertNext !== det3(r.m) < 0;
1993
- invertNext = false;
1994
2055
  if (isTop && separateStuds)
1995
2056
  continue;
1996
- walk(ck2, compose(M, r.m), r.color === "16" ? color : r.color, inv, isUnder ? "under" : target, depth + 1, tex, r.color === "16" ? inh : this.local(r.color, scope), scope);
2057
+ walk(ck2, compose(M, r.m), r.color === "16" ? color : r.color, inv, isUnder ? "under" : target, depth + 1, tex, r.color === "16" ? inh : this.local(r.color, scope), scope, bfc.cull);
1997
2058
  continue;
1998
2059
  }
1999
2060
  if (c !== "3" && c !== "4")
@@ -2004,9 +2065,7 @@ class Compiler {
2004
2065
  const pts = [];
2005
2066
  for (let i = 0;i < n; i++)
2006
2067
  pts.push(xf(M, v.slice(i * 3, i * 3 + 3)));
2007
- let order = n === 3 ? [0, 1, 2] : [0, 1, 2, 0, 2, 3];
2008
- if (!ccw !== invert)
2009
- order = n === 3 ? [0, 2, 1] : [0, 2, 1, 0, 3, 2];
2068
+ const order = windOrder(n, !bfc.ccw !== invert, bfc.cull || !!out.uncertified);
2010
2069
  const rgba = this.rgbaAt(tok[1], color, inh, scope, colorOf);
2011
2070
  if (tex) {
2012
2071
  const tk = tex.glossmap ? tex.texture + "\x00" + tex.glossmap : tex.texture;
@@ -2376,7 +2435,7 @@ function encodePack(parts, studKinds, weld, extra, options = {}) {
2376
2435
  return Buffer.concat([head, ...chunks.map((c) => new Uint8Array(c.buffer, c.byteOffset, c.byteLength))]);
2377
2436
  }
2378
2437
 
2379
- // ../../../../../../../tmp/ldr-build-MGO8Dx/src/node/build-pack.ts
2438
+ // ../../../../../../../tmp/ldr-build-78cdvu/src/node/build-pack.ts
2380
2439
  function packCompilerOpts(o) {
2381
2440
  return { studs: o.studs ?? "low", prims: o.primitives ?? "full", weld: o.weld ?? true, separateStuds: true, cond: !!o.cond, tex: !!o.textures };
2382
2441
  }
@@ -2449,7 +2508,7 @@ function buildPack(o) {
2449
2508
  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 } : {} };
2450
2509
  }
2451
2510
 
2452
- // ../../../../../../../tmp/ldr-build-MGO8Dx/src/cli.ts
2511
+ // ../../../../../../../tmp/ldr-build-78cdvu/src/cli.ts
2453
2512
  var HELP = `ldr pack — build a parts pack for the ldr three.js renderer
2454
2513
 
2455
2514
  Usage: