Come è fatto il visore 3D
Il visore 3D è la pagina dove la parete si gira, si apre e si
taglia (c'è anche quello della nostra idea, Visore 3D - Idea,
stessa pagina con altri modelli). Fino a questa versione era una pagina HTML con circa 600 righe di
JavaScript scritte a mano; ora la logica è scritta in Nim, compilata in
JavaScript, e la pagina è un documento nimib con un solo blocco nbJsFromCode
(il blocco di nimib che compila Nim per il browser).
Questa pagina spiega come funziona il visore e come è avvenuto il passaggio.
Perché
Prima il percorso dei dati era questo:
Nim (C) testo JavaScript
parete(p) ─► Model ─► toJson ─► 12 modelli JSON ─► visore3d.tpl.html (600 righe)
incollati nel template campi rinominati: w, d, h,
holesTop.y = holesPiano.z ...
Il visore non vedeva il tipo Part di Caderò ma una sua copia in JSON, con
nomi diversi: w, d, h invece di size.x, size.y, size.z; nei fori
del piano y e d invece di z e h. Il pannello frontale riceveva come fori
anche le sagome dei frontali, ma solo nel JSON. Ogni nuova proprietà delle parti
(apre, alzata, dentro...) andava aggiunta in tre posti: il tipo in Nim, la
conversione in JSON, il JavaScript. E il JavaScript non aveva nessun controllo:
un nome sbagliato diventava undefined senza errori.
Ora:
Nim (JS, nel browser)
parete(p) ─► Model ─► avviaVisore ─► three.js
- lo stesso codice Nim che fa le tavole calcola i modelli anche nel browser:
niente JSON, niente copie; ogni variante è una chiamata a
parete(...); - il visore usa i tipi veri (
Part,Kind,RRect) e il compilatore controlla nomi e tipi; - i valori che prima erano scritti a mano nel JavaScript (viste, materiali, assi dei tagli) sono costanti e funzioni Nim con un nome e un commento;
- la pagina diventa un documento nimib come gli altri, generato da
nimble docs.
I pezzi
| file | cosa contiene |
|---|---|
src/cadero/js/three.nim |
i binding: le classi e i metodi di three.js che servono, visti da Nim |
src/cadero/js/pagina.nim |
le poche cose del browser che mancano in std/dom |
src/cadero/visore.nim |
il visore: scena, tocchi, esploso, in Nim |
docsrc/visore3d.nim, docsrc/visore3d_idea.nim |
le due pagine (falegnami e idea): modelli e testi, il blocco nbJsFromCode |
docsrc/pagina_visore.nim |
il renderer comune: template, carta da parati |
tools/visore3d.tpl.html |
stile, pulsanti, posti per la tavola e per lo script |
tools/visore3d.nim |
dalla pagina di Pages all'artefatto claude.ai |
1. Il blocco nbJsFromCode
Il cuore della pagina è questo blocco. Non gira quando si genera il documento:
nimib lo scrive in un file, lo compila con nim js e mette il JavaScript che
esce nella pagina, dentro un <script>. Le variabili tra parentesi (qui
cartaSrc, l'immagine della carta da parati) sono catturate: nimib le
trasforma in JSON durante la generazione e il blocco le ritrova nel browser.
docsrc/visore3d_idea.nim
nbJsFromCode(cartaSrc):
import ../src/cadero/visore, ../models/parete
# nel visore tutto parte chiuso: cassetti, ante, ribalte e tavolino si aprono
# toccandoli. Tutte le combinazioni delle opzioni, con la chiave che usa il
# visore: "base" o "tavolino", poi "Scoperta" (risega), "Antina" (solo con il
# tavolino), "Comodino" (prese sul comodino); es. "tavolinoScopertaComodino".
# I modelli si calcolano qui, nel browser, con lo stesso codice Nim delle tavole.
var varianti: Table[string, Model]
for tav in [false, true]:
for scoperta in [false, true]:
for antina in (if tav: @[false, true] else: @[false]):
for comodino in [false, true]:
var p = rilievo()
p.disegno.cassettoAperto = -1
if tav: p = p.conTavolino(aperto = false)
p.libreria.risegaRivestita = not scoperta
p.tavolino.antina = antina
p.prese.sulComodino = comodino
# (la chiave si scrive a pezzi: nimib ricopia il blocco come testo e
# un `(if ...) & (if ...)` su più righe non sopravvive alla copia)
var chiave = "base"
if tav: chiave = "tavolino"
if scoperta: chiave.add "Scoperta"
if antina: chiave.add "Antina"
if comodino: chiave.add "Comodino"
varianti[chiave] = parete(p)
let carta = Carta(src: cartaSrc, w: 106, h: 52.6)
quandoThreePronto(proc () = avviaVisore(varianti, carta))
Le varianti sono le stesse di prima (base o con tavolino, risega rivestita o
scoperta, antina, prese sul comodino: 12 combinazioni), ma ora si calcolano nel
browser invece di arrivare come 12 modelli JSON incollati nella pagina. Il file da
compilare nimib lo scrive nella cartella dei documenti (docs/): per questo gli
import cominciano con ../.
2. three.js visto da Nim
three.js è una libreria JavaScript. Per usarla da Nim si scrivono dei binding: dichiarazioni Nim che dicono al compilatore quale JavaScript scrivere. Non serve un binding completo, solo le classi e i metodi che il visore usa (circa 80). Due esempi dal file dei binding:
src/cadero/js/three.nim
Vector3* = ref object of JsRoot
x*, y*, z*: float
Euler* = ref object of JsRoot
x*, y*, z*: float
Color* = ref object of JsRoot
Box3* = ref object of JsRoot
min*, max*: Vector3
src/cadero/js/three.nim
proc newBox3*(): Box3 {.importjs: "new THREE.Box3()".}
proc setFromObject*(b: Box3; o: Object3D): Box3 {.importjs: "#.setFromObject(@)", discardable.}
proc expandByObject*(b: Box3; o: Object3D): Box3 {.importjs: "#.expandByObject(@)", discardable.}
proc getCenter*(b: Box3; v: Vector3): Vector3 {.importjs: "#.getCenter(@)", discardable.}
proc getSize*(b: Box3; v: Vector3): Vector3 {.importjs: "#.getSize(@)", discardable.}
- un tipo che eredita da
JsRootcon il pragmaimportcè un oggetto JavaScript qualunque: i suoi campi (x,position,visible) si leggono e scrivono così come sono; - un proc con
importjsha un modello di JavaScript:#è il prossimo argomento,@tutti i restanti.newBox3()diventanew THREE.Box3(),b.getCenter(v)restab.getCenter(v); - i parametri "oggetto" di three.js (es.
{color: 0xe3c9a0, roughness: 0.8}) si scrivono conjs{...}distd/jsffi.
I binding hanno i nomi di three.js, così il codice del visore si legge con la documentazione di three.js accanto.
Come arriva three.js
three.js è copiato nel repository (vendor/three/, la versione 0.160.0) e la
generazione dei documenti lo mette in docs/three/, accanto alle pagine: nessuna
chiamata a un CDN. Si carica come modulo ES, con un importmap. Un modulo
parte dopo gli script normali, e quello generato da Nim è uno script normale.
Allora un piccolo modulo nella pagina mette three.js in window.THREE e manda un
evento; il codice Nim aspetta quell'evento con quandoThreePronto:
tools/visore3d.tpl.html
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
window.THREE = { ...THREE, OrbitControls };
window.dispatchEvent(new Event('three-pronto'));
</script>
src/cadero/js/three.nim
proc quandoThreePronto*(avvia: proc ()) =
## three.js è un modulo ES: si carica dopo gli script normali (come quello
## generato da Nim). La pagina lo mette in `window.THREE` e manda l'evento
## "three-pronto"; se è già arrivato, si parte subito.
proc giaPronto(): bool {.importjs: "(typeof window.THREE === 'object')".}
proc aspetta(f: proc ()) {.importjs: "window.addEventListener('three-pronto', #, {once: true})".}
if giaPronto(): avvia() else: aspetta(avvia)
3. Dalla parte alla scena
costruisci(m) scorre le parti del modello. Per ognuna sceglie la forma, la
mette nella scena con il suo materiale e i suoi spigoli e, se è apribile, la
aggancia al suo gruppo. Gli assi cambiano nome: in three.js l'alto è y e la
profondità va verso −z, quindi (x, y, z) del CAD diventano (x, z, −y).
src/cadero/visore.nim
proc geometria(p: Part): BufferGeometry =
## La forma di una parte: cilindro (foro maniglia), sagoma estrusa o scatola.
if p.kind == kKnob:
result = newCylinderGeometry(p.w / 2, p.w / 2, 0.4, 20)
result.rotateX(PI / 2)
result.translate(p.x, p.z, -p.y + 0.15)
elif p.holesPiano.len > 0 or p.taccheP.len > 0:
result = extrudedTop(p)
elif p.holes.len > 0 or p.contorno.len > 0 or (p.kind in {kFront, kBox} and p.r > 0):
result = extruded(p)
else:
result = newBoxGeometry(p.w, p.h, p.d)
result.translate(p.x + p.w / 2, p.z + p.h / 2, -(p.y + p.d / 2))
Per leggere il codice come prima, la parte ha anche i nomi brevi di una volta, calcolati dai campi veri:
src/cadero/visore.nim
proc x(p: Part): float = p.pos.x
proc y(p: Part): float = p.pos.y
proc z(p: Part): float = p.pos.z
proc w(p: Part): float = p.size.x
proc d(p: Part): float = p.size.y
proc h(p: Part): float = p.size.z
Il pannello frontale nel 3D ha come fori anche le sagome di ante e cassetti, che
vanno a occuparli. Prima questo succedeva solo nella conversione in JSON; ora è
una funzione del modello, usata sia dal visore sia da toJson:
src/cadero/model.nim
proc foriVisti*(m: Model; p: Part): seq[RRect] =
## Le aperture di una parte come le disegna il 3D. Il pannello frontale
## (un pannello con aperture) riceve come fori anche le sagome dei frontali
## e dei cassetti, che vanno a occuparle; le altre parti tengono le loro.
if p.kind == kPanel and p.holes.len > 0 and p.name == "fronte":
result = p.holes
for q in m.parts:
if q.kind in {kFront, kBox}: result.add q.frontRect
else: result = p.holes
4. Aprire e chiudere
Ogni gruppo apribile (Unita) ha due contenitori: pivot, che sta sullo spigolo
della cerniera e ruota o trasla, e inner, che riporta le parti al loro posto.
k va da 0 (chiuso) a 1 (aperto) e a ogni fotogramma insegue target; posa
dice dove sta il gruppo per un certo k, secondo il suo modo di aprirsi (apre):
src/cadero/visore.nim
proc posa(u: Unita) =
## Muove un gruppo: k va da 0 (chiuso) a 1 (aperto).
let k = u.k
case u.apre
of "tira": u.pivot.position.z = (if u.disegnataAperta: k - 1 else: k) * u.corsa
of "sx": u.pivot.rotation.y = -k * 1.75
of "dx": u.pivot.rotation.y = k * 1.75
of "su": u.pivot.rotation.x = -k * 1.6
of "alza": # i coperchi si sollevano nel vuoto e si allontanano un poco l'uno dall'altro
u.pivot.position.y = k * u.corsa; u.pivot.position.z = k * 5 * u.verso
of "solleva": u.pivot.position.y = k * u.corsa
of "esce": # il materasso sale sopra la fascia e poi esce dalla nicchia verso chi guarda
const f = 0.3
u.pivot.position.y = min(k / f, 1) * u.alzata
u.pivot.position.z = max(0, (k - f) / (1 - f)) * u.corsa
of "ribalta dx": # l'antina scende, gira intorno alla cerniera e si affianca a destra
u.pivot.rotation.z = k * PI
else: discard
Il resto del visore segue la stessa forma del JavaScript di prima, con gli stessi
nomi dove possibile (build è diventato costruisci, pose è posa): le viste
(vista(nome)), il tocco che apre (unitAt), l'esploso. (Le sezioni in SVG con i piani di
taglio c'erano e sono state tolte: le sezioni stanno nelle tavole.)
5. Come è stata fatta la migrazione
- Binding minimi. Elenco di tutto quello che il JavaScript usava di three.js e del browser, e un binding per ognuno, con i nomi originali.
- Traduzione riga per riga del JavaScript in
src/cadero/visore.nim, tenendo la stessa struttura e gli stessi commenti, così il confronto è facile. - I modelli nel browser. Il codice di
models/parete.nimcompila anche connim jssenza modifiche: il blocconbJsFromCodechiamaparete(...)e passa i modelli al visore. La conversione JSON resta solo per la vista 3D piccola dentro i documenti (viewer3dinweb.nim). - La pagina come documento nimib, con un renderer suo (copia di quello di nimib) che riempie il template del visore invece del tema dei documenti.
- Verifica. Vecchio e nuovo visore aperti in Chromium headless con le stesse azioni (vista iniziale, apri tutto, chiudi tutto, due tocchi su ante e cassetti, tavolino, antina aperta, prese sul comodino, dentro con la risega scoperta, senza carta da parati, senza fronte, esploso, sezioni, sezione selezionata e cursore spostato), con le animazioni disattivate: le 15 immagini sono identiche al pixel e gli SVG delle tre sezioni identici carattere per carattere.
Cose da sapere
- Closure nei cicli. In JavaScript
for (const name of ...)dà a ogni giro la sua variabile; in Nim no: una closure creata in un ciclo vede l'ultima. Per questo i pulsanti creati in un ciclo usanoclosureScope:con una copia della variabile (il primo tentativo aveva proprio questo errore: "Base" riapriva il tavolino). - Stringhe. In Nim JS una
stringnon è una stringa JavaScript: per DOM e three.js si passacstring. - Conversioni di tipo. Un evento DOM non ha le informazioni di tipo di Nim:
per passare da
Eventa un evento del puntatore si usacast, non la conversione normale. importjsvuole un modello. Per leggere una costante comeTHREE.RepeatWrappingil modello va tra parentesi:"(THREE.RepeatWrapping)".- Peso della pagina. Il JavaScript generato (visore + modello + libreria standard di Nim) prende il posto dei 12 modelli JSON: la pagina passa da circa 350 KB a circa 495 KB (la carta da parati ne pesa 60). Con più varianti la differenza cresce a favore di Nim: una variante in più è una chiamata, non un JSON.
- Il blocco è ricopiato come testo. nimib trasforma il blocco
nbJsFromCodein testo e lo ricompila: un'espressione come(if a: "x" else: "") & (if b: ...)su più righe non sopravvive alla copia. Meglio istruzioni semplici, una per riga. - Compilazione.
nimble docsora chiama anchenim jsper il blocco del visore: qualche secondo in più.
6. Cosa diventa possibile
Visto che il modello si calcola nel browser, il prossimo passo naturale sono i
cursori delle misure nel visore: cambiare un parametro (per esempio l'altezza
della nicchia) e ricostruire la scena con parete(p) senza rigenerare la pagina.
Comandi
nimble docs # tutti i documenti, visore compreso (anche nim js)
nim r docsrc/visore3d.nim # solo il visore -> docs/visore3d.html
nim r tools/visore3d.nim # dopo il visore: tools/parete-3d.html per l'artefatto