@three-flatland/normals 0.1.0-alpha.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Justin Walsh
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,223 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/thejustinwalsh/three-flatland/main/assets/repo-banner.png" alt="three-flatland" width="100%" />
3
+ </p>
4
+
5
+ # @three-flatland/normals
6
+
7
+ Offline + runtime normal map generation for sprites and tilesets. Contributes a `normal` subcommand to the [`flatland-bake`](https://www.npmjs.com/package/@three-flatland/bake) CLI and ships a browser-safe `NormalMapLoader` used by [three-flatland](https://www.npmjs.com/package/three-flatland).
8
+
9
+ > **Alpha Release** — this package is in active development. The API will evolve and breaking changes are expected between releases. Pin your version and check the [changelog](https://github.com/thejustinwalsh/three-flatland/releases) before upgrading.
10
+
11
+ [![npm](https://img.shields.io/npm/v/@three-flatland/normals)](https://www.npmjs.com/package/@three-flatland/normals)
12
+ [![license](https://img.shields.io/npm/l/@three-flatland/normals)](https://github.com/thejustinwalsh/three-flatland/blob/main/LICENSE)
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ npm install @three-flatland/normals @three-flatland/bake
18
+ ```
19
+
20
+ ### Requirements
21
+
22
+ - **three** >= 0.183.1 (peer, WebGPU/TSL)
23
+ - **[@three-flatland/bake](https://www.npmjs.com/package/@three-flatland/bake)** — CLI dispatcher that hosts the `normal` subcommand
24
+
25
+ ## Quick Start
26
+
27
+ ### Bake a normal map from the CLI
28
+
29
+ ```bash
30
+ npx flatland-bake normal public/sprites/knight.png
31
+ ```
32
+
33
+ Writes `public/sprites/knight.normal.png` next to the source. Ship the baked PNG; the runtime loader picks it up automatically.
34
+
35
+ ### Load a normal map at runtime
36
+
37
+ With three-flatland, pass `normals: true` to any texture-producing loader — the baked sibling is discovered and attached for you. Outside three-flatland, use `NormalMapLoader` directly:
38
+
39
+ ```typescript
40
+ import { NormalMapLoader } from '@three-flatland/normals'
41
+
42
+ const normalMap = await NormalMapLoader.load('/sprites/knight.png')
43
+ if (normalMap) material.normalMap = normalMap
44
+ ```
45
+
46
+ `NormalMapLoader` resolves the `.normal.png` sibling via HEAD probe and returns `null` when it's missing so you can fall back to an unlit material or a runtime bake.
47
+
48
+ ## Options
49
+
50
+ ### CLI flags
51
+
52
+ ```
53
+ flatland-bake normal <input.png> [output.png] [--strength <n>]
54
+ flatland-bake normal <input.png> --descriptor <input.normal.json>
55
+ ```
56
+
57
+ | Flag | Description |
58
+ |---|---|
59
+ | `--strength <n>` | Gradient multiplier before normalization (default `1`) |
60
+ | `--descriptor <path>` | Region-aware descriptor JSON — per-frame / per-tile control for atlases and tilemaps |
61
+
62
+ See the [three-flatland docs](https://thejustinwalsh.com/three-flatland/) for descriptor examples covering sprite sheets, LDtk tilesets, and directional (3/4-view wall) tiles.
63
+
64
+ ### Loader options
65
+
66
+ ```typescript
67
+ // Generate this asset's normal map in the browser on every load instead
68
+ // of loading a pre-baked sidecar. You still get the same normal-map data;
69
+ // this flag just commits to "the browser is where it's produced." For
70
+ // procedurally varied content, throwaway prototypes, lean bundles.
71
+ // Not a dev-iteration knob — the default path (probe → bake on miss +
72
+ // warn) already handles iteration. Mirrors `SlugFontLoader.forceRuntime`.
73
+ const tex = await NormalMapLoader.load(url, { forceRuntime: true })
74
+ ```
75
+
76
+ ## Authoring tiles for LDtk / Tiled
77
+
78
+ Per-tile custom data drives the tileset's baked normals. The `LDtkLoader` and `TiledLoader` read these fields from each tile's properties panel and synthesize regions for the baker.
79
+
80
+ ### Atlas encoding (what's in the output PNG)
81
+
82
+ | Channel | Meaning |
83
+ |---|---|
84
+ | R | `nx` — normal X component, `[-1, 1]` → `[0, 255]` |
85
+ | G | `ny` — normal Y component, `[-1, 1]` → `[0, 255]` |
86
+ | B | `elevation` — world-space Z in `[0, 1]`, 0 = ground, 1 = top-of-wall |
87
+ | A | source alpha (silhouette) |
88
+
89
+ Runtime reconstructs `nz = sqrt(max(0, 1 − nx² − ny²))` — outward-facing convention, no sign ambiguity.
90
+
91
+ ### Direction vocabulary
92
+
93
+ ```
94
+ 'flat' — no tilt, normal = (0, 0, 1)
95
+ 'up' | 'north' — tilts toward top of screen
96
+ 'down' | 'south' — toward bottom
97
+ 'left' | 'west' — toward left
98
+ 'right' | 'east' — toward right
99
+ 'up-left' | 'north-west' — diagonals at π/4
100
+ 'up-right' | 'north-east'
101
+ 'down-left' | 'south-west'
102
+ 'down-right' | 'south-east'
103
+ <number> — raw radians, 0 = +X, CCW positive
104
+ ```
105
+
106
+ Aliases are equivalent; use whichever reads better for your team.
107
+
108
+ ### JSON recipes
109
+
110
+ **Standard floor tile**
111
+
112
+ ```json
113
+ {}
114
+ ```
115
+
116
+ Or leave untagged. Default elevation 0, flat normal, torch-lit.
117
+
118
+ **All-cap tile** (wall top viewed dead-on, roof patch, pillar cap)
119
+
120
+ ```json
121
+ {"tileElevation": 1}
122
+ ```
123
+
124
+ Whole cell is flat at cap height. Torch-dark from ground-level lights, ambient-lit only.
125
+
126
+ **Top-of-map wall** (cap at top of art, face below)
127
+
128
+ ```json
129
+ {"tileDir": "south", "tileCapTop": 4}
130
+ ```
131
+
132
+ Cap auto-elevation = 1, face auto-elevation = 0.5.
133
+
134
+ **Bottom-of-map wall** (cap at bottom of art, face above)
135
+
136
+ ```json
137
+ {"tileDir": "north", "tileCapBottom": 4}
138
+ ```
139
+
140
+ **Side walls** (cap strip runs along one vertical edge of the art)
141
+
142
+ ```json
143
+ {"tileDir": "east", "tileCapBottom": 4} // left-of-map wall
144
+ {"tileDir": "west", "tileCapBottom": 4} // right-of-map wall
145
+ ```
146
+
147
+ **Outer corners** (L-shaped cap + diagonal face)
148
+
149
+ ```json
150
+ {"tileDir": "south-east", "tileCapTop": 4, "tileCapLeft": 4} // NW corner
151
+ {"tileDir": "south-west", "tileCapTop": 4, "tileCapRight": 4} // NE corner
152
+ {"tileDir": "north-east", "tileCapBottom": 4, "tileCapLeft": 4} // SW corner
153
+ {"tileDir": "north-west", "tileCapBottom": 4, "tileCapRight": 4} // SE corner
154
+ ```
155
+
156
+ **Outer-facing corner** (small square cap, face wraps around as L-shape)
157
+
158
+ ```json
159
+ {"tileDir": "south-east", "tileCapTopLeft": 4}
160
+ ```
161
+
162
+ Four variants: `tileCapTopLeft` / `tileCapTopRight` / `tileCapBottomLeft` / `tileCapBottomRight`. Face auto-decomposes into 2–3 rectangles around the corner cap square.
163
+
164
+ **Half-wall / low partition**
165
+
166
+ ```json
167
+ {"tileDir": "south", "tileCapTop": 4, "tileElevation": 0.3}
168
+ ```
169
+
170
+ Face sits lower on the wall so ground-level torches light the face more directly. Cap still at 1.
171
+
172
+ **Tall pillar / raised architecture**
173
+
174
+ ```json
175
+ {"tileDir": "south", "tileCapTop": 4, "tileElevation": 0.9}
176
+ ```
177
+
178
+ Face sits near cap height — torches barely light the face, reads as extremely tall.
179
+
180
+ **Brick/stone surfaces with cracks sunken** (non-alpha bump)
181
+
182
+ ```json
183
+ {"tileDir": "south", "tileCapTop": 4, "tileBump": "luminance", "tileStrength": 1.5}
184
+ ```
185
+
186
+ Bright brick faces raise, dark mortar sinks. Works on opaque tiles where alpha is 1 everywhere.
187
+
188
+ ### Per-tile custom data field reference
189
+
190
+ | Field | Type | Default | Meaning |
191
+ |---|---|---|---|
192
+ | `tileDir` | direction alias or radian | `'flat'` | Which way the face tilts |
193
+ | `tileDirection` | direction alias or radian | — | Alias of `tileDir` |
194
+ | `tileCap` | px | 0 | Shorthand — same as `tileCapTop` when no per-edge field set |
195
+ | `tileCapTop` / `tileCapBottom` / `tileCapLeft` / `tileCapRight` | px | 0 | Edge-strip cap thickness |
196
+ | `tileCapTopLeft` / `tileCapTopRight` / `tileCapBottomLeft` / `tileCapBottomRight` | px | 0 | `N×N` corner-square caps |
197
+ | `tileElevation` | 0..1 | `0.5` on face / descriptor default elsewhere | World-space Z of the primary surface |
198
+ | `tilePitch` | radians | π/4 | Tilt angle from flat |
199
+ | `tileBump` | `'alpha'` / `'luminance'` / `'red'` / `'green'` / `'blue'` / `'none'` | `'alpha'` | Per-texel bump source |
200
+ | `tileStrength` | float | 1 | Gradient multiplier (negative inverts) |
201
+
202
+ Legacy `*Px` aliases (`tileCapPx`, `tileCapTopPx`, etc.) are still accepted.
203
+
204
+ ## Using with plain Three.js
205
+
206
+ This package is renderer-agnostic and works with any Three.js WebGPU project. Bake a normal via the CLI, load it with `NormalMapLoader`, and assign it to a standard material's `normalMap` slot. No three-flatland dependency required.
207
+
208
+ ## Related
209
+
210
+ - **[three-flatland](https://www.npmjs.com/package/three-flatland)** — the 2D engine. High-level loaders (`SpriteSheetLoader`, `LDtkLoader`) orchestrate this package automatically when you pass `normals: true`.
211
+ - **[@three-flatland/bake](https://www.npmjs.com/package/@three-flatland/bake)** — shared bake pipeline infrastructure. Hosts the `flatland-bake` CLI; other packages plug in their own bakers.
212
+
213
+ ## Documentation
214
+
215
+ Full docs, interactive examples, and API reference at **[thejustinwalsh.com/three-flatland](https://thejustinwalsh.com/three-flatland/)**
216
+
217
+ ## License
218
+
219
+ [MIT](./LICENSE)
220
+
221
+ ---
222
+
223
+ <sub>This README was created with AI assistance. AI can make mistakes — please verify claims and test code examples. Submit corrections [here](https://github.com/thejustinwalsh/three-flatland/issues).</sub>
@@ -0,0 +1,129 @@
1
+ import { Loader, TextureLoader } from "three";
2
+ import {
3
+ devtimeWarn as sharedDevtimeWarn,
4
+ _resetDevtimeWarnings as sharedResetDevtimeWarnings,
5
+ hashDescriptor
6
+ } from "@three-flatland/bake";
7
+ import { bakedNormalURL } from "./bake.js";
8
+ class NormalMapLoader extends Loader {
9
+ /**
10
+ * Generate this asset's normal map in the browser on every load
11
+ * instead of loading a pre-baked `.normal.png` sidecar. With a
12
+ * `descriptor`, the in-memory bake runs on every load. Without a
13
+ * descriptor, the loader resolves directly to `null` and the caller
14
+ * uses the TSL runtime fallback. Suppresses the "no baked sibling"
15
+ * warn either way.
16
+ *
17
+ * Use when runtime is the right home for the bake. Not a dev-iteration
18
+ * knob — the default path (probe → bake on miss + warn) already
19
+ * handles iteration. See {@link BakedAssetLoaderOptions.forceRuntime}.
20
+ */
21
+ forceRuntime = false;
22
+ /**
23
+ * When set, missing sidecars trigger an in-memory bake via
24
+ * `resolveNormalMap`. Without a descriptor, the loader can only probe
25
+ * the baked sibling and returns `null` on miss (legacy behavior).
26
+ *
27
+ * Set via the `useLoader` callback:
28
+ * ```ts
29
+ * useLoader(NormalMapLoader, url, undefined, (l) => { l.descriptor = desc })
30
+ * ```
31
+ */
32
+ descriptor = void 0;
33
+ constructor(manager) {
34
+ super(manager);
35
+ }
36
+ // ─── Instance API (R3F useLoader compatibility) ───
37
+ load(url, onLoad, onProgress, onError) {
38
+ const resolved = this.manager.resolveURL(url);
39
+ NormalMapLoader._loadImpl(resolved, this.forceRuntime, this.descriptor).then((result) => {
40
+ onLoad?.(result);
41
+ }).catch((err) => {
42
+ if (onError) onError(err);
43
+ else console.error("NormalMapLoader:", err);
44
+ this.manager.itemError(url);
45
+ });
46
+ return null;
47
+ }
48
+ loadAsync(url) {
49
+ return NormalMapLoader._loadImpl(
50
+ this.manager.resolveURL(url),
51
+ this.forceRuntime,
52
+ this.descriptor
53
+ );
54
+ }
55
+ // ─── Static API (vanilla usage) ───
56
+ /**
57
+ * Vanilla cache: keyed by `(url, forceRuntime, descriptor hash)` so
58
+ * two callers passing *different* descriptors for the same URL get
59
+ * distinct results instead of colliding on the first one's bake.
60
+ *
61
+ * Instance API (R3F `useLoader`) bypasses this map — R3F has its own
62
+ * suspense cache and we don't want double-caching to fight the lifecycle.
63
+ */
64
+ static _cache = /* @__PURE__ */ new Map();
65
+ static load(url, options) {
66
+ const forceRuntime = options?.forceRuntime ?? false;
67
+ const descriptor = options?.descriptor;
68
+ const descriptorKey = descriptor ? hashDescriptor(descriptor) : "nodesc";
69
+ const cacheKey = `${url}:${forceRuntime ? "runtime" : "probe"}:${descriptorKey}`;
70
+ const cached = this._cache.get(cacheKey);
71
+ if (cached) return cached;
72
+ const promise = this._loadImpl(url, forceRuntime, descriptor);
73
+ this._cache.set(cacheKey, promise);
74
+ return promise;
75
+ }
76
+ static clearCache() {
77
+ this._cache.clear();
78
+ }
79
+ // ─── Implementation ───
80
+ static async _loadImpl(url, forceRuntime, descriptor) {
81
+ if (descriptor) {
82
+ const { resolveNormalMap } = await import("./resolveNormalMap.js");
83
+ return resolveNormalMap(url, descriptor, { forceRuntime });
84
+ }
85
+ if (!forceRuntime) {
86
+ const baked = await this._tryLoadBaked(url);
87
+ if (baked) return baked;
88
+ }
89
+ sharedDevtimeWarn(
90
+ "normal",
91
+ url,
92
+ `No baked normal sibling for ${url} and no descriptor passed. Either pre-bake (\`npx flatland-bake normal\`), pass a \`descriptor\` to NormalMapLoader.load() for in-memory bake, or use SpriteSheetLoader/LDtkLoader with \`normals: true\` (which synthesizes a descriptor for you).`
93
+ );
94
+ return null;
95
+ }
96
+ static async _tryLoadBaked(spriteURL) {
97
+ const bakedURL = bakedNormalURL(spriteURL);
98
+ let head;
99
+ try {
100
+ head = await fetch(bakedURL, { method: "HEAD" });
101
+ } catch {
102
+ return null;
103
+ }
104
+ if (!head.ok) return null;
105
+ return new Promise((resolve, _reject) => {
106
+ const loader = new TextureLoader();
107
+ loader.load(
108
+ bakedURL,
109
+ (tex) => resolve(tex),
110
+ void 0,
111
+ (err) => {
112
+ console.warn(
113
+ `[normal] Found ${bakedURL} via HEAD but TextureLoader failed \u2014 falling back to runtime.`,
114
+ err
115
+ );
116
+ resolve(null);
117
+ }
118
+ );
119
+ });
120
+ }
121
+ }
122
+ function _resetDevtimeWarnings() {
123
+ sharedResetDevtimeWarnings();
124
+ }
125
+ export {
126
+ NormalMapLoader,
127
+ _resetDevtimeWarnings
128
+ };
129
+ //# sourceMappingURL=NormalMapLoader.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/NormalMapLoader.ts"],"sourcesContent":["import { Loader, type LoadingManager, TextureLoader, type Texture } from 'three'\nimport {\n devtimeWarn as sharedDevtimeWarn,\n _resetDevtimeWarnings as sharedResetDevtimeWarnings,\n hashDescriptor,\n type BakedAssetLoaderOptions,\n} from '@three-flatland/bake'\nimport { bakedNormalURL } from './bake.js'\nimport type { NormalSourceDescriptor } from './descriptor.js'\n\n/**\n * Options accepted by `NormalMapLoader.load()`. Inherits `forceRuntime`\n * from the shared {@link BakedAssetLoaderOptions} so every baked-asset\n * loader in the codebase advertises the same option shape.\n */\nexport interface NormalMapLoaderStaticOptions extends BakedAssetLoaderOptions {\n /**\n * When provided, missing sidecars trigger an in-memory bake via\n * `resolveNormalMap`. Without a descriptor, `NormalMapLoader.load()` can\n * only probe the baked sibling and returns `null` on miss (legacy\n * behavior, preserved for backward compat).\n */\n descriptor?: NormalSourceDescriptor\n}\n\n/**\n * Result of loading a sprite's normal data.\n *\n * Either a baked normal texture (fast path) or `null`, signalling the caller\n * to use the runtime TSL `normalFromSprite` helper against the sprite's own\n * alpha channel.\n */\nexport type NormalMapResult = Texture | null\n\n/**\n * Loader for per-sprite normal maps following the canonical three-flatland\n * \"try baked → fall back to runtime\" pattern documented in\n * `planning/bake/loader-pattern.md`.\n *\n * Given a sprite PNG URL, this loader fetches the sibling `.normal.png` that\n * `flatland-bake normal` produces. If it is absent, the loader resolves to\n * `null` so the lit material can switch to its TSL runtime fallback.\n *\n * Only the baked path uses the network — the runtime branch is a shader\n * concern and lives in `@three-flatland/nodes/lighting/normalFromSprite`.\n *\n * @example\n * ```ts\n * // Vanilla static API\n * const normalTex = await NormalMapLoader.load('/sprites/knight.png')\n * if (normalTex) material.normalMap = normalTex\n * else material.useRuntimeNormals = true\n *\n * // Skip the baked probe — go straight to runtime (or null without descriptor)\n * const tex = await NormalMapLoader.load(url, { forceRuntime: true })\n *\n * // R3F useLoader\n * const tex = useLoader(NormalMapLoader, '/sprites/knight.png')\n * ```\n */\nexport class NormalMapLoader extends Loader<NormalMapResult> {\n /**\n * Generate this asset's normal map in the browser on every load\n * instead of loading a pre-baked `.normal.png` sidecar. With a\n * `descriptor`, the in-memory bake runs on every load. Without a\n * descriptor, the loader resolves directly to `null` and the caller\n * uses the TSL runtime fallback. Suppresses the \"no baked sibling\"\n * warn either way.\n *\n * Use when runtime is the right home for the bake. Not a dev-iteration\n * knob — the default path (probe → bake on miss + warn) already\n * handles iteration. See {@link BakedAssetLoaderOptions.forceRuntime}.\n */\n forceRuntime = false\n /**\n * When set, missing sidecars trigger an in-memory bake via\n * `resolveNormalMap`. Without a descriptor, the loader can only probe\n * the baked sibling and returns `null` on miss (legacy behavior).\n *\n * Set via the `useLoader` callback:\n * ```ts\n * useLoader(NormalMapLoader, url, undefined, (l) => { l.descriptor = desc })\n * ```\n */\n descriptor: NormalSourceDescriptor | undefined = undefined\n\n constructor(manager?: LoadingManager) {\n super(manager)\n }\n\n // ─── Instance API (R3F useLoader compatibility) ───\n\n load(\n url: string,\n onLoad?: (data: NormalMapResult) => void,\n onProgress?: (event: ProgressEvent) => void,\n onError?: (err: unknown) => void\n ): NormalMapResult {\n const resolved = this.manager.resolveURL(url)\n\n NormalMapLoader._loadImpl(resolved, this.forceRuntime, this.descriptor)\n .then((result) => {\n onLoad?.(result)\n })\n .catch((err) => {\n if (onError) onError(err)\n else console.error('NormalMapLoader:', err)\n this.manager.itemError(url)\n })\n\n return null\n }\n\n loadAsync(url: string): Promise<NormalMapResult> {\n return NormalMapLoader._loadImpl(\n this.manager.resolveURL(url),\n this.forceRuntime,\n this.descriptor\n )\n }\n\n // ─── Static API (vanilla usage) ───\n\n /**\n * Vanilla cache: keyed by `(url, forceRuntime, descriptor hash)` so\n * two callers passing *different* descriptors for the same URL get\n * distinct results instead of colliding on the first one's bake.\n *\n * Instance API (R3F `useLoader`) bypasses this map — R3F has its own\n * suspense cache and we don't want double-caching to fight the lifecycle.\n */\n private static _cache = new Map<string, Promise<NormalMapResult>>()\n\n static load(\n url: string,\n options?: NormalMapLoaderStaticOptions\n ): Promise<NormalMapResult> {\n const forceRuntime = options?.forceRuntime ?? false\n const descriptor = options?.descriptor\n // Hash the descriptor so distinct descriptors for the same URL get\n // distinct cache entries. `descriptor ? 'desc' : 'nodesc'` would have\n // collapsed every (URL, *) pair into one slot — first-bake-wins.\n const descriptorKey = descriptor ? hashDescriptor(descriptor) : 'nodesc'\n const cacheKey = `${url}:${forceRuntime ? 'runtime' : 'probe'}:${descriptorKey}`\n const cached = this._cache.get(cacheKey)\n if (cached) return cached\n\n const promise = this._loadImpl(url, forceRuntime, descriptor)\n this._cache.set(cacheKey, promise)\n return promise\n }\n\n static clearCache(): void {\n this._cache.clear()\n }\n\n // ─── Implementation ───\n\n private static async _loadImpl(\n url: string,\n forceRuntime: boolean,\n descriptor: NormalSourceDescriptor | undefined\n ): Promise<NormalMapResult> {\n // With a descriptor we can do the full resolve: try baked → in-memory bake.\n if (descriptor) {\n const { resolveNormalMap } = await import('./resolveNormalMap.js')\n return resolveNormalMap(url, descriptor, { forceRuntime })\n }\n\n // No descriptor → legacy URL-only behavior: probe sidecar, return null on miss.\n if (!forceRuntime) {\n const baked = await this._tryLoadBaked(url)\n if (baked) return baked\n }\n\n sharedDevtimeWarn(\n 'normal',\n url,\n `No baked normal sibling for ${url} and no descriptor passed. ` +\n `Either pre-bake (\\`npx flatland-bake normal\\`), pass a \\`descriptor\\` to ` +\n `NormalMapLoader.load() for in-memory bake, or use SpriteSheetLoader/LDtkLoader ` +\n `with \\`normals: true\\` (which synthesizes a descriptor for you).`\n )\n return null\n }\n\n private static async _tryLoadBaked(spriteURL: string): Promise<Texture | null> {\n const bakedURL = bakedNormalURL(spriteURL)\n\n // Probe with HEAD first so a 404 stays silent. TextureLoader swallows\n // network errors into a generic event, which is not what we want.\n let head: Response\n try {\n head = await fetch(bakedURL, { method: 'HEAD' })\n } catch {\n return null\n }\n if (!head.ok) return null\n\n return new Promise((resolve, _reject) => {\n const loader = new TextureLoader()\n loader.load(\n bakedURL,\n (tex) => resolve(tex as unknown as Texture),\n undefined,\n (err) => {\n console.warn(\n `[normal] Found ${bakedURL} via HEAD but TextureLoader failed — falling back to runtime.`,\n err\n )\n resolve(null)\n }\n )\n })\n }\n}\n\n/** Clear the devtime-warning dedupe set. Intended for tests. */\nexport function _resetDevtimeWarnings(): void {\n sharedResetDevtimeWarnings()\n}\n"],"mappings":"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","names":[]}
package/dist/bake.js ADDED
@@ -0,0 +1,123 @@
1
+ import {
2
+ DEFAULT_BUMP,
3
+ DEFAULT_PITCH,
4
+ DEFAULT_STRENGTH,
5
+ resolveRegion
6
+ } from "./descriptor.js";
7
+ function bakeNormalMap(pixels, width, height, descriptor = {}) {
8
+ const out = new Uint8Array(pixels.length);
9
+ for (let i = 0; i < pixels.length; i += 4) {
10
+ out[i] = 128;
11
+ out[i + 1] = 128;
12
+ out[i + 2] = 0;
13
+ out[i + 3] = pixels[i + 3];
14
+ }
15
+ const regions = descriptor.regions && descriptor.regions.length > 0 ? descriptor.regions : [{ x: 0, y: 0, w: width, h: height }];
16
+ for (const region of regions) {
17
+ const resolved = resolveRegion(region, descriptor);
18
+ bakeRegion(pixels, out, width, resolved);
19
+ }
20
+ return out;
21
+ }
22
+ function bakeRegion(pixels, out, width, region) {
23
+ const x0 = region.x;
24
+ const y0 = region.y;
25
+ const x1 = region.x + region.w;
26
+ const y1 = region.y + region.h;
27
+ const strength = region.strength;
28
+ const bumpMode = region.bump;
29
+ const bumpChannel = bumpMode === "alpha" ? 3 : bumpMode === "red" ? 0 : bumpMode === "green" ? 1 : bumpMode === "blue" ? 2 : -1;
30
+ const useLuminance = bumpMode === "luminance";
31
+ const useBump = bumpChannel >= 0 || useLuminance;
32
+ let r00 = 1, r01 = 0, r02 = 0;
33
+ let r10 = 0, r11 = 1, r12 = 0;
34
+ let r20 = 0, r21 = 0, r22 = 1;
35
+ const hasTilt = region.angle !== null;
36
+ if (hasTilt) {
37
+ const theta = region.angle;
38
+ const pitch = region.pitch;
39
+ const ax = -Math.sin(theta);
40
+ const ay = Math.cos(theta);
41
+ const c = Math.cos(pitch);
42
+ const s = Math.sin(pitch);
43
+ const t = 1 - c;
44
+ r00 = c + ax * ax * t;
45
+ r01 = ax * ay * t;
46
+ r02 = ay * s;
47
+ r10 = ay * ax * t;
48
+ r11 = c + ay * ay * t;
49
+ r12 = -ax * s;
50
+ r20 = -ay * s;
51
+ r21 = ax * s;
52
+ r22 = c;
53
+ }
54
+ const heightAt = (x, y) => {
55
+ const cx = x < x0 ? x0 : x >= x1 ? x1 - 1 : x;
56
+ const cy = y < y0 ? y0 : y >= y1 ? y1 - 1 : y;
57
+ const base = (cy * width + cx) * 4;
58
+ if (useLuminance) {
59
+ return (0.2126 * pixels[base] + 0.7152 * pixels[base + 1] + 0.0722 * pixels[base + 2]) / 255;
60
+ }
61
+ return pixels[base + bumpChannel] / 255;
62
+ };
63
+ for (let y = y0; y < y1; y++) {
64
+ for (let x = x0; x < x1; x++) {
65
+ const idx = (y * width + x) * 4;
66
+ let lx;
67
+ let ly;
68
+ let lz;
69
+ if (useBump) {
70
+ const hL = heightAt(x - 1, y);
71
+ const hR = heightAt(x + 1, y);
72
+ const hD = heightAt(x, y - 1);
73
+ const hU = heightAt(x, y + 1);
74
+ const dx = (hR - hL) * strength;
75
+ const dy = (hU - hD) * strength;
76
+ lx = -dx;
77
+ ly = -dy;
78
+ lz = 1;
79
+ } else {
80
+ lx = 0;
81
+ ly = 0;
82
+ lz = 1;
83
+ }
84
+ let nx;
85
+ let ny;
86
+ let nz;
87
+ if (hasTilt) {
88
+ nx = r00 * lx + r01 * ly + r02 * lz;
89
+ ny = r10 * lx + r11 * ly + r12 * lz;
90
+ nz = r20 * lx + r21 * ly + r22 * lz;
91
+ } else {
92
+ nx = lx;
93
+ ny = ly;
94
+ nz = lz;
95
+ }
96
+ const len = Math.hypot(nx, ny, nz);
97
+ nx /= len;
98
+ ny /= len;
99
+ nz /= len;
100
+ out[idx] = Math.round((nx * 0.5 + 0.5) * 255);
101
+ out[idx + 1] = Math.round((ny * 0.5 + 0.5) * 255);
102
+ out[idx + 2] = Math.round(region.elevation * 255);
103
+ out[idx + 3] = pixels[idx + 3];
104
+ }
105
+ }
106
+ }
107
+ function bakeNormalMapFromPixels(pixels, width, height, options = {}) {
108
+ const strength = options.strength ?? DEFAULT_STRENGTH;
109
+ return bakeNormalMap(pixels, width, height, {
110
+ strength,
111
+ bump: DEFAULT_BUMP,
112
+ pitch: DEFAULT_PITCH
113
+ });
114
+ }
115
+ function bakedNormalURL(spriteURL) {
116
+ return spriteURL.replace(/\.png($|[?#])/i, ".normal.png$1");
117
+ }
118
+ export {
119
+ bakeNormalMap,
120
+ bakeNormalMapFromPixels,
121
+ bakedNormalURL
122
+ };
123
+ //# sourceMappingURL=bake.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/bake.ts"],"sourcesContent":["import {\n DEFAULT_BUMP,\n DEFAULT_PITCH,\n DEFAULT_STRENGTH,\n resolveRegion,\n type NormalSourceDescriptor,\n type ResolvedNormalRegion,\n} from './descriptor.js'\n\n/**\n * @deprecated — legacy flat-texture bake options. Use\n * `bakeNormalMap(pixels, w, h, descriptor)` for region-aware control.\n */\nexport interface BakeOptions {\n /** Scales the alpha gradient before normalization. Default 1. */\n strength?: number\n}\n\n/**\n * Produce a tangent-space normal map from source pixels + a descriptor.\n *\n * Cross-platform: browser + node. No filesystem, no pngjs. Used by\n * the CLI baker (wrapped with file I/O) and the runtime loader\n * (called directly on decoded image pixels).\n *\n * Per texel in each region:\n * 1. `bump: 'alpha'` — central-difference gradient on the source\n * alpha channel, clamped to the region bounds so adjacent regions\n * (e.g. atlas cells) can't bleed into each other.\n * 2. Compose with a tilt rotation that takes `+Z` to the region's\n * `direction` at `pitch` radians.\n * 3. Normalize and encode to `rgb = normal * 0.5 + 0.5`. Alpha is\n * copied from the source so the baked map carries its own\n * silhouette.\n *\n * Texels outside every region receive the flat normal `(0, 0, 1)`\n * with the source's alpha — safe default for sparse descriptors.\n *\n * @param pixels RGBA pixel buffer, row-major, 4 bytes per pixel.\n * @param width Pixel width.\n * @param height Pixel height.\n * @param descriptor Region / tilt / bump control. Defaults to a single\n * whole-texture flat region with alpha-derived bump.\n * @returns RGBA pixel buffer containing the encoded normal map.\n */\nexport function bakeNormalMap(\n pixels: Uint8Array,\n width: number,\n height: number,\n descriptor: NormalSourceDescriptor = {}\n): Uint8Array {\n const out = new Uint8Array(pixels.length)\n\n // Initialize every texel to flat-normal (nx=0, ny=0), elevation=0,\n // source alpha. Runtime reconstructs nz = sqrt(1 − nx² − ny²) — a\n // zero XY yields nz=1 (flat floor). Regions that cover texels will\n // overwrite; texels outside any region keep this safe default.\n //\n // Encoding layout:\n // R = nx encoded as (nx * 0.5 + 0.5) * 255 (0.5 = 128)\n // G = ny encoded the same (0.5 = 128)\n // B = elevation in [0, 1] scaled to [0, 255] (0 = ground default)\n // A = source alpha preserved\n for (let i = 0; i < pixels.length; i += 4) {\n out[i] = 128\n out[i + 1] = 128\n out[i + 2] = 0\n out[i + 3] = pixels[i + 3]!\n }\n\n const regions =\n descriptor.regions && descriptor.regions.length > 0\n ? descriptor.regions\n : [{ x: 0, y: 0, w: width, h: height }]\n\n for (const region of regions) {\n const resolved = resolveRegion(region, descriptor)\n bakeRegion(pixels, out, width, resolved)\n }\n\n return out\n}\n\n/**\n * Bake a single region into the output buffer. Region-local alpha\n * clamping prevents cross-region gradient bleed.\n */\nfunction bakeRegion(\n pixels: Uint8Array,\n out: Uint8Array,\n width: number,\n region: ResolvedNormalRegion\n): void {\n const x0 = region.x\n const y0 = region.y\n const x1 = region.x + region.w\n const y1 = region.y + region.h\n const strength = region.strength\n // Index into the RGBA pixel run for the chosen height source, or -1\n // for 'none'. 0 = R, 1 = G, 2 = B, 3 = A. Luminance needs all three\n // RGB components and is handled separately below.\n const bumpMode = region.bump\n const bumpChannel =\n bumpMode === 'alpha'\n ? 3\n : bumpMode === 'red'\n ? 0\n : bumpMode === 'green'\n ? 1\n : bumpMode === 'blue'\n ? 2\n : -1\n const useLuminance = bumpMode === 'luminance'\n const useBump = bumpChannel >= 0 || useLuminance\n\n // Build the tilt rotation matrix (+Z → target direction at pitch).\n // For direction angle θ, the tilt axis is perpendicular to the tilt\n // direction in the XY plane: `axis = (-sin θ, cos θ, 0)`.\n // Rodrigues' formula composes the rotation matrix from the axis\n // and `pitch` radians.\n let r00 = 1,\n r01 = 0,\n r02 = 0\n let r10 = 0,\n r11 = 1,\n r12 = 0\n let r20 = 0,\n r21 = 0,\n r22 = 1\n const hasTilt = region.angle !== null\n if (hasTilt) {\n const theta = region.angle as number\n const pitch = region.pitch\n const ax = -Math.sin(theta)\n const ay = Math.cos(theta)\n const c = Math.cos(pitch)\n const s = Math.sin(pitch)\n const t = 1 - c\n // Axis is in the XY plane (az = 0), so several Rodrigues terms drop out.\n r00 = c + ax * ax * t\n r01 = ax * ay * t\n r02 = ay * s\n r10 = ay * ax * t\n r11 = c + ay * ay * t\n r12 = -ax * s\n r20 = -ay * s\n r21 = ax * s\n r22 = c\n }\n\n // Height sample for the chosen bump mode, clamped to region bounds\n // so adjacent regions (e.g., atlas cells) can't bleed gradients into\n // each other. Returns a value in [0, 1].\n const heightAt = (x: number, y: number): number => {\n const cx = x < x0 ? x0 : x >= x1 ? x1 - 1 : x\n const cy = y < y0 ? y0 : y >= y1 ? y1 - 1 : y\n const base = (cy * width + cx) * 4\n if (useLuminance) {\n // Rec. 709 luminance — matches perceptual brightness, so art\n // authored against a monitor reads consistently.\n return (\n (0.2126 * pixels[base]! +\n 0.7152 * pixels[base + 1]! +\n 0.0722 * pixels[base + 2]!) /\n 255\n )\n }\n return pixels[base + bumpChannel]! / 255\n }\n\n for (let y = y0; y < y1; y++) {\n for (let x = x0; x < x1; x++) {\n const idx = (y * width + x) * 4\n\n // Local tangent-space bump. Central difference on the height\n // source — `heightAt` selects alpha / luminance / a color\n // channel. Standard tangent-space convention: high values are\n // raised, low values are sunken, normal points away from peaks.\n let lx: number\n let ly: number\n let lz: number\n if (useBump) {\n const hL = heightAt(x - 1, y)\n const hR = heightAt(x + 1, y)\n const hD = heightAt(x, y - 1)\n const hU = heightAt(x, y + 1)\n const dx = (hR - hL) * strength\n const dy = (hU - hD) * strength\n lx = -dx\n ly = -dy\n lz = 1\n } else {\n lx = 0\n ly = 0\n lz = 1\n }\n\n // Apply tilt: n = R · local.\n let nx: number\n let ny: number\n let nz: number\n if (hasTilt) {\n nx = r00 * lx + r01 * ly + r02 * lz\n ny = r10 * lx + r11 * ly + r12 * lz\n nz = r20 * lx + r21 * ly + r22 * lz\n } else {\n nx = lx\n ny = ly\n nz = lz\n }\n\n const len = Math.hypot(nx, ny, nz)\n nx /= len\n ny /= len\n nz /= len\n // Note: nz is not written — runtime reconstructs it as\n // sqrt(1 − nx² − ny²). Outward-facing convention means nz ≥ 0\n // always, so the sign is implicit.\n\n out[idx] = Math.round((nx * 0.5 + 0.5) * 255)\n out[idx + 1] = Math.round((ny * 0.5 + 0.5) * 255)\n out[idx + 2] = Math.round(region.elevation * 255)\n out[idx + 3] = pixels[idx + 3]!\n }\n }\n}\n\n/**\n * Legacy back-compat wrapper. Use `bakeNormalMap(pixels, w, h, {\n * strength })` for equivalent behavior in new code.\n *\n * @deprecated\n */\nexport function bakeNormalMapFromPixels(\n pixels: Uint8Array,\n width: number,\n height: number,\n options: BakeOptions = {}\n): Uint8Array {\n const strength = options.strength ?? DEFAULT_STRENGTH\n return bakeNormalMap(pixels, width, height, {\n strength,\n bump: DEFAULT_BUMP,\n pitch: DEFAULT_PITCH,\n })\n}\n\n/**\n * Derive the conventional `.normal.png` sibling URL for a sprite PNG.\n *\n * Runtime loaders call this to try the baked output before falling\n * back to the runtime TSL path.\n *\n * Pure string rewrite — browser-safe, no filesystem.\n */\nexport function bakedNormalURL(spriteURL: string): string {\n return spriteURL.replace(/\\.png($|[?#])/i, '.normal.png$1')\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,38 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { PNG } from "pngjs";
3
+ import { hashDescriptor } from "@three-flatland/bake";
4
+ import { writeSidecarPng } from "@three-flatland/bake/node";
5
+ import { bakeNormalMap } from "./bake.js";
6
+ function bakeNormalMapFile(inputPath, descriptorOrOptions, outputPath) {
7
+ const buffer = readFileSync(inputPath);
8
+ const png = PNG.sync.read(buffer);
9
+ const pixels = new Uint8Array(
10
+ png.data.buffer,
11
+ png.data.byteOffset,
12
+ png.data.byteLength
13
+ );
14
+ const descriptor = resolveDescriptorInput(descriptorOrOptions);
15
+ const normalPixels = bakeNormalMap(pixels, png.width, png.height, descriptor);
16
+ const resolvedOut = outputPath ?? inputPath.replace(/\.png$/i, ".normal.png");
17
+ writeSidecarPng(resolvedOut, normalPixels, png.width, png.height, {
18
+ hash: hashDescriptor(descriptor),
19
+ v: 1
20
+ });
21
+ return resolvedOut;
22
+ }
23
+ function resolveDescriptorInput(input) {
24
+ if (input === void 0) return {};
25
+ if (typeof input === "string") {
26
+ const json = readFileSync(input, "utf8");
27
+ return JSON.parse(json);
28
+ }
29
+ if ("regions" in input || "direction" in input || "bump" in input || "pitch" in input) {
30
+ return input;
31
+ }
32
+ const legacy = input;
33
+ return { strength: legacy.strength };
34
+ }
35
+ export {
36
+ bakeNormalMapFile
37
+ };
38
+ //# sourceMappingURL=bake.node.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/bake.node.ts"],"sourcesContent":["import { readFileSync } from 'node:fs'\nimport { PNG } from 'pngjs'\nimport { hashDescriptor } from '@three-flatland/bake'\nimport { writeSidecarPng } from '@three-flatland/bake/node'\nimport { bakeNormalMap, type BakeOptions } from './bake.js'\nimport type { NormalSourceDescriptor } from './descriptor.js'\n\n/**\n * Bake a normal map from a PNG file on disk.\n *\n * Node-only wrapper around `bakeNormalMap` — handles file I/O and\n * stamps the output PNG with a `tEXt` chunk containing the descriptor\n * hash, so `probeBakedSibling` can invalidate stale outputs.\n *\n * Second argument accepts either:\n * - A full `NormalSourceDescriptor` (region-aware).\n * - A legacy `BakeOptions` (`{ strength }`) — for back-compat with\n * existing callers; promoted to a zero-region descriptor.\n * - A path to a descriptor JSON file.\n */\nexport function bakeNormalMapFile(\n inputPath: string,\n descriptorOrOptions?: NormalSourceDescriptor | BakeOptions | string,\n outputPath?: string\n): string {\n const buffer = readFileSync(inputPath)\n const png = PNG.sync.read(buffer)\n const pixels = new Uint8Array(\n png.data.buffer,\n png.data.byteOffset,\n png.data.byteLength\n )\n\n const descriptor = resolveDescriptorInput(descriptorOrOptions)\n const normalPixels = bakeNormalMap(pixels, png.width, png.height, descriptor)\n\n const resolvedOut = outputPath ?? inputPath.replace(/\\.png$/i, '.normal.png')\n writeSidecarPng(resolvedOut, normalPixels, png.width, png.height, {\n hash: hashDescriptor(descriptor),\n v: 1,\n })\n return resolvedOut\n}\n\nfunction resolveDescriptorInput(\n input: NormalSourceDescriptor | BakeOptions | string | undefined\n): NormalSourceDescriptor {\n if (input === undefined) return {}\n if (typeof input === 'string') {\n const json = readFileSync(input, 'utf8')\n return JSON.parse(json) as NormalSourceDescriptor\n }\n // Heuristic: a BakeOptions has only `strength`; a descriptor may have\n // `regions`, `direction`, etc. Treat as descriptor when any non-\n // `strength` field is present.\n if ('regions' in input || 'direction' in input || 'bump' in input || 'pitch' in input) {\n return input\n }\n const legacy = input as BakeOptions\n return { strength: legacy.strength }\n}\n"],"mappings":"AAAA,SAAS,oBAAoB;AAC7B,SAAS,WAAW;AACpB,SAAS,sBAAsB;AAC/B,SAAS,uBAAuB;AAChC,SAAS,qBAAuC;AAgBzC,SAAS,kBACd,WACA,qBACA,YACQ;AACR,QAAM,SAAS,aAAa,SAAS;AACrC,QAAM,MAAM,IAAI,KAAK,KAAK,MAAM;AAChC,QAAM,SAAS,IAAI;AAAA,IACjB,IAAI,KAAK;AAAA,IACT,IAAI,KAAK;AAAA,IACT,IAAI,KAAK;AAAA,EACX;AAEA,QAAM,aAAa,uBAAuB,mBAAmB;AAC7D,QAAM,eAAe,cAAc,QAAQ,IAAI,OAAO,IAAI,QAAQ,UAAU;AAE5E,QAAM,cAAc,cAAc,UAAU,QAAQ,WAAW,aAAa;AAC5E,kBAAgB,aAAa,cAAc,IAAI,OAAO,IAAI,QAAQ;AAAA,IAChE,MAAM,eAAe,UAAU;AAAA,IAC/B,GAAG;AAAA,EACL,CAAC;AACD,SAAO;AACT;AAEA,SAAS,uBACP,OACwB;AACxB,MAAI,UAAU,OAAW,QAAO,CAAC;AACjC,MAAI,OAAO,UAAU,UAAU;AAC7B,UAAM,OAAO,aAAa,OAAO,MAAM;AACvC,WAAO,KAAK,MAAM,IAAI;AAAA,EACxB;AAIA,MAAI,aAAa,SAAS,eAAe,SAAS,UAAU,SAAS,WAAW,OAAO;AACrF,WAAO;AAAA,EACT;AACA,QAAM,SAAS;AACf,SAAO,EAAE,UAAU,OAAO,SAAS;AACrC;","names":[]}
package/dist/cli.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ import { Baker } from '@three-flatland/bake';
2
+
3
+ declare const baker: Baker;
4
+
5
+ export { baker as default };