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

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 :

Empilement pyramidal cubique à faces centrées

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 pour luapdf et 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 fonction buildPyramid mais 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.lua que 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 rajouter return linalg à la dernière ligne de ce fichier et de charger cette bibliothèque avec local linalg = require("linalg") dans le fichier latex. Le gestionnaire de chargement de packages cherche alos le fichier linalg.lua dans le répertoire local ou bien dans TEXMF. On peut aussi utiliser dofile, avec local linalg = dofile("linalg.lua"). (Ou bien juste dofile("linalg.lua") si le fichier définit un package global linalg au 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 genre local 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.