Programmation en Lua dans LaTeX : empilement pyramidal de sphères
par Damien Mégy latex lua lualatex maths pavages
Table des matières
- Introduction et objectif
- Motivation pour utiliser autre chose que tikz/pgf en général, et lua en particulier
- Structure du code Lua
- Les constantes:
- Prérequis d'algèbre linéaire
- Construction de la pyramide
- Projection des points
- Passerelle Lua <-> LaTeX
- Code source final
- Conclusion et améliorations possibles
Introduction et objectif
Cet article est une étude de cas sur une utilisation concrète de Lua à l'intérieur d'un document LaTeX, à l'aide du moteur de rendu LuaTex au lieu de PdfTex.
L'objectif est d'obtenir facilement des illustrations d'empilements cubiques à faces centrées comme ceux-ci :

L'interêt de cette image est entre autres de mettre en évidence (dans la dernière figure) la structure hexagonale dans l'empilement pyramidal à base carrée. Cette image est produite par le code LaTeX suivant:
\printPyramid{.6}{-1}{.2}~
\printPyramid{.4}{-1}{.4}~
\printPyramid{.2}{-1}{.6}~
\printPyramid{0}{-1}{0.707}
Les trois nombres passés en paramètre représentent la position de la caméra, qui est toujours orientée vers le centre de la pyramide. (Le nombre 0.707 est une approximation de $1/\sqrt{2}$, ce qui explique pourquoi la caméra fait parfaitement face à une face de la pyramide.)
Jusqu'ici, pas de Lua en vue et la commande \printPyramid pourrait être implémentée en tikz et pgf.
Mais dans ce cas précis, la commande latex \printPyramid est définie comme suit:
\newcommand\printPyramid[3]{\directlua{printTikzPicture({#1,#2,#3})}}
Elle utilise donc directement une fonction Lua.
Motivation pour utiliser autre chose que tikz/pgf en général, et lua en particulier
La raison pour laquelle on utilise Lua dans ce cas est de pouvoir programmer dans un langage raisonnable le rendu des sphères. Le code n'est pas compliqué, mais déclarer et utiliser des variables avec \pgfmathsetmacro est une torture, et d'autre part, on doit à un moment trier les sphères par profondeur pour dessiner en dernier les sphères qui sont les plus proches de la caméra.
Tout ceci est donc pénible à faire en Tex et on désire donc utiliser un langage de programmation plus agréable. Il reste ensuite à motiver l'utilisation de Lua plutôt que, par exemple, Python (complètement externalisé, ou bien avec PythonTex).
On pourrait bien sûr externaliser le calcul des projections et le faire en python, en C, ou tout autre langage de programmation au lieu de Lua. Mais on devrait alors relancer plusieurs fois les compilations, l'execution Python etc.
L'avantage de Lualatex est d'avoir un seul fichier et de pouvoir le compiler en un seul clic depuis son éditeur préféré comme texMaker. On peut envoyer le fichier à des collègues ou des étudiants sans se soucier de leur installation Python, leur OS, bref tout est plus simple, Lualatex est pris en charge depuis plus de dix ans dans tous les éditeurs de latex, avec compilation en un clic. Latexmk permet aussi de compiler en lualatex. La compilation se fait donc, au choix :
- en cliquant sur le bouton "lualatex" au lieu de "pdflatex" dans l'éditeur,
- en ligne de commande avec
lualatex [-options] file.tex, - en ligne de commande avec
latexmk -lualatex [-options] file.tex(lualatexest un raccourci pourluapdfet d'autres options).
Structure du code Lua
Le cahier des charges est le suivant : les sphères affichées sont toujours les mêmes, mais on veut pouvoir changer facilement de point de vue, ou aficher facilement plusieurs figures, sans répéter de code.
La structure du code va être la suivante:
- Définition des constantes : rayon des sphères (1), orientation de l'espace (direction z)
- Définition des fonctions d'algèbre linéaire
- Insertion des coordonnées des centres des sphères dans un tableau
points. Cette insertion se fait au moyen d'une fonctionbuildPyramidmais pourrait être faite autrement, en lisant un fichier csv contenant les coordonnées par exemple. - Définition d'une fonction qui prend en entrée la position de la caméra et qui retourne les coordonnées des points de la pyramide dans le repère de la caméra, prêts à être projetés et affichés. De plus, ces nouvelles coordonnées sont triées sur la troisième composante, ce qui permet, lors de l'affichage, de dessiner en dernier les sphères les plus proches de la caméra, et donc de masquer les sphères précédentes.
Les constantes:
Le rayon des sphères va être 1dans notre exemple mais on va définir une constante RADIUS pour plus de clarté.
On va aussi nommer le vecteur qui détermine la "verticalité" dans le monde affiché. Pour simplifier le futur code, on va définir la direction "vers le base" et définir une constante WORLD_DOWNvalant {0,0,-1}.
Prérequis d'algèbre linéaire
Tout d'abord, on aura besoin de quelques fonctions élémentaires d'algèbre linéaire en dimension trois : transformer un vecteur non nul en vecteur unitaire, produit scalaire et produit vectoriel.
On encapsule ces fonctions dans un "module" (une simple table Lua) nommé linalg, puis on définit les trois fonctions de manière classique :
local linalg = {}
function linalg.normalize(t)
local n = math.sqrt(t[1]*t[1]+t[2]*t[2]+t[3]*t[3])
if n == 0 then return {0,0,0} end
return {t[1]/n, t[2]/n, t[3]/n}
end
function linalg.cross(u,v)
return {
u[2]*v[3]-u[3]*v[2],
u[3]*v[1]-u[1]*v[3],
u[1]*v[2]-u[2]*v[1]
}
end
function linalg.dot(u,v)
return u[1]*v[1]+u[2]*v[2]+u[3]*v[3]
end
Construction de la pyramide
La fonction prend en entrée le nombre d'étages de la pyramide. Elle construit ensuite la pyramide en bouclant sur le numéro de l'étage, puis en construisant pour chaque étage une grille carrée centrée sur (0,0):
local function buildPyramid(height)
local points = {}
for k=1,height do
for i=1,k do
for j=1,k do
table.insert(points,{k-2*i+1, k-2*j+1, math.sqrt(2)*(height-k)})
end
end
end
return points
end
Projection des points
La fonction Lua qui s'occupe de calculer les projection, puis de trier par profondeur perçue par la caméra, est la suivante.
Elle prend en entrée la position de la caméra. La caméra va toujours regarder vers l'origine. On commence don par calculer le repère associé.
ATTENTION Noter que la position de la caméra ne peut pas être du type
(0,0,z)avec ce code : on ne peut pas regarder "depuis le haut".
local function sortedProjections(cameraPosition)
local projections = {}
local camera = { w = linalg.normalize(cameraPosition) }
camera.u = linalg.normalize(linalg.cross(camera.w, WORLD_DOWN))
camera.v = linalg.normalize(linalg.cross(camera.w,camera.u))
for _, point in ipairs(points) do
table.insert(projections , {
linalg.dot(camera.u,point),
linalg.dot(camera.v,point),
linalg.dot(camera.w,point)
})
end
table.sort(projections, function(a, b) return a[3] < b[3] end)
return projections
end
Passerelle Lua <-> LaTeX
Étudions ensuite la fonction Lua printTikzPicture(). Elle prend en entrée un tableau de longeur trois, la position de la caméra, et imprime le code TikZ dans le document LaTeX.
Son code est :
function printTikzPicture(cameraPosition)
local projections = sortedProjections(cameraPosition)
local lines = { "\\begin{tikzpicture}" }
for _, p in ipairs(projections) do
table.insert(lines,string.format(
"\\draw[fill=white] (%.3fcm,%.3fcm) circle (%.1fcm);",
p[1], p[2], RADIUS
))
end
table.insert(lines,"\\end{tikzpicture}")
tex.print(table.concat(lines,"\n"))
end
Code source final
Le document LaTeX en entier:
\documentclass[margin=.5cm]{standalone}
\usepackage{tikz}
\usepackage{luacode}
\begin{document}
\begin{luacode*}
local RADIUS = 1 -- rayon des sphères
local WORLD_DOWN = {0,0,-1}
local points = {} -- coordonnées des centres des sphères
local function buildPyramid(height)
local points = {}
for k=1,height do
for i=1,k do
for j=1,k do
table.insert(points,{k-2*i+1, k-2*j+1, math.sqrt(2)*(height-k)})
end
end
end
return points
end
points = buildPyramid(5)
local linalg = {}
function linalg.normalize(t)
local n = math.sqrt(t[1]*t[1]+t[2]*t[2]+t[3]*t[3])
if n == 0 then return {0,0,0} end
return {t[1]/n, t[2]/n, t[3]/n}
end
function linalg.cross(u,v)
return {
u[2]*v[3]-u[3]*v[2],
u[3]*v[1]-u[1]*v[3],
u[1]*v[2]-u[2]*v[1]
}
end
function linalg.dot(u,v)
return u[1]*v[1]+u[2]*v[2]+u[3]*v[3]
end
local function sortedProjections(cameraPosition)
local projections = {}
local camera = { w = linalg.normalize(cameraPosition) }
camera.u = linalg.normalize(linalg.cross(camera.w, WORLD_DOWN))
camera.v = linalg.normalize(linalg.cross(camera.w,camera.u))
for _, point in ipairs(points) do
table.insert(projections , {
linalg.dot(camera.u,point),
linalg.dot(camera.v,point),
linalg.dot(camera.w,point)
})
end
table.sort(projections, function(a, b) return a[3] < b[3] end)
return projections
end
function printTikzPicture(cameraPosition)
local projections = sortedProjections(cameraPosition)
local lines = { "\\begin{tikzpicture}" }
for _, p in ipairs(projections) do
table.insert(lines,string.format(
"\\draw[fill=white] (%.3fcm,%.3fcm) circle (%.1fcm);",
p[1], p[2], RADIUS
))
end
table.insert(lines,"\\end{tikzpicture}")
tex.print(table.concat(lines,"\n"))
end
\end{luacode*}
\newcommand\printPyramid[3]{\directlua{printTikzPicture({#1,#2,#3})}}
\printPyramid{.6}{-1}{.2}~
\printPyramid{.4}{-1}{.4}~
\printPyramid{.2}{-1}{.6}~
\printPyramid{0}{-1}{0.707}
\end{document}
Conclusion et améliorations possibles
LuaLaTeX permet donc de déléguer à Lua tout ce qui relève du calcul, de l'algèbre linéaire et de la manipulation de données, tout en laissant à TikZ son rôle naturel : le dessin.
Parmi les améliorations ou variantes possibles, mentionnons quelques points:
- Les fonctions d'algèbre linéaire pourraient être externalisées dans un fichier
linalg.luaque l'on pourrait utiliser dans plusieurs fichiers latex sans dupliquer le code. Pour cela, il suffirait de placer le code en question dans un fichier externe, de rajouterreturn linalgà la dernière ligne de ce fichier et de charger cette bibliothèque aveclocal linalg = require("linalg")dans le fichier latex. Le gestionnaire de chargement de packages cherche alos le fichierlinalg.luadans le répertoire local ou bien dans TEXMF. On peut aussi utiliser dofile, aveclocal linalg = dofile("linalg.lua"). (Ou bien justedofile("linalg.lua")si le fichier définit un package globallinalgau lieu de retourner la table.) - On pourrait, au lieu d'utiliser une fonction
local points = buildPyramid(height)pour construire la pyramide, charger les coordonnées depuis un fichier externe avec quelque chose du genrelocal points = loadCoordinatesFromCsv(filename). Ceci permet de séparer la construction du modèle 3d de la logique de rendu, d'utilisr potentiellement d'autres script ou langages pour construire les points, voire un logiciel de 3d avec fonction d'exprtation en csv. - On pourrait tracer les bords des sphères avec un trait plus ou moins épais pour renforcer encore plus l'impression de profondeur. Dans ce cas il faut modifier la fonction de dessin en conséquence.