All articles

Project Folder Architecture

The complete folder structure of a real ASL animation project: why each directory exists, what the naming conventions encode, how the dependency graph enforces separation of concerns, and the mistakes still sitting in the tree.

Muhammad Aamir12 min readAug 4, 2026
The complete folder tree of a real Blender-plus-Next.js ASL animation project, showing the top-level split by execution context and the framework-independent animation system

Folder structure is a design document that cannot go stale. If the layout is right, a new reader can guess where things are. If it is wrong, no amount of README compensates.

This article walks the real tree of the ASL Sign Character project, including the parts that are wrong, because a structure article that only shows the good decisions is not useful.

The complete tree

text
Sign Character Project/
|-- ARTICLES_SPEC.md               spec for this article series
|-- README.md                      project documentation
|-- my sign character.blend        the character (never written by the pipeline)
|-- my sign character.blend1       Blender's automatic backup
|
|-- data/                          SOURCE data, as downloaded
|   |-- asl-lex/
|   |   |-- signdata.csv           2.0 MB - ASL-LEX 2.0, latin-1 encoded
|   |   `-- signdataKEY.csv        45 KB - column definitions
|   `-- lexicon/                   (empty - leftover)
|
|-- tools/                         build-time scripts, run outside Blender
|   `-- build_lexicon.py           CSV -> runtime JSON
|
|-- blender/                       scripts that run INSIDE Blender
|   |-- facial_morphs.py           generates the 20 facial morphs
|   |-- body_profile.py            fits collision volumes to the mesh
|   |-- export_character.py        non-destructive GLB export
|   `-- preview_morphs.py          renders one face close-up per morph
|
|-- articles/                      this series
|
|-- ASL data/                      3.8 GB of screen recordings (see below)
|
`-- web/                           the Next.js application
    |-- package.json
    |-- next.config.mjs
    |-- tsconfig.json
    |-- app/                       Next.js app router
    |   |-- layout.tsx
    |   |-- page.tsx               UI, state, dev hooks
    |   `-- globals.css
    |-- public/                    static assets, fetched at runtime
    |   |-- character/
    |   |   |-- sign_character.glb    10 MB
    |   |   `-- rig_manifest.json     bones, anchors, collision, morphs
    |   `-- data/
    |       `-- asl_lexicon.json      1.2 MB - 2,719 signs
    |-- src/
    |   |-- asl/                   the animation system (12 modules)
    |   `-- components/
    |       `-- SignStage.tsx      three.js scene, render loop
    `-- test/
        `-- run.ts                 headless checks

Why the top level splits this way

The organising question is when does this code run?

  • data/: Never (it is data). CSV. No runtime.

  • tools/: Build time, once. Python 3. Needs system Python.

  • blender/: Build time, once. Python 3.13. Needs Blender's interpreter.

  • web/src/: Every frame. TypeScript. Needs a browser.

Those four contexts have nothing in common. They cannot share code, they cannot share dependencies, and they run at completely different frequencies. Splitting them at the top level makes that boundary explicit.

Why tools/ and blender/ are separate despite both being Python: they run in different interpreters. blender/ scripts import bpy, which only exists inside Blender's bundled Python 3.13. tools/build_lexicon.py runs in your system Python and would fail inside Blender's environment for lack of installed packages.

Putting them in one scripts/ folder would mean every file needs a comment explaining which interpreter runs it. The directory name does that job for free.

Why data/ is separate from web/public/data/: this is the important one.

text
data/asl-lex/signdata.csv          2.0 MB   source, as downloaded
web/public/data/asl_lexicon.json   1.2 MB   generated, shipped to the browser

One is input, the other is output. build_lexicon.py transforms the first into the second. Keeping them apart means you can always tell which files are derived and therefore safe to delete and regenerate.

Everything stays inside the project

This was an explicit constraint in the goal prompt:

text
keeping every file, model, dataset, script, asset, and generated output
inside the existing "Sign Character Project" folder

Left to their own devices, agents and build tools scatter: a dataset into a ~/.cache directory, a model into a global package location, a temp file into /tmp.

The result of enforcing containment is a directory you can copy to another machine and have work. The ASL-LEX CSV is in data/. The exported GLB is in web/public/character/. The built lexicon is in web/public/data/. Nothing lives outside.

The cost: the folder is large, 3.8 GB of it is screen recordings, and 10 MB is the character. Containment trades disk for portability. For a project that runs entirely on one machine, that is the right trade.

The animation system

This is where the real design work is. Twelve modules in web/src/asl/, and the dependency graph is the point (the animation system itself is documented in Part 6 on building natural ASL animation):

text
                            types.ts
                               |
        +------------+---------+----------+----------+
        v            v         v          v          v
   handshapes   locations   overrides   gloss    anatomy
        |            |         |                    |
        +------+-----+         |                    |
               v               |                    v
         fingerspell           |                  rig.ts
               |               |                    |
               +-------+-------+--------+-----------+
                       v                v
                  synthesize.ts  <-- player.ts --> face.ts
                       |                |
                       +--------+-------+
                                v
                          diagnostics.ts

Measured from the actual imports:

text
anatomy.ts       <-
face.ts          <-
types.ts         <-
gloss.ts         <- types
handshapes.ts    <- types
locations.ts     <- types
overrides.ts     <- types
rig.ts           <- anatomy types
fingerspell.ts   <- handshapes locations types
player.ts        <- face handshapes locations rig types
diagnostics.ts   <- anatomy player rig types
synthesize.ts    <- anatomy fingerspell handshapes locations overrides player types

Three things worth reading out of that.

  1. 1

    types.ts imports nothing: It is the shared vocabulary: Vec3, Handshape, HandTarget, Pose, Keyframe, NonManual. Everything depends on it and it depends on nothing, so there are no cycles by construction.

  2. 2

    gloss.ts depends only on types.ts: The entire English to ASL grammar layer has no knowledge of rigs, rendering, or three.js. That is why it can run headless in Node, and why the test suite can check grammar without a browser.

  3. 3

    anatomy.ts and face.ts import nothing at all: They are pure. anatomy.ts holds joint limits and the body collision volume; face.ts holds blink and saccade timing. Both are self-contained models that could be lifted into another project unchanged.

The layering that emerges:

text
LAYER 0   types                         vocabulary
LAYER 1   anatomy, face                 pure models, no deps
LAYER 2   handshapes, locations,        data + lookup
          overrides, gloss
LAYER 3   rig, fingerspell              rig binding, composed data
LAYER 4   player                        interpolation, runtime state
LAYER 5   synthesize                    orchestration
LAYER 6   diagnostics                   observation

Nothing in a lower layer imports from a higher one. That was not enforced by a linter: it fell out of separating by responsibility, and it is what made bisection possible during debugging. When flicker appeared, I could ask is this synthesis or the solve because those are different files with a one-way dependency.

Note diagnostics.ts sits at the top. The measuring instrument depends on everything and nothing depends on it. That is correct: observation should be removable without affecting behaviour.

One dependency that looks wrong and is not

synthesize.ts imports from player.ts:

TypeScript
import { handOrientation, samplePose } from './player';

Synthesis producing keyframes should not need the player. But the cleanup passes have to know what the animation will actually look like after interpolation. Checking a hand's angular speed means computing the orientation the way the player will. Duplicating that logic would guarantee drift between what synthesis checks and what the player does.

Naming conventions

Files: what it is, not what it does

gloss.ts, synthesize.ts, rig.ts, player.ts, anatomy.ts, diagnostics.ts.

Single words, lowercase, no prefixes. No aslGlossTranslator.ts or ASLSynthesisEngine.ts.

Why: the folder is already asl/. Repeating the domain in every filename is noise. Within a well-named folder, short names read better.

Generated files say so

X_head, X_eyes: the X_ prefix marks duplicated meshes in the export's temporary collection:

python
dup.name = "X_" + src.name

Why: during export, both the original head and the duplicate exist simultaneously. Without a prefix you are one bpy.data.objects["head"] away from modifying the source. The prefix makes the destructive-looking operation obviously safe.

Bone names get flattened for the web

python
def clean_bone_name(name):
    """DEF-f_index.01.L -> f_index_01_L  (dot-free, three.js friendly)."""

Dots are property-path separators in several toolchains. The rename happens once, at the boundary between Blender and the web.

Match names you did not choose

The face mesh is eyelasshes: the original author's spelling.

python
FACE_MESH_ROLES = {
    "head": "skin",
    "Plane": "brows",       # the eyebrow strip
    "eyelasshes": "lashes",  # rides the upper lid
    "eyes": "eyeballs",     # rigid spheres, gaze rotation only
}

Why not fix it: renaming a mesh in a file you did not model breaks vertex group references, modifier targets, and anything else pointing at it. The comment carries the meaning; the string matches reality.

Plane is worse: a mesh named after the primitive it started as, which happens to be the eyebrow strip. The role mapping is what makes that legible.

Separation of concerns, concretely

The clearest test of separation is: can you change one thing without touching the others?

  • Add an ASL grammar rule: One file. gloss.ts.

  • Fix a sign's palm orientation: One file. overrides.ts.

  • Change how fingers curl: One file. rig.ts.

  • Add a facial morph: One file plus re-export. blender/facial_morphs.py.

  • Change interpolation: One file. player.ts.

  • Add an artifact detector: One file. diagnostics.ts.

  • Change the UI: One file. app/page.tsx.

Each row is one file. That is the payoff.

The counter-example from this project: adding mouth morphemes touched three files, gloss.ts (which sign gets which morpheme), synthesize.ts (what the morpheme does to the face), and test/run.ts (assert they connect).

That is a genuine cross-cutting feature, and it produced a genuine cross-cutting bug: gloss.ts emitted puff, synthesize.ts had no puff entry, and the mouth silently did nothing. The seam between two files is exactly where that class of bug lives.

The fix was not to merge the files: they have different responsibilities, and merging would have made both worse. It was a test that asserts every morpheme one file can emit resolves in the other.

The web layer

text
web/
|-- app/           Next.js routing + UI
|-- src/asl/       animation system (framework-independent)
|-- src/components/  three.js scene
|-- public/        static assets
`-- test/          headless checks

Why src/asl/ is not under app/: nothing in it imports React or Next.js. It is plain TypeScript operating on data structures. That is what lets test/run.ts run it under tsx with no browser and no bundler:

Bash
npx tsx test/run.ts

The test suite exercises all 2,719 signs, checks every wrist target is reachable, and validates keyframe cleanup, in a couple of seconds, with no DOM.

If the animation system imported from app/, none of that would work. Framework independence is not purity for its own sake; it is what makes the code testable.

Why public/ holds the GLB and lexicon: they are fetched at runtime rather than bundled. A 10 MB GLB in the JavaScript bundle would be absurd. In public/ they are static files the browser can cache independently of the code.

What is wrong with this structure

Four real problems, in the tree right now.

1. ASL data/ is 3.8 GB of screen recordings

text
ASL data/
|-- Screen Recording 2026-08-01 at 10.33.43 PM.mov
|-- Screen Recording 2026-08-02 at 1.07.13 PM.mov
|-- ... (7 recordings total)
`-- Screenshot 2026-08-02 at 1.27.06 PM.png

Three point eight gigabytes. The entire rest of the project is about 14 MB.

2. data/lexicon/ is empty

A leftover from an earlier plan where the built lexicon was going to live there before it moved to web/public/data/.

Why it is still there: nobody deletes empty directories. They are invisible in most file listings and cost nothing, until someone puts a file in one, assuming it is the intended location.

Fix: delete it.

3. There is no version control

No .git directory.

This is the real problem in the list. Everything else is cosmetic. Without git:

  • The rejected-fix comments in the code are the only record of what was tried. They are good comments, but they are not a history.

  • There is no way to bisect a regression.

  • The 3.8 GB of recordings would need a .gitignore before the first commit, which is a reason it has not happened.

What I would do: git init, a .gitignore covering node_modules/, .next/, __pycache__/, ASL data/, and *.blend1, then commit. The .blend at ~50 MB should go in Git LFS or stay out of the repo entirely with a note about where to get it.

4. tsconfig.tsbuildinfo is committed-adjacent

A TypeScript incremental build cache sitting in web/. Generated, machine-specific, and would be noise in a repository. It belongs in .gitignore.

Maintainability: what makes this survivable

Four properties, in rough order of value.

  1. 1

    Regenerable artifacts: Every generated file can be rebuilt from a script in the tree. Delete either output and one command brings it back. Nothing generated is precious.

  2. 2

    The rig is calibrated, not hard-coded: Bend axes, palm normals, and segment lengths are measured from the rest pose at load time. Re-export the character with different proportions and the solver recalibrates. There is no table of magic numbers to update.

  3. 3

    Decisions are recorded where they will be questioned: Rejected fixes live as comments next to the constant someone will want to change. A separate decisions document does not get read at the moment of temptation. A comment does.

  4. 4

    The test suite runs without infrastructure: npm test (tsx test/run.ts) with no browser, no server, taking seconds. A test that is slow or needs setup gets skipped. This one exercises the whole synthesis path across every sign in the lexicon and finishes before you look away.

Example rejected-fix comment sitting next to a tunable constant:

TypeScript
/**
 * Slowing BOTH to 1.2 cut flicker (141 -> 112) but let collisions straight
 * back in (head penetration 0 -> 53).
 */
const MAX_ESCAPE_RATE = 14.0;

Would I structure it the same way again?

Mostly yes. Three changes:

  • Separate media from data by name, from day one: ASL data/ versus data/ is a naming collision that cost nothing to avoid and now requires a rename.

  • Initialise git first, before anything else: The absence of history is the one structural mistake with no cheap fix.

  • Consider splitting rig.ts: At 1,363 lines it is the largest file in the project and it holds three distinct responsibilities: rig binding and calibration, IK solving, and collision resolution. They are cohesive (the resolvers need the solver) but the file is at the size where finding things gets slow. Splitting into rig-bind.ts, rig-solve.ts, rig-collide.ts would probably help.

What I would not change: the four-way top-level split by execution context, the framework-independent animation system, and keeping generated artifacts separate from source. Those three carried the project.

Key Takeaways

  1. 1

    Split the top level by when code runs: Not by what language it is in. tools/ and blender/ are both Python but use different interpreters.

  2. 2

    Never mix source and generated files: data/ holds the downloaded CSV; web/public/data/ holds the built JSON.

  3. 3

    Keep the domain logic framework-independent: src/asl/ imports no React and no Next.js, which is what lets the test suite run headless in seconds.

  4. 4

    Let the dependency graph enforce layering: types.ts imports nothing, gloss.ts imports only types, diagnostics.ts sits on top and nothing depends on it.

  5. 5

    If two modules must agree on a computation, one imports it from the other: Duplicating a formula guarantees eventual drift.

  6. 6

    Add a test at the seam when a feature spans files: That is exactly where the puff bug lived.

  7. 7

    Convert naming conventions at the boundary, not throughout: DEF-f_index.01.L becomes f_index_01_L in one function.

  8. 8

    Match names you did not choose, including misspellings: Comments carry meaning; strings must match reality.

  9. 9

    Record rejected decisions next to the code that invites them: Not in a separate document.

  10. 10

    Label what is and is not a pipeline input: Containment without labelling produced a 3.8 GB folder that looks like a data source.

  11. 11

    Initialise version control before anything else: It is the one structural mistake here with no cheap retrofit.

Resources

  • Next.js App Router: nextjs.org/docs/app (https://nextjs.org/docs/app) for the app/, public/ and static asset conventions.

  • tsx: github.com/privatenumber/tsx (https://github.com/privatenumber/tsx), used to run the TypeScript test suite directly in Node.

  • Git LFS: git-lfs.com (https://git-lfs.com) for versioning the ~50 MB .blend if you do add version control.

  • ASL-LEX 2.0: Sevcikova Sehyr, Caselli, Cohen-Goldberg and Emmorey (2021), CC BY-NC 4.0. The source data in data/asl-lex/.

The rest of the series

This is Part 7 of a 10-part series. The other parts:

  1. 1

    How I Built an AI-Powered ASL Sign Character with Claude Code

  2. 2

    How Blender MCP Works with Claude Code

  3. 3

    Building a Production Ready Sign Language Character

  4. 4

    Designing the Goal Prompt

  5. 5

    Loop Engineering Explained

  6. 6

    Building Natural ASL Animation

  7. 7

    Every Prompt Used During Development

  8. 8

    Common Problems We Solved

  9. 9

    Lessons Learned

Frequently Asked Questions

How should I organise a project that mixes Blender scripts and a web app?

Split at the top level by execution context. Blender scripts (import bpy, Blender's bundled Python) go in one directory, build-time scripts using system Python go in another, and the web app is self-contained. They share no code and no dependencies, so the boundary should be visible in the tree.

Where should generated files live?

In a different directory from their sources, always. This project keeps the downloaded ASL-LEX CSV in data/ and the built lexicon in web/public/data/. When source and generated files share a directory, nobody can tell what is safe to delete, and eventually someone hand-edits a generated file.

Why keep the animation system out of the app/ directory?

So it imports no framework. src/asl/ is plain TypeScript over plain data structures, which is what lets the test suite run under tsx with no browser, no bundler and no DOM, exercising all 2,719 signs in seconds.

How do you prevent circular dependencies?

Have a types module that imports nothing and let everything else depend on it. In this project types.ts has zero imports, anatomy.ts and face.ts also have zero, and the graph layers cleanly upward from there. No linter rule was needed; it fell out of separating by responsibility.

Should generated Blender objects have a naming prefix?

Yes, when originals and duplicates coexist. The export prefixes duplicated meshes with X_, so head and X_head are both in the scene and no operation can hit the wrong one by accident.

What do you do about badly named meshes in a file you did not create?

Match them exactly and map them to roles in code. This character has a mesh called eyelasshes and another called Plane that is actually the eyebrow strip. Renaming would break vertex groups and modifier targets; a role mapping with comments gives you readable code without touching the file.

How big is too big for a single module?

There is no threshold, but rig.ts at 1,363 lines is past the point where finding things is quick. It holds three cohesive responsibilities (binding, solving, collision resolution) and would probably read better split three ways. The other eleven modules in that folder are all under 500 lines and none of them feel large.

Does every project need version control from day one?

This one did not have it and that is the single structural mistake I would call serious. Without git there is no bisect, no history, and the only record of what was tried and rejected is code comments. The retrofit cost grows with the project, particularly here, where 3.8 GB of screen recordings need excluding before a first commit is even sensible.

If you are shaping the architecture of a real product (real teams, real growth pressure) getting the tree right early beats fixing it later. That is exactly the kind of web application engineering we ship at ETechViral. The next part of the series lists every prompt used during development.

Tags
  • Project Structure
  • Architecture
  • Next.js
  • Blender
  • TypeScript
  • Maintainability
  • Separation of Concerns