Générateur procédural de cartes SVG en vue isométrique, dans un style line art à l'encre : noir sur blanc, aucun aplat, le volume est porté uniquement par la densité des hachures.
Le dessin est destiné à un traceur (plotter). Cette contrainte gouverne toute l'architecture — voir « Règles de la plume » ci-dessous.
Le projet se construit entité par entité. Chaque sous-entité (sapin, maison, rocher, pont, rivière…) est un générateur autonome et testable ; l'assemblage de la carte complète viendra ensuite, en instanciant ces générateurs et en les triant en profondeur.
Aujourd'hui disponible : le générateur de sapins.
npm install # typescript + @types/node (rien d'autre)
npm test # garde-fous du générateur
npm run check # typage
# un sapin
npm run render -- --kind fir --seed 42 --out out/sapin.svg
# une planche de contact : variantes, diversité, balayages de paramètres,
# et une petite forêt isométrique
npm run gallery -- --kind fir --count 24 --out out/gallery.html
# export destiné à la machine : aucun remplissage, aucun fond
npm run render -- --kind fir --seed 42 --plot --out out/sapin-plot.svgNode 22+ suffit : les fichiers TypeScript sont exécutés directement
(--experimental-strip-types), il n'y a pas d'étape de build.
- Une seule épaisseur de trait dans tout le document. C'est celle de la
plume physique. L'attribut
stroke-widthest posé une seule fois, sur le groupe racine du composant (penAttrs, danssrc/core/pen.ts) ; aucun tracé ne porte le sien. Un test le vérifie sur toutes les combinaisons d'options : il doit y avoir exactement unstroke-widthpar document. - Pour appuyer un trait, on le repasse.
strokePassesretrace le contournfois, chaque passe décalée d'exactement une largeur de plume vers l'intérieur : les passes se touchent, sans recouvrement ni interstice. Voir l'optionoutlinePasseset le presetcontour-appuye. - La valeur ne vient que de l'écartement. Clair ou foncé se règle par
l'espacement des hachures, jamais par l'épaisseur ni par un gris. Au-delà
d'un certain assombrissement (
shade > 0.72), une seconde famille croisée apparaît dans le cœur de l'ombre plutôt que de resserrer jusqu'à l'aplat.safeSpacingempêche deux traits parallèles de se toucher. - Les écartements sont exprimés en largeurs de plume, pas en fraction de l'objet : c'est la plume qui fixe la finesse possible. Une légère dépendance à la taille (en racine carrée) évite qu'un très grand sujet ne vire à la masse noire.
--plotproduit la géométrie pure : aucun remplissage, aucun fond.
Le blanc opaque n'est qu'un outil d'occlusion à l'écran. Un traceur ne
remplit rien : en mode --plot, les recouvrements entre entités redeviennent
visibles. Les résoudre géométriquement (élimination des parties cachées) est un
travail à faire au niveau du compositeur de carte, pas des entités.
Trois signatures trahissent immédiatement la machine. Le générateur les traite comme des règles, pas comme des réglages :
- Aucune symétrie. Les deux flancs sont tirés indépendamment — largeurs,
hauteurs de pointes, profondeurs d'encoches diffèrent d'un côté à l'autre.
Un arbre miroir se repère au premier coup d'œil ; comparez les variantes
standardetsymetriquesur la planche de contact.asymmetry: 0rétablit le miroir si on en veut un. - Aucune arête droite. Chaque descente vers une pointe est creusée vers
l'intérieur (ce qui affûte la flèche au lieu de l'arrondir), chaque dessous de
branche s'affaisse. Réglé par
bow. - L'ombre suit la forme. Ce n'est pas un remplissage calculé sur la silhouette entière mais des traits suspendus sous chaque étage de branches, dans l'axe de la branche, longs près de la pointe et courts près du tronc — la façon dont on ombre réellement un conifère à la plume. Le flanc éclairé n'en reçoit que quelques-uns, juste assez pour que le dessin ne soit pas à moitié vide.
S'y ajoute le geste, dans src/core/ink.ts : la ligne dérive lentement
(bruit basse fréquence) au lieu de vibrer, la plume se lève par moments
(gaps), et le contour dépasse son point de fermeture au lieu de se refermer
pile (overshoot).
import { createFir } from "./src/entities/fir.ts";
const sapin = createFir({ seed: "foret-01", height: 120 });
// sapin.node -> groupe SVG en coordonnées locales
// sapin.bbox -> boîte englobante
// sapin.footprint -> empreinte au sol projetéeDeux morphologies :
silhouette(défaut) — un seul contour fermé. Depuis la flèche, chaque flanc descend vers une pointe de branche, remonte brièvement vers l'intérieur (l'encoche), puis repart plus bas et plus large ; le bas se referme sur un tronc court. C'est la morphologie de l'image de référence, lisible jusqu'à ~20 px de haut.tiers— étages triangulaires empilés, remplis en blanc opaque et se masquant les uns les autres. Plus riche, pour les sujets de premier plan.
Trois textures : hatch (défaut — traits d'ombre suspendus sous les branches en
style silhouette, hachures parallèles croisées en style tiers), stipple
(points et tirets courts, tous tracés à la largeur de la plume), ou none. Dans
tous les cas la matière se pose du côté opposé à la lumière.
| Paramètre | Défaut | Rôle |
|---|---|---|
seed |
"fir" |
Tout est déterministe : même seed, même SVG au caractère près. |
height |
120 |
Hauteur totale en pixels, du sol à la flèche. |
widthRatio |
0.5 |
Largeur du feuillage, en fraction de la hauteur. |
tiers |
auto | Nombre d'étages de branches (auto d'après la hauteur). |
taper |
0.85 |
Profil du cône : < 1 trapu, > 1 élancé. |
notchDepth / notchRise |
0.52 / 0.15 |
Géométrie des encoches entre étages. |
droop |
0.22 |
Plongée des pointes de branches. |
bow |
0.06 |
Galbe des arêtes ; 0 donne des segments droits. |
asymmetry |
1 |
Divergence entre les deux flancs ; 0 = miroir parfait. |
shade |
0.55 |
Densité de la texture d'ombre, 0 à 1. |
jitter |
0.5 |
Dérive du trait ; 0 donne un tracé géométrique exact. |
gaps |
0.05 |
Proportion du contour où la plume se lève. |
overshoot |
2 |
Dépassement au point de fermeture, en largeurs de plume. |
penWidth |
1 |
Largeur de plume, unique pour tout le composant. |
outlinePasses |
1 |
Passes du contour ; 2 = deux traits collés. |
variation |
0.7 |
Dispersion des paramètres non fournis (voir ci-dessous). |
detail |
1 |
0 silhouette nue, 1 standard, 2 branches internes. |
lightAngleDeg |
-135 |
Direction de la lumière (haut-gauche par défaut). |
La liste complète et commentée est dans FirOptions (src/entities/fir.ts).
Un paramètre non fourni est tiré autour de sa valeur par défaut, de sorte que
deux seeds donnent deux arbres de morphologies réellement différentes et pas
seulement deux textures différentes. Un paramètre fourni explicitement est
respecté à l'identique. variation: 0 fige tout et redonne exactement l'arbre
par défaut.
Des variantes prêtes à l'emploi sont exposées dans FIR_PRESETS (jeune,
eleve, trapu, fleche, esquisse, net, detaille, contour-appuye,
ombre-dense, pointille, nu, symetrique, etages…), utilisables en CLI
via --preset.
src/
core/
rng.ts PRNG déterministe (mulberry32) + sous-générateurs isolés
noise.ts bruit de valeur 1D, cohérent, pour la dérive du trait
geom.ts vecteurs, projection isométrique, tests polygonaux
pen.ts la plume : épaisseur unique, écartements sûrs, encre
ink.ts géométrie exacte -> chemins SVG « à la plume », passes
hatch.ts remplissage par traits parallèles (scanline) + demi-plan
texture.ts pointillés (points et tirets) à l'intérieur d'un polygone
svg.ts mini modèle de document SVG + sérialisation
component.ts contrat commun aux entités, placement et tri de scène
entities/
fir.ts le générateur de sapins
index.ts registre des entités (utilisé par la CLI)
cli/
render.ts rend une entité en fichier SVG
gallery.ts planche de contact HTML
test/
fir.test.ts déterminisme, épaisseur unique, asymétrie, export traceur
Une entité est dessinée en coordonnées locales, l'origine (0, 0) étant son
point de contact avec le sol, l'axe y montant en négatif. Elle ne connaît ni sa
position sur la carte ni son ordre de dessin :
interface Component {
kind: string; // "fir"
seed: string;
node: SvgNode; // groupe SVG local
bbox: Rect;
footprint: Vec2[]; // empreinte au sol projetée, pour l'implantation
groundRadius: number;// rayon au sol (unités monde), espacement et collisions
height: number;
penWidth: number; // épaisseur unique du tracé
meta: Record<string, unknown>; // paramètres résolus
}Le compositeur place l'entité (placeComponent) et trie la scène par
profondeur (composeScene) : les objets les plus proches de la caméra sont
dessinés en dernier et masquent ceux de derrière grâce au remplissage blanc
opaque. La section « Composition isométrique » de la planche de contact montre
ce mécanisme sur une forêt.
- Écran :
xvers la droite,yvers le bas (comme SVG). - Monde :
x,yau sol,zvers le haut. - Projection isométrique vraie, axes du sol à 30° : une hauteur
zse lit directement en pixels vers le haut. Les entités sont donc dessinées de face, la projection ne servant qu'à les poser sur le terrain. - Une seule couleur d'encre, une seule épaisseur, un remplissage blanc opaque pour les occlusions à l'écran uniquement.
- Créer
src/entities/<nom>.tsexposantcreate<Nom>(options): Component, construit avecmakeComponent— qui pose lestroke-widthunique. Ne jamais écrirestroke-widthsur un tracé. - L'enregistrer dans
src/entities/index.ts(ENTITIES) avec ses presets. npm run gallery -- --kind <nom>pour juger le style sur une planche.
La CLI et le futur compositeur n'ont alors rien à savoir de plus.