bots-garden/mini-mepublic Fork
⑂ a334114
Commits
⬇ Clone ▾
git clone https://git.rickub.com/bots-garden/mini-me.git
git clone ssh://git@rickub.com/bots-garden/mini-me.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

🛟 Updated.

k33g committed 2026-11-13T13:07:49+01:00 Browse files
a334114 parent: 4cf6a27
added demos/qwen-style/workspace/HANDOFF.md +118 -0
new file mode 100644
@@ -0,0 +1,118 @@
1+# Handoff — état du projet `povray`
2+
3+Dernière mise à jour : après le path tracing et le tutoriel.
4+Ce fichier permet de reprendre le travail sans re-lire toute l'historique.
5+
6+## Ce qu'est le projet
7+
8+Bibliothèque Go **sans dépendances** qui rend des scènes façon POV-Ray :
9+on décrit objets, matériaux, lumières et caméra en code Go, la
10+bibliothèque trace des rayons et produit une image PNG.
11+
12+- Module : `github.com/user/povray` (**placeholder** — voir "À faire")
13+- Go 1.27, encodeur PNG maison (stdlib only), rendu parallèle en
14+ chunks de lignes (goroutines + `sync.WaitGroup`)
15+
16+## État : ce qui marche
17+
18+Tout est vérifié : `gofmt` propre, `go vet` propre sur la bibliothèque,
19+`go test ./...` passe, les 5 exemples rendent des PNG 800×600 valides.
20+
21+| Fichier | Rôle |
22+|---|---|
23+| `vector.go` | `Vec3`, `Ray`, `Hit`, `Flip` |
24+| `objects.go` | interface `Object` ; `Sphere`, `Plane`, `Box`, `Group` |
25+| `material.go` | `Color` (+ presets nommés), `Material` (dont `Emission`), `Light`, `LightMaterial` |
26+| `camera.go` | caméra trou d'épingle ; `RayAt` (centre pixel) et `RayAtUV` (point fractionnaire, pour le supersampling) |
27+| `transform.go` | `Mat4` 4×4 : `Translate`/`RotateX/Y/Z`/`Scale`/`Multiply`/`Inverse` (Gauss-Jordan) ; `Transform{Object, Matrix}` |
28+| `renderer.go` | `Scene`, `Renderer` (réflexions/réfractions, ombres, `Samples` = grille n×n), `Image`, PPM |
29+| `pathtracer.go` | `PathTracer` : GI, ombres douces, cosinus sampling, russe roulette, déterministe (graines PCG par goroutine) |
30+| `png.go` | encodeur PNG maison, `Image.SavePNG` |
31+| `povray_test.go` | tests unitaires + smoke tests de rendu (dont `TestSupersampling`, `TestPathTracer`) |
32+| `examples/` | `spheres`, `mirror`, `procedural`, `transform`, `pathtrace` — chacun écrit son `.png` |
33+| `docs/TUTORIEL.md` | guide FR en 7 niveaux (0→6) pour composer de belles images |
34+| `README.md` | référence API (EN) |
35+| `ROADMAP.md` | ce qui manque, classé par impact |
36+
37+## Décisions importantes (ne pas re-discuter sans raison)
38+
39+- **Zéro dépendance** : PNG écrit à la main (`compress/zlib`, `hash/crc32`,
40+ `encoding/binary`). PPM reste dispo.
41+- **Littéraux non-keyed** `Vec3{1, 0, 0}` / `Color{...}` : style
42+ bibliothèque de maths, assumé ; `go vet` le signale dans les exemples,
43+ c'est documenté dans le README.
44+- **Ordre des transformations** : `Multiply` s'applique de droite à
45+ gauche (inside-out) — `Translate(...).Multiply(RotateX(...))` =
46+ rotation d'abord. Documenté dans le tutoriel (niveau 3).
47+- **Normales sous transformation** : transposée de l'inverse (correct
48+ sous scale non uniforme), renormalisées.
49+- **Path tracer** : lumières = surfaces émissives uniquement
50+ (`LightMaterial`) ; `Scene.Lights` ignorées. `Samples` = rayons/pixel
51+ (pas une grille). Déterministe par design.
52+- **Ombres** : les objets avec `Transparency > 0.5` ne portent pas
53+ d'ombre (documenté).
54+- **Gamma 2.2** appliqué dans `Image.Set` (toujours, pas optionnel).
55+- **Tutoriel en français**, README en anglais — volontaire.
56+
57+## Pièges connus (déjà tombés dedans, ne pas re-tomber)
58+
59+- `RayAtUV` prend des `float64` : `px+0.5` sur un `int` est une
60+ troncature silencieuse (vet le catche). Toujours `float64(px)+0.5`.
61+- `Hit.Dist` est un `float64` : `.Len()` pas `.Norm()` (qui renvoie un
62+ `Vec3`).
63+- **Boucle infinie de rebonds** : verre/miroir peuvent rebondir
64+ indéfiniment → stack overflow. Le `PathTracer` a une limite dure de
65+ 50 (le `Renderer` est borné par `MaxDepth`). Tout nouveau type de
66+ rebond doit respecter une limite.
67+- `Box` : les normales doivent pointer vers l'extérieur (le `Copysign`
68+ est négatif dans `objects.go` — ne pas "corriger" en le retournant).
69+- Le `Transform` avec `Matrix` zéro (non initialisé) est singulier :
70+ `Inverse` renvoie `ok=false`, l'objet est simplement invisible.
71+ Utiliser `povray.Identity()` si on ne transforme pas.
72+- Tests de rendu : les couleurs après gamma ne sont pas les valeurs
73+ attendues naïvement (ex. gris 0.5 → 186, pas 128). Mesurer avant
74+ d'écrire les seuils.
75+- IEND d'un PNG : length(4) à `len-12`, "IEND" à `len-8`, CRC
76+ `AE426082` à `len-4`.
77+
78+## Commandes utiles
79+
80+```sh
81+cd workspace
82+go test ./... # tous les tests
83+go vet . # propre sur la bibliothèque
84+go build ./...
85+go run ./examples/spheres # ~1 s
86+go run ./examples/transform # ~1 s
87+go run ./examples/pathtrace # ~40 s (64 samples/pixel)
88+```
89+
90+## À faire (ordre suggéré, détail dans ROADMAP.md)
91+
92+Les 3 gros items du rendu sont faits (transformations, anti-aliasing,
93+path tracing). Reste :
94+
95+1. **Hygiène de package** (~1 h, rend le projet publiable) :
96+ - module path réel (remplacer `github.com/user/povray` — demander à
97+ l'utilisateur son identifiant GitHub)
98+ - `LICENSE` (MIT ou Apache — demander laquelle)
99+ - CI GitHub Actions (`go test` + `go vet` + `gofmt -l`)
100+ - `Makefile` (`test`, `render`, `clean`)
101+ - doc de package (`// Package povray ...` dans un fichier)
102+2. **Textures par position** (checker, gradient, bruit) — le matériau
103+ est encore une couleur fixe.
104+3. **Maillage triangles** (import `.obj`) — la primitive manquante pour
105+ les vraies scènes.
106+4. Divers : torus/cylindre/cône, normal mapping, importance sampling
107+ des lumières, gamma optionnel.
108+
109+## Si on reprend
110+
111+1. Lire ce fichier.
112+2. `go test ./...` pour vérifier l'état de base.
113+3. Choisir un item "À faire" (demander à l'utilisateur s'il n'a pas
114+ déjà tranché).
115+4. Le tutoriel (`docs/TUTORIEL.md`) est la référence de ce que l'API
116+ doit continuer à rendre lisible — toute évolution d'API doit être
117+ reflétée dedans, dans le README et dans le ROADMAP (les trois
118+ restent synchronisés).
new file mode 100644
@@ -0,0 +1,118 @@
1+# Handoff — état du projet `povray`
2+
3+Dernière mise à jour : après le path tracing et le tutoriel.
4+Ce fichier permet de reprendre le travail sans re-lire toute l'historique.
5+
6+## Ce qu'est le projet
7+
8+Bibliothèque Go **sans dépendances** qui rend des scènes façon POV-Ray :
9+on décrit objets, matériaux, lumières et caméra en code Go, la
10+bibliothèque trace des rayons et produit une image PNG.
11+
12+- Module : `github.com/user/povray` (**placeholder** — voir "À faire")
13+- Go 1.27, encodeur PNG maison (stdlib only), rendu parallèle en
14+ chunks de lignes (goroutines + `sync.WaitGroup`)
15+
16+## État : ce qui marche
17+
18+Tout est vérifié : `gofmt` propre, `go vet` propre sur la bibliothèque,
19+`go test ./...` passe, les 5 exemples rendent des PNG 800×600 valides.
20+
21+| Fichier | Rôle |
22+|---|---|
23+| `vector.go` | `Vec3`, `Ray`, `Hit`, `Flip` |
24+| `objects.go` | interface `Object` ; `Sphere`, `Plane`, `Box`, `Group` |
25+| `material.go` | `Color` (+ presets nommés), `Material` (dont `Emission`), `Light`, `LightMaterial` |
26+| `camera.go` | caméra trou d'épingle ; `RayAt` (centre pixel) et `RayAtUV` (point fractionnaire, pour le supersampling) |
27+| `transform.go` | `Mat4` 4×4 : `Translate`/`RotateX/Y/Z`/`Scale`/`Multiply`/`Inverse` (Gauss-Jordan) ; `Transform{Object, Matrix}` |
28+| `renderer.go` | `Scene`, `Renderer` (réflexions/réfractions, ombres, `Samples` = grille n×n), `Image`, PPM |
29+| `pathtracer.go` | `PathTracer` : GI, ombres douces, cosinus sampling, russe roulette, déterministe (graines PCG par goroutine) |
30+| `png.go` | encodeur PNG maison, `Image.SavePNG` |
31+| `povray_test.go` | tests unitaires + smoke tests de rendu (dont `TestSupersampling`, `TestPathTracer`) |
32+| `examples/` | `spheres`, `mirror`, `procedural`, `transform`, `pathtrace` — chacun écrit son `.png` |
33+| `docs/TUTORIEL.md` | guide FR en 7 niveaux (0→6) pour composer de belles images |
34+| `README.md` | référence API (EN) |
35+| `ROADMAP.md` | ce qui manque, classé par impact |
36+
37+## Décisions importantes (ne pas re-discuter sans raison)
38+
39+- **Zéro dépendance** : PNG écrit à la main (`compress/zlib`, `hash/crc32`,
40+ `encoding/binary`). PPM reste dispo.
41+- **Littéraux non-keyed** `Vec3{1, 0, 0}` / `Color{...}` : style
42+ bibliothèque de maths, assumé ; `go vet` le signale dans les exemples,
43+ c'est documenté dans le README.
44+- **Ordre des transformations** : `Multiply` s'applique de droite à
45+ gauche (inside-out) — `Translate(...).Multiply(RotateX(...))` =
46+ rotation d'abord. Documenté dans le tutoriel (niveau 3).
47+- **Normales sous transformation** : transposée de l'inverse (correct
48+ sous scale non uniforme), renormalisées.
49+- **Path tracer** : lumières = surfaces émissives uniquement
50+ (`LightMaterial`) ; `Scene.Lights` ignorées. `Samples` = rayons/pixel
51+ (pas une grille). Déterministe par design.
52+- **Ombres** : les objets avec `Transparency > 0.5` ne portent pas
53+ d'ombre (documenté).
54+- **Gamma 2.2** appliqué dans `Image.Set` (toujours, pas optionnel).
55+- **Tutoriel en français**, README en anglais — volontaire.
56+
57+## Pièges connus (déjà tombés dedans, ne pas re-tomber)
58+
59+- `RayAtUV` prend des `float64` : `px+0.5` sur un `int` est une
60+ troncature silencieuse (vet le catche). Toujours `float64(px)+0.5`.
61+- `Hit.Dist` est un `float64` : `.Len()` pas `.Norm()` (qui renvoie un
62+ `Vec3`).
63+- **Boucle infinie de rebonds** : verre/miroir peuvent rebondir
64+ indéfiniment → stack overflow. Le `PathTracer` a une limite dure de
65+ 50 (le `Renderer` est borné par `MaxDepth`). Tout nouveau type de
66+ rebond doit respecter une limite.
67+- `Box` : les normales doivent pointer vers l'extérieur (le `Copysign`
68+ est négatif dans `objects.go` — ne pas "corriger" en le retournant).
69+- Le `Transform` avec `Matrix` zéro (non initialisé) est singulier :
70+ `Inverse` renvoie `ok=false`, l'objet est simplement invisible.
71+ Utiliser `povray.Identity()` si on ne transforme pas.
72+- Tests de rendu : les couleurs après gamma ne sont pas les valeurs
73+ attendues naïvement (ex. gris 0.5 → 186, pas 128). Mesurer avant
74+ d'écrire les seuils.
75+- IEND d'un PNG : length(4) à `len-12`, "IEND" à `len-8`, CRC
76+ `AE426082` à `len-4`.
77+
78+## Commandes utiles
79+
80+```sh
81+cd workspace
82+go test ./... # tous les tests
83+go vet . # propre sur la bibliothèque
84+go build ./...
85+go run ./examples/spheres # ~1 s
86+go run ./examples/transform # ~1 s
87+go run ./examples/pathtrace # ~40 s (64 samples/pixel)
88+```
89+
90+## À faire (ordre suggéré, détail dans ROADMAP.md)
91+
92+Les 3 gros items du rendu sont faits (transformations, anti-aliasing,
93+path tracing). Reste :
94+
95+1. **Hygiène de package** (~1 h, rend le projet publiable) :
96+ - module path réel (remplacer `github.com/user/povray` — demander à
97+ l'utilisateur son identifiant GitHub)
98+ - `LICENSE` (MIT ou Apache — demander laquelle)
99+ - CI GitHub Actions (`go test` + `go vet` + `gofmt -l`)
100+ - `Makefile` (`test`, `render`, `clean`)
101+ - doc de package (`// Package povray ...` dans un fichier)
102+2. **Textures par position** (checker, gradient, bruit) — le matériau
103+ est encore une couleur fixe.
104+3. **Maillage triangles** (import `.obj`) — la primitive manquante pour
105+ les vraies scènes.
106+4. Divers : torus/cylindre/cône, normal mapping, importance sampling
107+ des lumières, gamma optionnel.
108+
109+## Si on reprend
110+
111+1. Lire ce fichier.
112+2. `go test ./...` pour vérifier l'état de base.
113+3. Choisir un item "À faire" (demander à l'utilisateur s'il n'a pas
114+ déjà tranché).
115+4. Le tutoriel (`docs/TUTORIEL.md`) est la référence de ce que l'API
116+ doit continuer à rendre lisible — toute évolution d'API doit être
117+ reflétée dedans, dans le README et dans le ROADMAP (les trois
118+ restent synchronisés).
added demos/qwen-style/workspace/README.md +127 -0
new file mode 100644
@@ -0,0 +1,127 @@
1+# povray — a small Go ray tracer
2+
3+A dependency-free Go library for rendering scenes the way POV-Ray does:
4+you describe objects, materials, lights and a camera in Go code, and the
5+library traces rays to produce an image.
6+
7+```
8+workspace/
9+├── go.mod
10+├── HANDOFF.md state snapshot for resuming work later
11+├── vector.go Vec3 math, Ray, Hit
12+├── objects.go Sphere, Plane, Box, Group + the Object interface
13+├── material.go Color, Material, Light
14+├── camera.go pinhole Camera
15+├── transform.go Mat4 (translate/rotate/scale, inverse) + Transform object
16+├── renderer.go Scene, Renderer, Image, PPM writer
17+├── pathtracer.go PathTracer (global illumination, soft shadows)
18+├── png.go Image.SavePNG (dependency-free PNG encoder)
19+├── docs/
20+│ └── TUTORIEL.md step-by-step guide (French) for making good images
21+└── examples/
22+ ├── spheres/ basic diffuse/specular scene
23+ ├── mirror/ reflections + a glass sphere
24+ ├── procedural/ a scene generated in loops (hue sweep grid)
25+ ├── transform/ tilted boxes, ellipsoid, ring of rotated boxes
26+ └── pathtrace/ path-traced room with emissive light panels
27+```
28+
29+## Quick start
30+
31+```go
32+scene := povray.NewScene()
33+scene.AddObject(povray.Sphere{Center: povray.Vec3{0, 1, 0}, Radius: 1},
34+ povray.Material{Diffuse: povray.Red, Specular: povray.White, Shininess: 60, Ambient: 0.1})
35+scene.AddObject(povray.NewPlaneXY(0),
36+ povray.Material{Diffuse: povray.Gray, Ambient: 0.3})
37+scene.Lights = []povray.Light{povray.NewLight(povray.Vec3{4, 8, 6}, povray.White)}
38+
39+cam := povray.NewCamera(povray.Vec3{0, 2.5, 10})
40+r := povray.Renderer{Scene: scene, Width: 800, Height: 600, Samples: 2}
41+img := r.Render(cam)
42+img.SavePNG("out.png") // or img.SavePPM("out.ppm")
43+```
44+
45+## Concepts
46+
47+- **Objects** implement `Object` (`Intersect(Ray) (Hit, bool)`). Built in:
48+ `Sphere`, `Plane` (infinite), `Box` (axis-aligned), `Group` (closest of
49+ many). Add your own by implementing the interface.
50+- **Transforms** place any object anywhere: `Translate`, `RotateX/Y/Z`,
51+ `Scale` build a `Mat4` (composed with `Multiply`, applied inside-out),
52+ and `Transform{Object, Matrix}` maps the ray into the object's local
53+ space, intersects there, and maps the hit point and normal back to
54+ world space — normals go through the inverse transpose, so non-uniform
55+ scale stays correct.
56+- **Materials** control the look: `Diffuse` (base color), `Specular` +
57+ `Shininess` (highlights), `Ambient`, `Reflectivity` (mirror, 0..1) and
58+ `Transparency` + `IOR` (glass, 0..1).
59+- **Lights** are colored point lights with inverse-square falloff; the
60+ renderer casts shadow rays for each.
61+- **Camera** is a pinhole camera: position, look-at point, up vector and
62+ vertical field of view.
63+- **Renderer** is parallel (row chunks across goroutines) and limits
64+ reflection/refraction bounces with `Scene.MaxDepth`. Set `Samples`
65+ for anti-aliasing: `Samples: 2` shoots a 2×2 grid of rays per pixel
66+ and averages them (0 or 1 = one ray per pixel, no anti-aliasing).
67+- **PathTracer** renders the same scenes with path tracing: rays
68+ bounce between surfaces, giving global illumination and soft
69+ shadows. Lights are emissive surfaces (`LightMaterial(color,
70+ intensity)`); `Scene.Lights` are ignored. `Samples` is the number of
71+ rays per pixel (16 for previews, 64+ for clean output). Renders are
72+ deterministic.
73+
74+## Run the examples
75+
76+```sh
77+go run ./examples/spheres # writes spheres.png
78+go run ./examples/mirror # writes mirror.png
79+go run ./examples/procedural # writes procedural.png
80+go run ./examples/transform # writes transform.png
81+go run ./examples/pathtrace # writes pathtrace.png (slower)
82+```
83+
84+For a step-by-step guide to composing good-looking scenes (materials,
85+camera, transforms, path tracing, color recipes), see
86+[docs/TUTORIEL.md](docs/TUTORIEL.md).
87+
88+Output is PNG (8-bit truecolor, written by the library's own encoder — no
89+dependencies). `SavePPM` is still available if you prefer PPM.
90+
91+## Inspiration
92+
93+The architecture and math follow the classic educational ray-tracing books;
94+the Go idioms are ours.
95+
96+- **Ray Tracing in One Weekend** (Peter Shirley) — the main influence:
97+ the `Vec3`/`Ray`/`Hit` model, the quadratic-discriminant sphere
98+ intersection, and the incremental build-up (sphere → ground → light →
99+ specular → shadows → reflections → refraction).
100+- **POV-Ray** — the library's name and scene model come from it: describe
101+ objects, materials, lights and a camera; the engine traces rays.
102+- **The Ray Tracer Challenge** (Shirley) — the unit-test style,
103+ validating each primitive with simple geometric cases (head-on hit,
104+ miss, ray from inside).
105+
106+Specific to this implementation (not in the books, which are C++/Java):
107+
108+- the Go `Object` interface with `Group` as a generic combinator
109+- parallel rendering in row chunks across goroutines (`sync.WaitGroup`)
110+- the hand-rolled PNG encoder (`png.go`) — the books only write PPM
111+- inverse-square light falloff with clamping in `shade`
112+
113+If you want to go further, *Ray Tracing in One Weekend* (and its sequel,
114+*Realistic Rendering with Path Tracing*) is the reference to read — this
115+codebase closely follows its progression.
116+
117+## Limitations
118+
119+No textures, no area lights (emissive surfaces are the light source for
120+the path tracer), no triangle meshes. Anti-aliasing is grid
121+supersampling (`Renderer.Samples`) plus stochastic sampling in the
122+`PathTracer`. Transforms are per-object (`Transform` wrapper); there is
123+no scene-wide model/view matrix yet.
124+
125+Note: `Vec3` and `Color` are intentionally written unkeyed
126+(`povray.Vec3{1, 0, 0}`) — `go vet` will flag this in example code; that's
127+expected for a math library.
new file mode 100644
@@ -0,0 +1,127 @@
1+# povray — a small Go ray tracer
2+
3+A dependency-free Go library for rendering scenes the way POV-Ray does:
4+you describe objects, materials, lights and a camera in Go code, and the
5+library traces rays to produce an image.
6+
7+```
8+workspace/
9+├── go.mod
10+├── HANDOFF.md state snapshot for resuming work later
11+├── vector.go Vec3 math, Ray, Hit
12+├── objects.go Sphere, Plane, Box, Group + the Object interface
13+├── material.go Color, Material, Light
14+├── camera.go pinhole Camera
15+├── transform.go Mat4 (translate/rotate/scale, inverse) + Transform object
16+├── renderer.go Scene, Renderer, Image, PPM writer
17+├── pathtracer.go PathTracer (global illumination, soft shadows)
18+├── png.go Image.SavePNG (dependency-free PNG encoder)
19+├── docs/
20+│ └── TUTORIEL.md step-by-step guide (French) for making good images
21+└── examples/
22+ ├── spheres/ basic diffuse/specular scene
23+ ├── mirror/ reflections + a glass sphere
24+ ├── procedural/ a scene generated in loops (hue sweep grid)
25+ ├── transform/ tilted boxes, ellipsoid, ring of rotated boxes
26+ └── pathtrace/ path-traced room with emissive light panels
27+```
28+
29+## Quick start
30+
31+```go
32+scene := povray.NewScene()
33+scene.AddObject(povray.Sphere{Center: povray.Vec3{0, 1, 0}, Radius: 1},
34+ povray.Material{Diffuse: povray.Red, Specular: povray.White, Shininess: 60, Ambient: 0.1})
35+scene.AddObject(povray.NewPlaneXY(0),
36+ povray.Material{Diffuse: povray.Gray, Ambient: 0.3})
37+scene.Lights = []povray.Light{povray.NewLight(povray.Vec3{4, 8, 6}, povray.White)}
38+
39+cam := povray.NewCamera(povray.Vec3{0, 2.5, 10})
40+r := povray.Renderer{Scene: scene, Width: 800, Height: 600, Samples: 2}
41+img := r.Render(cam)
42+img.SavePNG("out.png") // or img.SavePPM("out.ppm")
43+```
44+
45+## Concepts
46+
47+- **Objects** implement `Object` (`Intersect(Ray) (Hit, bool)`). Built in:
48+ `Sphere`, `Plane` (infinite), `Box` (axis-aligned), `Group` (closest of
49+ many). Add your own by implementing the interface.
50+- **Transforms** place any object anywhere: `Translate`, `RotateX/Y/Z`,
51+ `Scale` build a `Mat4` (composed with `Multiply`, applied inside-out),
52+ and `Transform{Object, Matrix}` maps the ray into the object's local
53+ space, intersects there, and maps the hit point and normal back to
54+ world space — normals go through the inverse transpose, so non-uniform
55+ scale stays correct.
56+- **Materials** control the look: `Diffuse` (base color), `Specular` +
57+ `Shininess` (highlights), `Ambient`, `Reflectivity` (mirror, 0..1) and
58+ `Transparency` + `IOR` (glass, 0..1).
59+- **Lights** are colored point lights with inverse-square falloff; the
60+ renderer casts shadow rays for each.
61+- **Camera** is a pinhole camera: position, look-at point, up vector and
62+ vertical field of view.
63+- **Renderer** is parallel (row chunks across goroutines) and limits
64+ reflection/refraction bounces with `Scene.MaxDepth`. Set `Samples`
65+ for anti-aliasing: `Samples: 2` shoots a 2×2 grid of rays per pixel
66+ and averages them (0 or 1 = one ray per pixel, no anti-aliasing).
67+- **PathTracer** renders the same scenes with path tracing: rays
68+ bounce between surfaces, giving global illumination and soft
69+ shadows. Lights are emissive surfaces (`LightMaterial(color,
70+ intensity)`); `Scene.Lights` are ignored. `Samples` is the number of
71+ rays per pixel (16 for previews, 64+ for clean output). Renders are
72+ deterministic.
73+
74+## Run the examples
75+
76+```sh
77+go run ./examples/spheres # writes spheres.png
78+go run ./examples/mirror # writes mirror.png
79+go run ./examples/procedural # writes procedural.png
80+go run ./examples/transform # writes transform.png
81+go run ./examples/pathtrace # writes pathtrace.png (slower)
82+```
83+
84+For a step-by-step guide to composing good-looking scenes (materials,
85+camera, transforms, path tracing, color recipes), see
86+[docs/TUTORIEL.md](docs/TUTORIEL.md).
87+
88+Output is PNG (8-bit truecolor, written by the library's own encoder — no
89+dependencies). `SavePPM` is still available if you prefer PPM.
90+
91+## Inspiration
92+
93+The architecture and math follow the classic educational ray-tracing books;
94+the Go idioms are ours.
95+
96+- **Ray Tracing in One Weekend** (Peter Shirley) — the main influence:
97+ the `Vec3`/`Ray`/`Hit` model, the quadratic-discriminant sphere
98+ intersection, and the incremental build-up (sphere → ground → light →
99+ specular → shadows → reflections → refraction).
100+- **POV-Ray** — the library's name and scene model come from it: describe
101+ objects, materials, lights and a camera; the engine traces rays.
102+- **The Ray Tracer Challenge** (Shirley) — the unit-test style,
103+ validating each primitive with simple geometric cases (head-on hit,
104+ miss, ray from inside).
105+
106+Specific to this implementation (not in the books, which are C++/Java):
107+
108+- the Go `Object` interface with `Group` as a generic combinator
109+- parallel rendering in row chunks across goroutines (`sync.WaitGroup`)
110+- the hand-rolled PNG encoder (`png.go`) — the books only write PPM
111+- inverse-square light falloff with clamping in `shade`
112+
113+If you want to go further, *Ray Tracing in One Weekend* (and its sequel,
114+*Realistic Rendering with Path Tracing*) is the reference to read — this
115+codebase closely follows its progression.
116+
117+## Limitations
118+
119+No textures, no area lights (emissive surfaces are the light source for
120+the path tracer), no triangle meshes. Anti-aliasing is grid
121+supersampling (`Renderer.Samples`) plus stochastic sampling in the
122+`PathTracer`. Transforms are per-object (`Transform` wrapper); there is
123+no scene-wide model/view matrix yet.
124+
125+Note: `Vec3` and `Color` are intentionally written unkeyed
126+(`povray.Vec3{1, 0, 0}`) — `go vet` will flag this in example code; that's
127+expected for a math library.
added demos/qwen-style/workspace/ROADMAP.md +62 -0
new file mode 100644
@@ -0,0 +1,62 @@
1+# Roadmap — what's missing
2+
3+Status snapshot of the `povray` library and the work that remains, in
4+order of impact. The core is solid (primitives, materials, shadows,
5+reflections, refraction, parallel rendering, PNG, tests); this is a
6+*minimal* ray tracer with a clear path to grow.
7+
8+## 1. Rendering — biggest visual impact
9+
10+- **Transformations** (done — see `transform.go`). Objects can now be
11+ translated, rotated and (non-uniformly) scaled via the `Transform`
12+ wrapper; normals are mapped with the inverse transpose so lighting
13+ stays correct under non-uniform scale.
14+- **Anti-aliasing** (done — see `Renderer.Samples`). Grid supersampling:
15+ `Samples: n` shoots an n×n grid of rays per pixel and averages them.
16+ All examples now render with `Samples: 2`.
17+- **Textures** — color by position (checker, gradient, noise). Today a
18+ material is a single fixed color.
19+- **Normal mapping / bump** — non-smooth surfaces without meshes.
20+- **Path tracing** (done — see `pathtracer.go`). The `PathTracer`
21+ bounces rays between surfaces: global illumination, soft shadows,
22+ colored bounce light. Lights are emissive surfaces
23+ (`LightMaterial`); renders are deterministic.
24+- **Area lights** — partially covered: emissive surfaces (boxes) act as
25+ area lights in the path tracer. A dedicated, more efficient area-light
26+ sampler (importance sampling over the light) is still missing.
27+- **More primitives**: torus, cylinder, cone, and especially a
28+ **triangle mesh** (to import `.obj` models).
29+
30+## 2. Physical quality
31+
32+- **Gamma / tone mapping** — the current output is linear; gamma 2.2
33+ makes colors closer to what the eye expects.
34+- **Refraction and shadows** — transparent objects are ignored by
35+ shadow rays (documented limitation); acceptable, but noted.
36+
37+## 3. Engineering — what a publishable package needs
38+
39+- **Real module path** — `github.com/user/povray` is a placeholder;
40+ nobody can `go get` the package.
41+- **CI** — no `.github/workflows`; a simple `go test` + `go vet` +
42+ `gofmt -l` on GitHub Actions.
43+- **Test coverage** — examples have no tests; missing cases: `Box` seen
44+ from inside, nested `Group`, refraction through the glass sphere,
45+ `SavePNG` on a rendered (not 4×3) image.
46+- **`go vet` clean everywhere** — unkeyed `Vec3{...}`/`Color{...}`
47+ literals in examples are still flagged; key them, add a `//nolint`,
48+ or provide constructors.
49+- **License** — no `LICENSE` (MIT/Apache), so the code is "all rights
50+ reserved" by default.
51+- **`Makefile`** — `test`, `render`, `clean` targets.
52+- **Docs** — public types are commented, but there is no package-level
53+ doc (`// Package povray ...`).
54+
55+## Suggested order
56+
57+1. ~~**Transformations**~~ — done (`transform.go`, `examples/transform`).
58+2. ~~**Anti-aliasing**~~ — done (`Renderer.Samples`, grid supersampling).
59+3. ~~**Path tracing**~~ — done (`pathtracer.go`, `examples/pathtrace`).
60+
61+Package hygiene (real module path + `LICENSE` + CI + `Makefile`) is
62+about an hour of work and makes the package publishable.
new file mode 100644
@@ -0,0 +1,62 @@
1+# Roadmap — what's missing
2+
3+Status snapshot of the `povray` library and the work that remains, in
4+order of impact. The core is solid (primitives, materials, shadows,
5+reflections, refraction, parallel rendering, PNG, tests); this is a
6+*minimal* ray tracer with a clear path to grow.
7+
8+## 1. Rendering — biggest visual impact
9+
10+- **Transformations** (done — see `transform.go`). Objects can now be
11+ translated, rotated and (non-uniformly) scaled via the `Transform`
12+ wrapper; normals are mapped with the inverse transpose so lighting
13+ stays correct under non-uniform scale.
14+- **Anti-aliasing** (done — see `Renderer.Samples`). Grid supersampling:
15+ `Samples: n` shoots an n×n grid of rays per pixel and averages them.
16+ All examples now render with `Samples: 2`.
17+- **Textures** — color by position (checker, gradient, noise). Today a
18+ material is a single fixed color.
19+- **Normal mapping / bump** — non-smooth surfaces without meshes.
20+- **Path tracing** (done — see `pathtracer.go`). The `PathTracer`
21+ bounces rays between surfaces: global illumination, soft shadows,
22+ colored bounce light. Lights are emissive surfaces
23+ (`LightMaterial`); renders are deterministic.
24+- **Area lights** — partially covered: emissive surfaces (boxes) act as
25+ area lights in the path tracer. A dedicated, more efficient area-light
26+ sampler (importance sampling over the light) is still missing.
27+- **More primitives**: torus, cylinder, cone, and especially a
28+ **triangle mesh** (to import `.obj` models).
29+
30+## 2. Physical quality
31+
32+- **Gamma / tone mapping** — the current output is linear; gamma 2.2
33+ makes colors closer to what the eye expects.
34+- **Refraction and shadows** — transparent objects are ignored by
35+ shadow rays (documented limitation); acceptable, but noted.
36+
37+## 3. Engineering — what a publishable package needs
38+
39+- **Real module path** — `github.com/user/povray` is a placeholder;
40+ nobody can `go get` the package.
41+- **CI** — no `.github/workflows`; a simple `go test` + `go vet` +
42+ `gofmt -l` on GitHub Actions.
43+- **Test coverage** — examples have no tests; missing cases: `Box` seen
44+ from inside, nested `Group`, refraction through the glass sphere,
45+ `SavePNG` on a rendered (not 4×3) image.
46+- **`go vet` clean everywhere** — unkeyed `Vec3{...}`/`Color{...}`
47+ literals in examples are still flagged; key them, add a `//nolint`,
48+ or provide constructors.
49+- **License** — no `LICENSE` (MIT/Apache), so the code is "all rights
50+ reserved" by default.
51+- **`Makefile`** — `test`, `render`, `clean` targets.
52+- **Docs** — public types are commented, but there is no package-level
53+ doc (`// Package povray ...`).
54+
55+## Suggested order
56+
57+1. ~~**Transformations**~~ — done (`transform.go`, `examples/transform`).
58+2. ~~**Anti-aliasing**~~ — done (`Renderer.Samples`, grid supersampling).
59+3. ~~**Path tracing**~~ — done (`pathtracer.go`, `examples/pathtrace`).
60+
61+Package hygiene (real module path + `LICENSE` + CI + `Makefile`) is
62+about an hour of work and makes the package publishable.
added demos/qwen-style/workspace/camera.go +47 -0
new file mode 100644
@@ -0,0 +1,47 @@
1+package povray
2+
3+import "math"
4+
5+// Camera is a pinhole camera looking from Position toward LookAt.
6+type Camera struct {
7+ Position Vec3
8+ LookAt Vec3
9+ Up Vec3
10+ // Fov is the vertical field of view in degrees.
11+ Fov float64
12+}
13+
14+// NewCamera makes a camera looking at the origin from a position,
15+// with the world Y axis as up and a 60 degree vertical field of view.
16+func NewCamera(pos Vec3) Camera {
17+ return Camera{Position: pos, LookAt: Vec3{}, Up: Vec3{0, 1, 0}, Fov: 60}
18+}
19+
20+// Basis returns the camera's right, up and forward unit vectors.
21+func (c Camera) Basis() (right, up, fwd Vec3) {
22+ fwd = c.LookAt.Sub(c.Position).Norm()
23+ right = fwd.Cross(c.Up).Norm()
24+ up = right.Cross(fwd)
25+ return
26+}
27+
28+// RayAt returns the ray through the center of pixel (px, py).
29+func (c Camera) RayAt(px, py, w, h int) Ray {
30+ return c.RayAtUV(float64(px)+0.5, float64(py)+0.5, w, h)
31+}
32+
33+// RayAtUV returns the ray through the point (u, v) in pixel units
34+// (u in [0, w), v in [0, h)); used for supersampling.
35+func (c Camera) RayAtUV(u, v float64, w, h int) Ray {
36+ right, up, fwd := c.Basis()
37+ // Vertical half-angle; horizontal scaled by aspect.
38+ tanHalf := math.Tan(radians(c.Fov) / 2)
39+ du := 2 * tanHalf
40+ dv := du * float64(w) / float64(h)
41+ uu := (2*u/float64(w) - 1) * dv
42+ vv := (1 - 2*v/float64(h)) * du
43+ dir := fwd.Add(right.Scale(uu)).Add(up.Scale(vv)).Norm()
44+ return Ray{Origin: c.Position, Dir: dir}
45+}
46+
47+func radians(deg float64) float64 { return deg * math.Pi / 180 }
new file mode 100644
@@ -0,0 +1,47 @@
1+package povray
2+
3+import "math"
4+
5+// Camera is a pinhole camera looking from Position toward LookAt.
6+type Camera struct {
7+ Position Vec3
8+ LookAt Vec3
9+ Up Vec3
10+ // Fov is the vertical field of view in degrees.
11+ Fov float64
12+}
13+
14+// NewCamera makes a camera looking at the origin from a position,
15+// with the world Y axis as up and a 60 degree vertical field of view.
16+func NewCamera(pos Vec3) Camera {
17+ return Camera{Position: pos, LookAt: Vec3{}, Up: Vec3{0, 1, 0}, Fov: 60}
18+}
19+
20+// Basis returns the camera's right, up and forward unit vectors.
21+func (c Camera) Basis() (right, up, fwd Vec3) {
22+ fwd = c.LookAt.Sub(c.Position).Norm()
23+ right = fwd.Cross(c.Up).Norm()
24+ up = right.Cross(fwd)
25+ return
26+}
27+
28+// RayAt returns the ray through the center of pixel (px, py).
29+func (c Camera) RayAt(px, py, w, h int) Ray {
30+ return c.RayAtUV(float64(px)+0.5, float64(py)+0.5, w, h)
31+}
32+
33+// RayAtUV returns the ray through the point (u, v) in pixel units
34+// (u in [0, w), v in [0, h)); used for supersampling.
35+func (c Camera) RayAtUV(u, v float64, w, h int) Ray {
36+ right, up, fwd := c.Basis()
37+ // Vertical half-angle; horizontal scaled by aspect.
38+ tanHalf := math.Tan(radians(c.Fov) / 2)
39+ du := 2 * tanHalf
40+ dv := du * float64(w) / float64(h)
41+ uu := (2*u/float64(w) - 1) * dv
42+ vv := (1 - 2*v/float64(h)) * du
43+ dir := fwd.Add(right.Scale(uu)).Add(up.Scale(vv)).Norm()
44+ return Ray{Origin: c.Position, Dir: dir}
45+}
46+
47+func radians(deg float64) float64 { return deg * math.Pi / 180 }
added demos/qwen-style/workspace/docs/TUTORIEL.md +302 -0
new file mode 100644
@@ -0,0 +1,302 @@
1+# Tutoriel — créer de belles images
2+
3+Ce guide vous apprend à composer des scènes avec la bibliothèque
4+`povray`, du plus simple au plus avancé. Chaque niveau s'appuie sur le
5+précédent ; chaque code est complet et rendable tel quel
6+(`go run` dans le dossier du projet).
7+
8+Les exemples finis sont dans `examples/` — quand un niveau vous semble
9+flou, lisez le fichier correspondant.
10+
11+## Niveau 0 — Votre première image
12+
13+Une scène, une sphère, une lumière, une caméra. C'est tout.
14+
15+```go
16+package main
17+
18+import (
19+ "log"
20+
21+ "github.com/user/povray"
22+)
23+
24+func main() {
25+ scene := povray.NewScene()
26+ scene.AddObject(
27+ povray.Sphere{Center: povray.Vec3{0, 1, 0}, Radius: 1},
28+ povray.Material{Diffuse: povray.Red, Specular: povray.White, Shininess: 30},
29+ )
30+ scene.AddObject(povray.NewPlaneXY(0),
31+ povray.Material{Diffuse: povray.Gray, Ambient: 0.3})
32+ scene.Lights = []povray.Light{povray.NewLight(povray.Vec3{4, 8, 6}, povray.White)}
33+
34+ cam := povray.NewCamera(povray.Vec3{0, 2, 8})
35+ r := povray.Renderer{Scene: scene, Width: 800, Height: 600, Samples: 2}
36+ if err := r.Render(cam).SavePNG("hello.png"); err != nil {
37+ log.Fatal(err)
38+ }
39+}
40+```
41+
42+Ce qu'il faut retenir :
43+
44+- **`NewScene()`** crée une scène (fond blanc, gamma 2.2).
45+- **`AddObject(obj, mat)`** ajoute un objet *et* son matériau.
46+- **`Lights`** est une liste de lumières ponctuelles.
47+- **`Renderer`** : `Width`/`Height` en pixels, `Samples: 2` = 4 rayons
48+ par pixel (anti-aliasing). Sans `Samples`, les bords sont en dents de
49+ scie.
50+- **`SavePNG`** écrit le fichier.
51+
52+## Niveau 1 — Matériaux et couleurs
53+
54+Le matériau est ce qui fait l'image. Les champs utiles :
55+
56+| Champ | Effet | Valeurs typiques |
57+|---|---|---|
58+| `Diffuse` | couleur de base | `povray.Red`, `povray.Color{0.8, 0.2, 0.1}` |
59+| `Specular` | couleur du reflet | `povray.White` |
60+| `Shininess` | taille du reflet (plus = plus petit) | 10 (mat) à 100 (chrome) |
61+| `Ambient` | lumière résiduelle dans l'ombre | 0.1 à 0.4 |
62+| `Reflectivity` | force du miroir | 0 à 1 |
63+| `Transparency` | force du verre | 0 à 1 |
64+| `IOR` | indice de réfraction du verre | 1.5 (verre), 1.33 (eau) |
65+
66+**Règle d'or des couleurs** : les valeurs de `Diffuse` sont *linéaires*.
67+Un gris `0.5` rend plus sombre qu'il n'en a l'air (le gamma 2.2
68+l'éclaircit au rendu, mais pas autant que le direct). Pour des couleurs
69+"vives", montez les valeurs : `Color{0.9, 0.1, 0.1}` est plus rouge que
70+`Color{0.5, 0.05, 0.05}`.
71+
72+Deux matériaux opposés sur des objets voisins créent du contraste —
73+c'est la base de toute belle image :
74+
75+```go
76+// Un sol sombre porte des objets clairs (et inversement).
77+scene.AddObject(povray.NewPlaneXY(0),
78+ povray.Material{Diffuse: povray.Color{0.12, 0.12, 0.15}, Ambient: 0.3})
79+
80+scene.AddObject(povray.Sphere{Center: povray.Vec3{-2, 1, 0}, Radius: 1},
81+ povray.Material{Diffuse: povray.Color{0.9, 0.3, 0.2}, Specular: povray.White, Shininess: 40})
82+scene.AddObject(povray.Sphere{Center: povray.Vec3{2, 1, 0}, Radius: 1},
83+ povray.Material{Diffuse: povray.Color{0.2, 0.7, 0.9}, Specular: povray.White, Shininess: 40})
84+```
85+
86+Voir `examples/spheres/` pour une scène complète.
87+
88+## Niveau 2 — La caméra et la composition
89+
90+La caméra est un trou d'épingle : elle est à `Position`, regarde
91+`LookAt`, avec `Fov` (champ vertical en degrés).
92+
93+```go
94+cam := povray.NewCamera(povray.Vec3{0, 3, 10}) // reculer et monter
95+cam.LookAt = povray.Vec3{0, 1, -2} // regarder plus loin que l'objet
96+cam.Fov = 50 // < 60 = téléobjectif, > 70 = grand angle
97+```
98+
99+**Principes de composition** :
100+
101+1. **Éloignez la caméra** plutôt que de zoomer : un Fov large
102+ (grand angle) déforme les objets proches ; un Fov étroit les
103+ comprime et aplatit la scène.
104+2. **Regardez légèrement au-dessus ou en dessous** du sujet, pas
105+ exactement au milieu : `LookAt` un peu plus haut que le centre des
106+ objets donne une vue "de dessus" naturelle.
107+3. **Laissez de la place** : le sujet au centre exact est rarement
108+ joli. Décalez-le d'un tiers (règle des tiers) en déplaçant
109+ `LookAt` dans la direction opposée.
110+4. **Le plan du sol doit sortir du cadre** : si l'horizon est visible,
111+ la scène a l'air petite. Montez la caméra ou rapprochez le sol.
112+
113+## Niveau 3 — Transformations : placer les objets
114+
115+Les objets bruts vivent dans l'espace monde. Pour les incliner,
116+étirer ou les disposer en boucle, on les enveloppe dans un
117+`Transform` :
118+
119+```go
120+// Une boîte inclinée de 30° autour de X, déplacée à (-3, 0, -2).
121+tilted := povray.Transform{
122+ Object: povray.Box{Min: povray.Vec3{-1, 0, -1}, Max: povray.Vec3{1, 2, 1}},
123+ Matrix: povray.Translate(-3, 0, -2).Multiply(povray.RotateX(math.Pi / 6)),
124+}
125+scene.AddObject(tilted, mat)
126+```
127+
128+**L'ordre compte** : `Multiply` s'applique de droite à gauche.
129+`Translate(...).Multiply(RotateX(...))` = *d'abord* la rotation,
130+*puis* la translation. Pour "se déplacer puis tourner sur place",
131+inversez : `RotateX(...).Multiply(Translate(...))`.
132+
133+Les primitives de base :
134+
135+- `Translate(x, y, z)` — déplacer
136+- `RotateX/Y/Z(angle)` — tourner (radians : `math.Pi/2` = 90°)
137+- `Scale(x, y, z)` — étirer (une sphère échelonnée = ellipsoïde)
138+- `Identity()` — rien
139+
140+**Disposer en boucle** est le moyen le plus simple de créer des
141+scènes riches :
142+
143+```go
144+var ring []povray.Object
145+for i := 0; i < 12; i++ {
146+ a := 2 * math.Pi * float64(i) / 12
147+ m := povray.RotateY(a).Multiply(povray.Translate(0, 0, -5.5))
148+ ring = append(ring, povray.Transform{
149+ Object: povray.Box{Min: povray.Vec3{-0.4, 0, -0.4}, Max: povray.Vec3{0.4, 1.6, 0.4}},
150+ Matrix: m,
151+ })
152+}
153+scene.AddObjects(ring, povray.Material{Diffuse: povray.Gold, ...})
154+```
155+
156+Voir `examples/transform/` (anneau de boîtes, ellipsoïde miroir) et
157+`examples/procedural/` (grille de 200 sphères dont la couleur varie
158+par position).
159+
160+## Niveau 4 — Miroirs et verre
161+
162+Deux champs de matériau suffisent :
163+
164+```go
165+// Miroir : Reflectivity élevé, Diffuse sombre.
166+mirror := povray.Material{
167+ Diffuse: povray.Color{0.1, 0.1, 0.12},
168+ Reflectivity: 0.9,
169+ Specular: povray.White, Shininess: 80,
170+}
171+
172+// Verre : Transparency élevé + IOR.
173+glass := povray.Material{
174+ Diffuse: povray.White,
175+ Transparency: 0.95,
176+ IOR: 1.5,
177+ Specular: povray.White, Shininess: 100,
178+ Ambient: 0.05,
179+}
180+```
181+
182+**Astuces** :
183+
184+- Un miroir pur (`Reflectivity: 1`, `Diffuse: Black`) est joli mais
185+ plat. Un `Reflectivity: 0.5` avec un `Diffuse` coloré donne un
186+ "métal teinté" très photogénique.
187+- Le verre est plus beau quand il *contient* ou *cache* quelque chose
188+ de coloré. Une sphère de verre seule est presque invisible.
189+- L'effet "sol miroir" : un plan avec `Reflectivity: 0.3` et
190+ `Diffuse` sombre fait des reflets doux sous les objets.
191+
192+Voir `examples/mirror/`.
193+
194+## Niveau 5 — Path tracing : lumière réaliste
195+
196+Le `Renderer` classique éclaire les surfaces une à une. Le
197+**`PathTracer`** fait rebondir la lumière de surface en surface :
198+éclairage indirect (une sphère rouge teintée le sol autour d'elle),
199+ombres douces, lumière de couleur. C'est ce qui donne des images
200+"réelles".
201+
202+```go
203+// Les lumières du path tracer sont des SURFACES ÉMISSIVES.
204+panel := povray.Transform{
205+ Object: povray.Box{Min: povray.Vec3{-1.5, 0, -1.5}, Max: povray.Vec3{1.5, 0.15, 1.5}},
206+ Matrix: povray.Translate(0, 9, -2),
207+}
208+scene.AddObject(panel, povray.LightMaterial(povray.Color{1, 0.85, 0.65}, 14))
209+
210+tracer := povray.PathTracer{Scene: scene, Width: 800, Height: 600, Samples: 64}
211+img := tracer.Render(cam)
212+```
213+
214+Différences avec le `Renderer` :
215+
216+- **Les `Scene.Lights` sont ignorées** — la lumière vient des matériaux
217+ avec `Emission` (utilisez `LightMaterial(couleur, intensité)`).
218+- **`Samples` = nombre de rayons par pixel**, pas une grille. 16 =
219+ rapide (un peu de bruit), 64 = propre, 256 = publication.
220+- **Le bruit est normal** : c'est un estimateur aléatoire. Plus de
221+ samples = moins de bruit. Le rendu est déterministe (même image à
222+ chaque fois pour la même config).
223+- **Les intensités** : un panneau de lumière a besoin d'une intensité
224+ de 10 à 20 pour bien éclairer une scène à cette échelle.
225+
226+Voir `examples/pathtrace/` : une pièce avec panneau chaud + bandeau
227+froid (la lumière rebondit et teinte les murs), sphère rouge, miroir
228+bleu, verre.
229+
230+## Niveau 6 — Recettes pour de belles images
231+
232+### La palette
233+
234+Choisissez 2 à 3 couleurs de base et un neutre. Les complémentaires
235+se font face :
236+
237+```go
238+var (
239+ warm = povray.Color{0.9, 0.45, 0.2} // orange
240+ cool = povray.Color{0.2, 0.4, 0.85} // bleu
241+ neutral = povray.Color{0.25, 0.25, 0.28} // gris foncé pour le sol
242+)
243+```
244+
245+Un sol neutre et sombre est le fond le plus polyvalent : il ne
246+concurrence pas les objets.
247+
248+### La lumière
249+
250+- **Deux lumières** valent mieux qu'une : une clé (forte, chaude,
251+ en hauteur) + une rembourrage (faible, froide, en face) :
252+
253+```go
254+scene.Lights = []povray.Light{
255+ povray.NewLight(povray.Vec3{6, 10, 8}, povray.Color{1, 0.95, 0.85}),
256+ povray.NewLight(povray.Vec3{-8, 4, -4}, povray.Color{0.5, 0.6, 1.0}),
257+}
258+```
259+
260+- Placez la lumière **en hauteur et de côté** : en face, les ombres
261+ disparaissent derrière les objets ; en haut, tout est plat.
262+- Dans le path tracer, la **couleur de la lumière** est plus
263+ importante que son intensité : un panneau `Color{1, 0.85, 0.65}`
264+ (chaud) + un bandeau `Color{0.4, 0.6, 1}` (froid) crée une ambiance
265+ immédiatement.
266+
267+### La profondeur
268+
269+- **Tailles variées** : un grand + un petit objet donnent l'échelle.
270+- **Écartez en Z** : les objets à des distances différentes créent de
271+ la profondeur ; à la même Z, la scène est plate.
272+- **Un élément au premier plan** (partiellement hors cadre) donne un
273+ effet "regard à travers".
274+
275+### Les réglages finaux
276+
277+| Réglage | Pour… |
278+|---|---|
279+| `Samples: 2` (Renderer) | aperçu rapide, propre |
280+| `Samples: 4` (Renderer) | rendu final du Renderer |
281+| `Samples: 16` (PathTracer) | itération (bruit visible) |
282+| `Samples: 64` (PathTracer) | rendu final path traced |
283+| `Width: 1920, Height: 1080` | image grande taille |
284+| `Fov: 45` | "photo" compressée, élégante |
285+| `Fov: 75` | vue immersive, scène large |
286+
287+### Ce qui fait une image "finie"
288+
289+1. Le sujet occupe environ **un tiers** du cadre.
290+2. Il y a **du contraste** : au moins une zone claire et une sombre.
291+3. Les **couleurs s'équilibrent** : pas de zone qui crie plus fort que
292+ le sujet.
293+4. Le **sol a une teinte** (jamais du blanc pur, jamais du noir pur).
294+5. On a regardé le rendu **à taille réduite** : si l'image fonctionne
295+ en 200 px de large, elle fonctionne en grand.
296+
297+## Où aller ensuite
298+
299+- `examples/` — cinq scènes complètes à décompiler.
300+- `README.md` — référence de l'API (anglais).
301+- `ROADMAP.md` — ce que la bibliothèque ne sait pas encore faire
302+ (textures, maillages `.obj`, area lights…).
new file mode 100644
@@ -0,0 +1,302 @@
1+# Tutoriel — créer de belles images
2+
3+Ce guide vous apprend à composer des scènes avec la bibliothèque
4+`povray`, du plus simple au plus avancé. Chaque niveau s'appuie sur le
5+précédent ; chaque code est complet et rendable tel quel
6+(`go run` dans le dossier du projet).
7+
8+Les exemples finis sont dans `examples/` — quand un niveau vous semble
9+flou, lisez le fichier correspondant.
10+
11+## Niveau 0 — Votre première image
12+
13+Une scène, une sphère, une lumière, une caméra. C'est tout.
14+
15+```go
16+package main
17+
18+import (
19+ "log"
20+
21+ "github.com/user/povray"
22+)
23+
24+func main() {
25+ scene := povray.NewScene()
26+ scene.AddObject(
27+ povray.Sphere{Center: povray.Vec3{0, 1, 0}, Radius: 1},
28+ povray.Material{Diffuse: povray.Red, Specular: povray.White, Shininess: 30},
29+ )
30+ scene.AddObject(povray.NewPlaneXY(0),
31+ povray.Material{Diffuse: povray.Gray, Ambient: 0.3})
32+ scene.Lights = []povray.Light{povray.NewLight(povray.Vec3{4, 8, 6}, povray.White)}
33+
34+ cam := povray.NewCamera(povray.Vec3{0, 2, 8})
35+ r := povray.Renderer{Scene: scene, Width: 800, Height: 600, Samples: 2}
36+ if err := r.Render(cam).SavePNG("hello.png"); err != nil {
37+ log.Fatal(err)
38+ }
39+}
40+```
41+
42+Ce qu'il faut retenir :
43+
44+- **`NewScene()`** crée une scène (fond blanc, gamma 2.2).
45+- **`AddObject(obj, mat)`** ajoute un objet *et* son matériau.
46+- **`Lights`** est une liste de lumières ponctuelles.
47+- **`Renderer`** : `Width`/`Height` en pixels, `Samples: 2` = 4 rayons
48+ par pixel (anti-aliasing). Sans `Samples`, les bords sont en dents de
49+ scie.
50+- **`SavePNG`** écrit le fichier.
51+
52+## Niveau 1 — Matériaux et couleurs
53+
54+Le matériau est ce qui fait l'image. Les champs utiles :
55+
56+| Champ | Effet | Valeurs typiques |
57+|---|---|---|
58+| `Diffuse` | couleur de base | `povray.Red`, `povray.Color{0.8, 0.2, 0.1}` |
59+| `Specular` | couleur du reflet | `povray.White` |
60+| `Shininess` | taille du reflet (plus = plus petit) | 10 (mat) à 100 (chrome) |
61+| `Ambient` | lumière résiduelle dans l'ombre | 0.1 à 0.4 |
62+| `Reflectivity` | force du miroir | 0 à 1 |
63+| `Transparency` | force du verre | 0 à 1 |
64+| `IOR` | indice de réfraction du verre | 1.5 (verre), 1.33 (eau) |
65+
66+**Règle d'or des couleurs** : les valeurs de `Diffuse` sont *linéaires*.
67+Un gris `0.5` rend plus sombre qu'il n'en a l'air (le gamma 2.2
68+l'éclaircit au rendu, mais pas autant que le direct). Pour des couleurs
69+"vives", montez les valeurs : `Color{0.9, 0.1, 0.1}` est plus rouge que
70+`Color{0.5, 0.05, 0.05}`.
71+
72+Deux matériaux opposés sur des objets voisins créent du contraste —
73+c'est la base de toute belle image :
74+
75+```go
76+// Un sol sombre porte des objets clairs (et inversement).
77+scene.AddObject(povray.NewPlaneXY(0),
78+ povray.Material{Diffuse: povray.Color{0.12, 0.12, 0.15}, Ambient: 0.3})
79+
80+scene.AddObject(povray.Sphere{Center: povray.Vec3{-2, 1, 0}, Radius: 1},
81+ povray.Material{Diffuse: povray.Color{0.9, 0.3, 0.2}, Specular: povray.White, Shininess: 40})
82+scene.AddObject(povray.Sphere{Center: povray.Vec3{2, 1, 0}, Radius: 1},
83+ povray.Material{Diffuse: povray.Color{0.2, 0.7, 0.9}, Specular: povray.White, Shininess: 40})
84+```
85+
86+Voir `examples/spheres/` pour une scène complète.
87+
88+## Niveau 2 — La caméra et la composition
89+
90+La caméra est un trou d'épingle : elle est à `Position`, regarde
91+`LookAt`, avec `Fov` (champ vertical en degrés).
92+
93+```go
94+cam := povray.NewCamera(povray.Vec3{0, 3, 10}) // reculer et monter
95+cam.LookAt = povray.Vec3{0, 1, -2} // regarder plus loin que l'objet
96+cam.Fov = 50 // < 60 = téléobjectif, > 70 = grand angle
97+```
98+
99+**Principes de composition** :
100+
101+1. **Éloignez la caméra** plutôt que de zoomer : un Fov large
102+ (grand angle) déforme les objets proches ; un Fov étroit les
103+ comprime et aplatit la scène.
104+2. **Regardez légèrement au-dessus ou en dessous** du sujet, pas
105+ exactement au milieu : `LookAt` un peu plus haut que le centre des
106+ objets donne une vue "de dessus" naturelle.
107+3. **Laissez de la place** : le sujet au centre exact est rarement
108+ joli. Décalez-le d'un tiers (règle des tiers) en déplaçant
109+ `LookAt` dans la direction opposée.
110+4. **Le plan du sol doit sortir du cadre** : si l'horizon est visible,
111+ la scène a l'air petite. Montez la caméra ou rapprochez le sol.
112+
113+## Niveau 3 — Transformations : placer les objets
114+
115+Les objets bruts vivent dans l'espace monde. Pour les incliner,
116+étirer ou les disposer en boucle, on les enveloppe dans un
117+`Transform` :
118+
119+```go
120+// Une boîte inclinée de 30° autour de X, déplacée à (-3, 0, -2).
121+tilted := povray.Transform{
122+ Object: povray.Box{Min: povray.Vec3{-1, 0, -1}, Max: povray.Vec3{1, 2, 1}},
123+ Matrix: povray.Translate(-3, 0, -2).Multiply(povray.RotateX(math.Pi / 6)),
124+}
125+scene.AddObject(tilted, mat)
126+```
127+
128+**L'ordre compte** : `Multiply` s'applique de droite à gauche.
129+`Translate(...).Multiply(RotateX(...))` = *d'abord* la rotation,
130+*puis* la translation. Pour "se déplacer puis tourner sur place",
131+inversez : `RotateX(...).Multiply(Translate(...))`.
132+
133+Les primitives de base :
134+
135+- `Translate(x, y, z)` — déplacer
136+- `RotateX/Y/Z(angle)` — tourner (radians : `math.Pi/2` = 90°)
137+- `Scale(x, y, z)` — étirer (une sphère échelonnée = ellipsoïde)
138+- `Identity()` — rien
139+
140+**Disposer en boucle** est le moyen le plus simple de créer des
141+scènes riches :
142+
143+```go
144+var ring []povray.Object
145+for i := 0; i < 12; i++ {
146+ a := 2 * math.Pi * float64(i) / 12
147+ m := povray.RotateY(a).Multiply(povray.Translate(0, 0, -5.5))
148+ ring = append(ring, povray.Transform{
149+ Object: povray.Box{Min: povray.Vec3{-0.4, 0, -0.4}, Max: povray.Vec3{0.4, 1.6, 0.4}},
150+ Matrix: m,
151+ })
152+}
153+scene.AddObjects(ring, povray.Material{Diffuse: povray.Gold, ...})
154+```
155+
156+Voir `examples/transform/` (anneau de boîtes, ellipsoïde miroir) et
157+`examples/procedural/` (grille de 200 sphères dont la couleur varie
158+par position).
159+
160+## Niveau 4 — Miroirs et verre
161+
162+Deux champs de matériau suffisent :
163+
164+```go
165+// Miroir : Reflectivity élevé, Diffuse sombre.
166+mirror := povray.Material{
167+ Diffuse: povray.Color{0.1, 0.1, 0.12},
168+ Reflectivity: 0.9,
169+ Specular: povray.White, Shininess: 80,
170+}
171+
172+// Verre : Transparency élevé + IOR.
173+glass := povray.Material{
174+ Diffuse: povray.White,
175+ Transparency: 0.95,
176+ IOR: 1.5,
177+ Specular: povray.White, Shininess: 100,
178+ Ambient: 0.05,
179+}
180+```
181+
182+**Astuces** :
183+
184+- Un miroir pur (`Reflectivity: 1`, `Diffuse: Black`) est joli mais
185+ plat. Un `Reflectivity: 0.5` avec un `Diffuse` coloré donne un
186+ "métal teinté" très photogénique.
187+- Le verre est plus beau quand il *contient* ou *cache* quelque chose
188+ de coloré. Une sphère de verre seule est presque invisible.
189+- L'effet "sol miroir" : un plan avec `Reflectivity: 0.3` et
190+ `Diffuse` sombre fait des reflets doux sous les objets.
191+
192+Voir `examples/mirror/`.
193+
194+## Niveau 5 — Path tracing : lumière réaliste
195+
196+Le `Renderer` classique éclaire les surfaces une à une. Le
197+**`PathTracer`** fait rebondir la lumière de surface en surface :
198+éclairage indirect (une sphère rouge teintée le sol autour d'elle),
199+ombres douces, lumière de couleur. C'est ce qui donne des images
200+"réelles".
201+
202+```go
203+// Les lumières du path tracer sont des SURFACES ÉMISSIVES.
204+panel := povray.Transform{
205+ Object: povray.Box{Min: povray.Vec3{-1.5, 0, -1.5}, Max: povray.Vec3{1.5, 0.15, 1.5}},
206+ Matrix: povray.Translate(0, 9, -2),
207+}
208+scene.AddObject(panel, povray.LightMaterial(povray.Color{1, 0.85, 0.65}, 14))
209+
210+tracer := povray.PathTracer{Scene: scene, Width: 800, Height: 600, Samples: 64}
211+img := tracer.Render(cam)
212+```
213+
214+Différences avec le `Renderer` :
215+
216+- **Les `Scene.Lights` sont ignorées** — la lumière vient des matériaux
217+ avec `Emission` (utilisez `LightMaterial(couleur, intensité)`).
218+- **`Samples` = nombre de rayons par pixel**, pas une grille. 16 =
219+ rapide (un peu de bruit), 64 = propre, 256 = publication.
220+- **Le bruit est normal** : c'est un estimateur aléatoire. Plus de
221+ samples = moins de bruit. Le rendu est déterministe (même image à
222+ chaque fois pour la même config).
223+- **Les intensités** : un panneau de lumière a besoin d'une intensité
224+ de 10 à 20 pour bien éclairer une scène à cette échelle.
225+
226+Voir `examples/pathtrace/` : une pièce avec panneau chaud + bandeau
227+froid (la lumière rebondit et teinte les murs), sphère rouge, miroir
228+bleu, verre.
229+
230+## Niveau 6 — Recettes pour de belles images
231+
232+### La palette
233+
234+Choisissez 2 à 3 couleurs de base et un neutre. Les complémentaires
235+se font face :
236+
237+```go
238+var (
239+ warm = povray.Color{0.9, 0.45, 0.2} // orange
240+ cool = povray.Color{0.2, 0.4, 0.85} // bleu
241+ neutral = povray.Color{0.25, 0.25, 0.28} // gris foncé pour le sol
242+)
243+```
244+
245+Un sol neutre et sombre est le fond le plus polyvalent : il ne
246+concurrence pas les objets.
247+
248+### La lumière
249+
250+- **Deux lumières** valent mieux qu'une : une clé (forte, chaude,
251+ en hauteur) + une rembourrage (faible, froide, en face) :
252+
253+```go
254+scene.Lights = []povray.Light{
255+ povray.NewLight(povray.Vec3{6, 10, 8}, povray.Color{1, 0.95, 0.85}),
256+ povray.NewLight(povray.Vec3{-8, 4, -4}, povray.Color{0.5, 0.6, 1.0}),
257+}
258+```
259+
260+- Placez la lumière **en hauteur et de côté** : en face, les ombres
261+ disparaissent derrière les objets ; en haut, tout est plat.
262+- Dans le path tracer, la **couleur de la lumière** est plus
263+ importante que son intensité : un panneau `Color{1, 0.85, 0.65}`
264+ (chaud) + un bandeau `Color{0.4, 0.6, 1}` (froid) crée une ambiance
265+ immédiatement.
266+
267+### La profondeur
268+
269+- **Tailles variées** : un grand + un petit objet donnent l'échelle.
270+- **Écartez en Z** : les objets à des distances différentes créent de
271+ la profondeur ; à la même Z, la scène est plate.
272+- **Un élément au premier plan** (partiellement hors cadre) donne un
273+ effet "regard à travers".
274+
275+### Les réglages finaux
276+
277+| Réglage | Pour… |
278+|---|---|
279+| `Samples: 2` (Renderer) | aperçu rapide, propre |
280+| `Samples: 4` (Renderer) | rendu final du Renderer |
281+| `Samples: 16` (PathTracer) | itération (bruit visible) |
282+| `Samples: 64` (PathTracer) | rendu final path traced |
283+| `Width: 1920, Height: 1080` | image grande taille |
284+| `Fov: 45` | "photo" compressée, élégante |
285+| `Fov: 75` | vue immersive, scène large |
286+
287+### Ce qui fait une image "finie"
288+
289+1. Le sujet occupe environ **un tiers** du cadre.
290+2. Il y a **du contraste** : au moins une zone claire et une sombre.
291+3. Les **couleurs s'équilibrent** : pas de zone qui crie plus fort que
292+ le sujet.
293+4. Le **sol a une teinte** (jamais du blanc pur, jamais du noir pur).
294+5. On a regardé le rendu **à taille réduite** : si l'image fonctionne
295+ en 200 px de large, elle fonctionne en grand.
296+
297+## Où aller ensuite
298+
299+- `examples/` — cinq scènes complètes à décompiler.
300+- `README.md` — référence de l'API (anglais).
301+- `ROADMAP.md` — ce que la bibliothèque ne sait pas encore faire
302+ (textures, maillages `.obj`, area lights…).
added demos/qwen-style/workspace/examples/mirror/main.go +86 -0
new file mode 100644
@@ -0,0 +1,86 @@
1+// Command mirror shows reflections and refraction: a glass sphere on
2+// a mirror floor, with colored spheres behind it.
3+package main
4+
5+import (
6+ "fmt"
7+ "log"
8+
9+ "github.com/user/povray"
10+)
11+
12+func main() {
13+ scene := povray.NewScene()
14+ scene.Background = povray.Color{0.05, 0.05, 0.1}
15+ scene.MaxDepth = 10
16+
17+ // Mirror floor.
18+ floorMat := povray.Material{
19+ Diffuse: povray.Color{0.2, 0.2, 0.25},
20+ Specular: povray.White,
21+ Shininess: 100,
22+ Ambient: 0.1,
23+ Reflectivity: 0.7,
24+ }
25+ scene.AddObject(povray.NewPlaneXY(0), floorMat)
26+
27+ // Glass sphere in front.
28+ glass := povray.Material{
29+ Diffuse: povray.Color{0.9, 0.95, 1},
30+ Specular: povray.White,
31+ Shininess: 120,
32+ Ambient: 0.05,
33+ Reflectivity: 0.2,
34+ Transparency: 0.9,
35+ IOR: 1.5,
36+ }
37+ scene.AddObject(povray.Sphere{Center: povray.Vec3{0, 1, 0}, Radius: 1}, glass)
38+
39+ // Colored spheres behind the glass.
40+ behind := []struct {
41+ x float64
42+ c povray.Color
43+ }{
44+ {-3, povray.Red},
45+ {0, povray.Yellow},
46+ {3, povray.Cyan},
47+ }
48+ for _, b := range behind {
49+ mat := povray.Material{
50+ Diffuse: b.c,
51+ Specular: povray.White,
52+ Shininess: 40,
53+ Ambient: 0.15,
54+ }
55+ scene.AddObject(povray.Sphere{Center: povray.Vec3{b.x, 1.2, -5}, Radius: 1.2}, mat)
56+ }
57+
58+ // A gold, mirror-like box.
59+ gold := povray.Material{
60+ Diffuse: povray.Gold,
61+ Specular: povray.White,
62+ Shininess: 80,
63+ Ambient: 0.15,
64+ Reflectivity: 0.5,
65+ }
66+ scene.AddObject(povray.Box{
67+ Min: povray.Vec3{2.2, 0, -2.5},
68+ Max: povray.Vec3{3.4, 1.2, -1.3},
69+ }, gold)
70+
71+ scene.Lights = []povray.Light{
72+ povray.NewLight(povray.Vec3{0, 10, 4}, povray.White),
73+ povray.NewLight(povray.Vec3{-5, 3, 8}, povray.Color{0.7, 0.8, 1}),
74+ povray.NewLight(povray.Vec3{5, 3, 8}, povray.Color{1, 0.85, 0.7}),
75+ }
76+
77+ cam := povray.NewCamera(povray.Vec3{0, 2, 8})
78+ cam.Fov = 55
79+
80+ renderer := povray.Renderer{Scene: scene, Width: 800, Height: 600, Samples: 2}
81+ img := renderer.Render(cam)
82+ if err := img.SavePNG("mirror.png"); err != nil {
83+ log.Fatal(err)
84+ }
85+ fmt.Println("wrote mirror.png")
86+}
new file mode 100644
@@ -0,0 +1,86 @@
1+// Command mirror shows reflections and refraction: a glass sphere on
2+// a mirror floor, with colored spheres behind it.
3+package main
4+
5+import (
6+ "fmt"
7+ "log"
8+
9+ "github.com/user/povray"
10+)
11+
12+func main() {
13+ scene := povray.NewScene()
14+ scene.Background = povray.Color{0.05, 0.05, 0.1}
15+ scene.MaxDepth = 10
16+
17+ // Mirror floor.
18+ floorMat := povray.Material{
19+ Diffuse: povray.Color{0.2, 0.2, 0.25},
20+ Specular: povray.White,
21+ Shininess: 100,
22+ Ambient: 0.1,
23+ Reflectivity: 0.7,
24+ }
25+ scene.AddObject(povray.NewPlaneXY(0), floorMat)
26+
27+ // Glass sphere in front.
28+ glass := povray.Material{
29+ Diffuse: povray.Color{0.9, 0.95, 1},
30+ Specular: povray.White,
31+ Shininess: 120,
32+ Ambient: 0.05,
33+ Reflectivity: 0.2,
34+ Transparency: 0.9,
35+ IOR: 1.5,
36+ }
37+ scene.AddObject(povray.Sphere{Center: povray.Vec3{0, 1, 0}, Radius: 1}, glass)
38+
39+ // Colored spheres behind the glass.
40+ behind := []struct {
41+ x float64
42+ c povray.Color
43+ }{
44+ {-3, povray.Red},
45+ {0, povray.Yellow},
46+ {3, povray.Cyan},
47+ }
48+ for _, b := range behind {
49+ mat := povray.Material{
50+ Diffuse: b.c,
51+ Specular: povray.White,
52+ Shininess: 40,
53+ Ambient: 0.15,
54+ }
55+ scene.AddObject(povray.Sphere{Center: povray.Vec3{b.x, 1.2, -5}, Radius: 1.2}, mat)
56+ }
57+
58+ // A gold, mirror-like box.
59+ gold := povray.Material{
60+ Diffuse: povray.Gold,
61+ Specular: povray.White,
62+ Shininess: 80,
63+ Ambient: 0.15,
64+ Reflectivity: 0.5,
65+ }
66+ scene.AddObject(povray.Box{
67+ Min: povray.Vec3{2.2, 0, -2.5},
68+ Max: povray.Vec3{3.4, 1.2, -1.3},
69+ }, gold)
70+
71+ scene.Lights = []povray.Light{
72+ povray.NewLight(povray.Vec3{0, 10, 4}, povray.White),
73+ povray.NewLight(povray.Vec3{-5, 3, 8}, povray.Color{0.7, 0.8, 1}),
74+ povray.NewLight(povray.Vec3{5, 3, 8}, povray.Color{1, 0.85, 0.7}),
75+ }
76+
77+ cam := povray.NewCamera(povray.Vec3{0, 2, 8})
78+ cam.Fov = 55
79+
80+ renderer := povray.Renderer{Scene: scene, Width: 800, Height: 600, Samples: 2}
81+ img := renderer.Render(cam)
82+ if err := img.SavePNG("mirror.png"); err != nil {
83+ log.Fatal(err)
84+ }
85+ fmt.Println("wrote mirror.png")
86+}
added demos/qwen-style/workspace/examples/pathtrace/main.go +67 -0
new file mode 100644
@@ -0,0 +1,67 @@
1+// Command pathtrace renders a scene with the path tracer: light is
2+// carried by rays bouncing between surfaces, which gives global
3+// illumination, soft shadows and colored bounce light.
4+package main
5+
6+import (
7+ "fmt"
8+ "log"
9+ "math"
10+
11+ "github.com/user/povray"
12+)
13+
14+func main() {
15+ scene := povray.NewScene()
16+ scene.Background = povray.Color{0.02, 0.03, 0.05}
17+
18+ // A large room: floor and two walls, slightly tinted so the bounce
19+ // light picks up color.
20+ room := povray.Material{Diffuse: povray.Color{0.75, 0.72, 0.68}}
21+ scene.AddObject(povray.NewPlaneXY(0), room)
22+ scene.AddObject(povray.Box{Min: povray.Vec3{-12, 0, -12}, Max: povray.Vec3{12, 12, -11.9}},
23+ povray.Material{Diffuse: povray.Color{0.55, 0.6, 0.75}})
24+
25+ // A warm light panel hanging above the scene.
26+ panel := povray.Transform{
27+ Object: povray.Box{Min: povray.Vec3{-1.5, 0, -1.5}, Max: povray.Vec3{1.5, 0.15, 1.5}},
28+ Matrix: povray.Translate(0, 9, -2),
29+ }
30+ scene.AddObject(panel, povray.LightMaterial(povray.Color{1, 0.85, 0.65}, 14))
31+
32+ // A cool light strip along the back wall, for colored bounce.
33+ strip := povray.Transform{
34+ Object: povray.Box{Min: povray.Vec3{-4, 0, -0.2}, Max: povray.Vec3{4, 2.5, 0}},
35+ Matrix: povray.Translate(0, 0, -11.8),
36+ }
37+ scene.AddObject(strip, povray.LightMaterial(povray.Color{0.4, 0.6, 1}, 6))
38+
39+ // The subjects: a red sphere, a blue mirror, a glass sphere.
40+ scene.AddObject(povray.Sphere{Center: povray.Vec3{-2.5, 1, -2}, Radius: 1},
41+ povray.Material{Diffuse: povray.Color{0.85, 0.15, 0.15}})
42+ scene.AddObject(povray.Sphere{Center: povray.Vec3{2.5, 1, -2}, Radius: 1},
43+ povray.Material{Diffuse: povray.Color{0.1, 0.2, 0.8}, Reflectivity: 0.6})
44+ scene.AddObject(povray.Sphere{Center: povray.Vec3{0, 1, 0}, Radius: 1},
45+ povray.Material{Diffuse: povray.White, Transparency: 0.95, IOR: 1.5})
46+
47+ // A few boxes, tilted, to catch the light.
48+ for i := 0; i < 3; i++ {
49+ b := povray.Transform{
50+ Object: povray.Box{Min: povray.Vec3{-0.5, 0, -0.5}, Max: povray.Vec3{0.5, 1.5, 0.5}},
51+ Matrix: povray.Translate(float64(i-1)*-4, 0, 2).
52+ Multiply(povray.RotateY(math.Pi / 8 * float64(i))),
53+ }
54+ scene.AddObject(b, povray.Material{Diffuse: povray.Color{0.8, 0.6, 0.3}})
55+ }
56+
57+ cam := povray.NewCamera(povray.Vec3{0, 3, 10})
58+ cam.LookAt = povray.Vec3{0, 1.5, -2}
59+ cam.Fov = 50
60+
61+ tracer := povray.PathTracer{Scene: scene, Width: 800, Height: 600, Samples: 64}
62+ img := tracer.Render(cam)
63+ if err := img.SavePNG("pathtrace.png"); err != nil {
64+ log.Fatal(err)
65+ }
66+ fmt.Println("wrote pathtrace.png")
67+}
new file mode 100644
@@ -0,0 +1,67 @@
1+// Command pathtrace renders a scene with the path tracer: light is
2+// carried by rays bouncing between surfaces, which gives global
3+// illumination, soft shadows and colored bounce light.
4+package main
5+
6+import (
7+ "fmt"
8+ "log"
9+ "math"
10+
11+ "github.com/user/povray"
12+)
13+
14+func main() {
15+ scene := povray.NewScene()
16+ scene.Background = povray.Color{0.02, 0.03, 0.05}
17+
18+ // A large room: floor and two walls, slightly tinted so the bounce
19+ // light picks up color.
20+ room := povray.Material{Diffuse: povray.Color{0.75, 0.72, 0.68}}
21+ scene.AddObject(povray.NewPlaneXY(0), room)
22+ scene.AddObject(povray.Box{Min: povray.Vec3{-12, 0, -12}, Max: povray.Vec3{12, 12, -11.9}},
23+ povray.Material{Diffuse: povray.Color{0.55, 0.6, 0.75}})
24+
25+ // A warm light panel hanging above the scene.
26+ panel := povray.Transform{
27+ Object: povray.Box{Min: povray.Vec3{-1.5, 0, -1.5}, Max: povray.Vec3{1.5, 0.15, 1.5}},
28+ Matrix: povray.Translate(0, 9, -2),
29+ }
30+ scene.AddObject(panel, povray.LightMaterial(povray.Color{1, 0.85, 0.65}, 14))
31+
32+ // A cool light strip along the back wall, for colored bounce.
33+ strip := povray.Transform{
34+ Object: povray.Box{Min: povray.Vec3{-4, 0, -0.2}, Max: povray.Vec3{4, 2.5, 0}},
35+ Matrix: povray.Translate(0, 0, -11.8),
36+ }
37+ scene.AddObject(strip, povray.LightMaterial(povray.Color{0.4, 0.6, 1}, 6))
38+
39+ // The subjects: a red sphere, a blue mirror, a glass sphere.
40+ scene.AddObject(povray.Sphere{Center: povray.Vec3{-2.5, 1, -2}, Radius: 1},
41+ povray.Material{Diffuse: povray.Color{0.85, 0.15, 0.15}})
42+ scene.AddObject(povray.Sphere{Center: povray.Vec3{2.5, 1, -2}, Radius: 1},
43+ povray.Material{Diffuse: povray.Color{0.1, 0.2, 0.8}, Reflectivity: 0.6})
44+ scene.AddObject(povray.Sphere{Center: povray.Vec3{0, 1, 0}, Radius: 1},
45+ povray.Material{Diffuse: povray.White, Transparency: 0.95, IOR: 1.5})
46+
47+ // A few boxes, tilted, to catch the light.
48+ for i := 0; i < 3; i++ {
49+ b := povray.Transform{
50+ Object: povray.Box{Min: povray.Vec3{-0.5, 0, -0.5}, Max: povray.Vec3{0.5, 1.5, 0.5}},
51+ Matrix: povray.Translate(float64(i-1)*-4, 0, 2).
52+ Multiply(povray.RotateY(math.Pi / 8 * float64(i))),
53+ }
54+ scene.AddObject(b, povray.Material{Diffuse: povray.Color{0.8, 0.6, 0.3}})
55+ }
56+
57+ cam := povray.NewCamera(povray.Vec3{0, 3, 10})
58+ cam.LookAt = povray.Vec3{0, 1.5, -2}
59+ cam.Fov = 50
60+
61+ tracer := povray.PathTracer{Scene: scene, Width: 800, Height: 600, Samples: 64}
62+ img := tracer.Render(cam)
63+ if err := img.SavePNG("pathtrace.png"); err != nil {
64+ log.Fatal(err)
65+ }
66+ fmt.Println("wrote pathtrace.png")
67+}
added demos/qwen-style/workspace/examples/procedural/main.go +115 -0
new file mode 100644
@@ -0,0 +1,115 @@
1+// Command procedural shows that a scene is just data: a grid of
2+// spheres whose colors and heights are computed in a loop, plus a
3+// ring of boxes, all generated from a few parameters.
4+package main
5+
6+import (
7+ "fmt"
8+ "log"
9+ "math"
10+
11+ "github.com/user/povray"
12+)
13+
14+func main() {
15+ scene := povray.NewScene()
16+ scene.Background = povray.Color{0.02, 0.03, 0.08}
17+
18+ // Ground.
19+ groundMat := povray.Material{
20+ Diffuse: povray.Color{0.3, 0.32, 0.38},
21+ Specular: povray.Color{0.2, 0.2, 0.2},
22+ Shininess: 15,
23+ Ambient: 0.25,
24+ }
25+ scene.AddObject(povray.NewPlaneXY(0), groundMat)
26+
27+ // A 6x6 grid of spheres; color and height vary with position.
28+ const n = 6
29+ step := 1.6
30+ for i := 0; i < n; i++ {
31+ for j := 0; j < n; j++ {
32+ x := (float64(i) - float64(n-1)/2) * step
33+ z := (float64(j) - float64(n-1)/2) * step
34+ dist := math.Hypot(x, z)
35+ r := 0.55 - 0.1*math.Min(1, dist/6)
36+ h := r + 0.25*math.Sin(dist*0.8)
37+
38+ // Hue sweep across the grid.
39+ t := float64(i+j) / float64(2*n)
40+ c := hsv(0.6-0.5*t, 0.7, 0.9)
41+
42+ mat := povray.Material{
43+ Diffuse: c,
44+ Specular: povray.White,
45+ Shininess: 50,
46+ Ambient: 0.1,
47+ }
48+ scene.AddObject(povray.Sphere{Center: povray.Vec3{x, h, z}, Radius: r}, mat)
49+ }
50+ }
51+
52+ // A ring of boxes around the grid.
53+ const m = 12
54+ for k := 0; k < m; k++ {
55+ a := 2 * math.Pi * float64(k) / m
56+ x, z := 7.5*math.Cos(a), 7.5*math.Sin(a)
57+ h := 1 + 0.8*math.Sin(3*a)
58+ c := hsv(0.55+0.08*math.Sin(2*a), 0.5, 0.8)
59+ mat := povray.Material{
60+ Diffuse: c,
61+ Specular: povray.White,
62+ Shininess: 25,
63+ Ambient: 0.15,
64+ }
65+ scene.AddObject(povray.Box{
66+ Min: povray.Vec3{x - 0.4, 0, z - 0.4},
67+ Max: povray.Vec3{x + 0.4, h, z + 0.4},
68+ }, mat)
69+ }
70+
71+ // Three colored lights for a dramatic look.
72+ scene.Lights = []povray.Light{
73+ povray.NewLight(povray.Vec3{0, 12, 0}, povray.White),
74+ povray.NewLight(povray.Vec3{10, 5, 10}, povray.Color{1, 0.4, 0.3}),
75+ povray.NewLight(povray.Vec3{-10, 5, -10}, povray.Color{0.3, 0.4, 1}),
76+ }
77+
78+ cam := povray.NewCamera(povray.Vec3{0, 9, 16})
79+ cam.LookAt = povray.Vec3{0, 0.5, 0}
80+ cam.Fov = 50
81+
82+ renderer := povray.Renderer{Scene: scene, Width: 800, Height: 600, Samples: 2}
83+ img := renderer.Render(cam)
84+ if err := img.SavePNG("procedural.png"); err != nil {
85+ log.Fatal(err)
86+ }
87+ fmt.Println("wrote procedural.png")
88+}
89+
90+// hsv converts hue [0,1), saturation and value [0,1] to RGB.
91+func hsv(h, s, v float64) povray.Color {
92+ h = math.Mod(h, 1)
93+ if h < 0 {
94+ h += 1
95+ }
96+ i := int(h * 6)
97+ f := h*6 - float64(i)
98+ p := v * (1 - s)
99+ q := v * (1 - s*f)
100+ t := v * (1 - s*(1-f))
101+ switch i % 6 {
102+ case 0:
103+ return povray.Color{v, t, p}
104+ case 1:
105+ return povray.Color{q, v, p}
106+ case 2:
107+ return povray.Color{p, v, t}
108+ case 3:
109+ return povray.Color{p, q, v}
110+ case 4:
111+ return povray.Color{t, p, v}
112+ default:
113+ return povray.Color{v, p, q}
114+ }
115+}
new file mode 100644
@@ -0,0 +1,115 @@
1+// Command procedural shows that a scene is just data: a grid of
2+// spheres whose colors and heights are computed in a loop, plus a
3+// ring of boxes, all generated from a few parameters.
4+package main
5+
6+import (
7+ "fmt"
8+ "log"
9+ "math"
10+
11+ "github.com/user/povray"
12+)
13+
14+func main() {
15+ scene := povray.NewScene()
16+ scene.Background = povray.Color{0.02, 0.03, 0.08}
17+
18+ // Ground.
19+ groundMat := povray.Material{
20+ Diffuse: povray.Color{0.3, 0.32, 0.38},
21+ Specular: povray.Color{0.2, 0.2, 0.2},
22+ Shininess: 15,
23+ Ambient: 0.25,
24+ }
25+ scene.AddObject(povray.NewPlaneXY(0), groundMat)
26+
27+ // A 6x6 grid of spheres; color and height vary with position.
28+ const n = 6
29+ step := 1.6
30+ for i := 0; i < n; i++ {
31+ for j := 0; j < n; j++ {
32+ x := (float64(i) - float64(n-1)/2) * step
33+ z := (float64(j) - float64(n-1)/2) * step
34+ dist := math.Hypot(x, z)
35+ r := 0.55 - 0.1*math.Min(1, dist/6)
36+ h := r + 0.25*math.Sin(dist*0.8)
37+
38+ // Hue sweep across the grid.
39+ t := float64(i+j) / float64(2*n)
40+ c := hsv(0.6-0.5*t, 0.7, 0.9)
41+
42+ mat := povray.Material{
43+ Diffuse: c,
44+ Specular: povray.White,
45+ Shininess: 50,
46+ Ambient: 0.1,
47+ }
48+ scene.AddObject(povray.Sphere{Center: povray.Vec3{x, h, z}, Radius: r}, mat)
49+ }
50+ }
51+
52+ // A ring of boxes around the grid.
53+ const m = 12
54+ for k := 0; k < m; k++ {
55+ a := 2 * math.Pi * float64(k) / m
56+ x, z := 7.5*math.Cos(a), 7.5*math.Sin(a)
57+ h := 1 + 0.8*math.Sin(3*a)
58+ c := hsv(0.55+0.08*math.Sin(2*a), 0.5, 0.8)
59+ mat := povray.Material{
60+ Diffuse: c,
61+ Specular: povray.White,
62+ Shininess: 25,
63+ Ambient: 0.15,
64+ }
65+ scene.AddObject(povray.Box{
66+ Min: povray.Vec3{x - 0.4, 0, z - 0.4},
67+ Max: povray.Vec3{x + 0.4, h, z + 0.4},
68+ }, mat)
69+ }
70+
71+ // Three colored lights for a dramatic look.
72+ scene.Lights = []povray.Light{
73+ povray.NewLight(povray.Vec3{0, 12, 0}, povray.White),
74+ povray.NewLight(povray.Vec3{10, 5, 10}, povray.Color{1, 0.4, 0.3}),
75+ povray.NewLight(povray.Vec3{-10, 5, -10}, povray.Color{0.3, 0.4, 1}),
76+ }
77+
78+ cam := povray.NewCamera(povray.Vec3{0, 9, 16})
79+ cam.LookAt = povray.Vec3{0, 0.5, 0}
80+ cam.Fov = 50
81+
82+ renderer := povray.Renderer{Scene: scene, Width: 800, Height: 600, Samples: 2}
83+ img := renderer.Render(cam)
84+ if err := img.SavePNG("procedural.png"); err != nil {
85+ log.Fatal(err)
86+ }
87+ fmt.Println("wrote procedural.png")
88+}
89+
90+// hsv converts hue [0,1), saturation and value [0,1] to RGB.
91+func hsv(h, s, v float64) povray.Color {
92+ h = math.Mod(h, 1)
93+ if h < 0 {
94+ h += 1
95+ }
96+ i := int(h * 6)
97+ f := h*6 - float64(i)
98+ p := v * (1 - s)
99+ q := v * (1 - s*f)
100+ t := v * (1 - s*(1-f))
101+ switch i % 6 {
102+ case 0:
103+ return povray.Color{v, t, p}
104+ case 1:
105+ return povray.Color{q, v, p}
106+ case 2:
107+ return povray.Color{p, v, t}
108+ case 3:
109+ return povray.Color{p, q, v}
110+ case 4:
111+ return povray.Color{t, p, v}
112+ default:
113+ return povray.Color{v, p, q}
114+ }
115+}
added demos/qwen-style/workspace/examples/spheres/main.go +73 -0
new file mode 100644
@@ -0,0 +1,73 @@
1+// Command spheres is the classic "first render" scene: a row of
2+// colored spheres and a box on a floor, lit by two point lights.
3+package main
4+
5+import (
6+ "fmt"
7+ "log"
8+
9+ "github.com/user/povray"
10+)
11+
12+func main() {
13+ scene := povray.NewScene()
14+ scene.Background = povray.Color{0.9, 0.9, 0.95}
15+
16+ // Floor.
17+ floor := povray.NewPlaneXY(0)
18+ floorMat := povray.Material{
19+ Diffuse: povray.Color{0.75, 0.75, 0.8},
20+ Specular: povray.Color{0.3, 0.3, 0.3},
21+ Shininess: 20,
22+ Ambient: 0.35,
23+ }
24+ scene.AddObject(floor, floorMat)
25+
26+ // A row of colored spheres.
27+ spheres := []struct {
28+ x float64
29+ c povray.Color
30+ }{
31+ {-2.2, povray.Red},
32+ {-0.8, povray.Green},
33+ {0.6, povray.Blue},
34+ {2.0, povray.Gold},
35+ }
36+ for _, s := range spheres {
37+ mat := povray.Material{
38+ Diffuse: s.c,
39+ Specular: povray.White,
40+ Shininess: 60,
41+ Ambient: 0.1,
42+ }
43+ scene.AddObject(povray.Sphere{Center: povray.Vec3{s.x, 1, 0}, Radius: 1}, mat)
44+ }
45+
46+ // A box for variety.
47+ boxMat := povray.Material{
48+ Diffuse: povray.Purple,
49+ Specular: povray.White,
50+ Shininess: 30,
51+ Ambient: 0.15,
52+ }
53+ scene.AddObject(povray.Box{
54+ Min: povray.Vec3{-0.9, 0, -3.5},
55+ Max: povray.Vec3{0.9, 1.8, -1.7},
56+ }, boxMat)
57+
58+ // Lights: a warm key light and a cool fill light.
59+ scene.Lights = []povray.Light{
60+ povray.NewLight(povray.Vec3{4, 8, 6}, povray.Color{1, 0.95, 0.85}),
61+ povray.NewLight(povray.Vec3{-6, 4, -4}, povray.Color{0.6, 0.7, 0.9}),
62+ }
63+
64+ cam := povray.NewCamera(povray.Vec3{0, 2.5, 10})
65+ cam.Fov = 55
66+
67+ renderer := povray.Renderer{Scene: scene, Width: 800, Height: 600, Samples: 2}
68+ img := renderer.Render(cam)
69+ if err := img.SavePNG("spheres.png"); err != nil {
70+ log.Fatal(err)
71+ }
72+ fmt.Println("wrote spheres.png")
73+}
new file mode 100644
@@ -0,0 +1,73 @@
1+// Command spheres is the classic "first render" scene: a row of
2+// colored spheres and a box on a floor, lit by two point lights.
3+package main
4+
5+import (
6+ "fmt"
7+ "log"
8+
9+ "github.com/user/povray"
10+)
11+
12+func main() {
13+ scene := povray.NewScene()
14+ scene.Background = povray.Color{0.9, 0.9, 0.95}
15+
16+ // Floor.
17+ floor := povray.NewPlaneXY(0)
18+ floorMat := povray.Material{
19+ Diffuse: povray.Color{0.75, 0.75, 0.8},
20+ Specular: povray.Color{0.3, 0.3, 0.3},
21+ Shininess: 20,
22+ Ambient: 0.35,
23+ }
24+ scene.AddObject(floor, floorMat)
25+
26+ // A row of colored spheres.
27+ spheres := []struct {
28+ x float64
29+ c povray.Color
30+ }{
31+ {-2.2, povray.Red},
32+ {-0.8, povray.Green},
33+ {0.6, povray.Blue},
34+ {2.0, povray.Gold},
35+ }
36+ for _, s := range spheres {
37+ mat := povray.Material{
38+ Diffuse: s.c,
39+ Specular: povray.White,
40+ Shininess: 60,
41+ Ambient: 0.1,
42+ }
43+ scene.AddObject(povray.Sphere{Center: povray.Vec3{s.x, 1, 0}, Radius: 1}, mat)
44+ }
45+
46+ // A box for variety.
47+ boxMat := povray.Material{
48+ Diffuse: povray.Purple,
49+ Specular: povray.White,
50+ Shininess: 30,
51+ Ambient: 0.15,
52+ }
53+ scene.AddObject(povray.Box{
54+ Min: povray.Vec3{-0.9, 0, -3.5},
55+ Max: povray.Vec3{0.9, 1.8, -1.7},
56+ }, boxMat)
57+
58+ // Lights: a warm key light and a cool fill light.
59+ scene.Lights = []povray.Light{
60+ povray.NewLight(povray.Vec3{4, 8, 6}, povray.Color{1, 0.95, 0.85}),
61+ povray.NewLight(povray.Vec3{-6, 4, -4}, povray.Color{0.6, 0.7, 0.9}),
62+ }
63+
64+ cam := povray.NewCamera(povray.Vec3{0, 2.5, 10})
65+ cam.Fov = 55
66+
67+ renderer := povray.Renderer{Scene: scene, Width: 800, Height: 600, Samples: 2}
68+ img := renderer.Render(cam)
69+ if err := img.SavePNG("spheres.png"); err != nil {
70+ log.Fatal(err)
71+ }
72+ fmt.Println("wrote spheres.png")
73+}
added demos/qwen-style/workspace/examples/transform/main.go +77 -0
new file mode 100644
@@ -0,0 +1,77 @@
1+// Command transform renders a scene built entirely from transformed
2+// primitives: tilted boxes, a stretched ellipsoid and a ring of rotated
3+// boxes, to show that objects can live in any orientation.
4+package main
5+
6+import (
7+ "fmt"
8+ "log"
9+ "math"
10+
11+ "github.com/user/povray"
12+)
13+
14+func main() {
15+ scene := povray.NewScene()
16+ scene.MaxDepth = 4
17+ scene.Lights = []povray.Light{
18+ povray.NewLight(povray.Vec3{6, 10, 8}, povray.White),
19+ povray.NewLight(povray.Vec3{-8, 4, -4}, povray.Color{0.5, 0.6, 1.0}),
20+ }
21+
22+ ground := povray.Material{Diffuse: povray.Color{0.18, 0.18, 0.22}, Ambient: 0.35}
23+ scene.AddObject(povray.NewPlaneXY(0), ground)
24+
25+ // A box tilted 45° around X, leaning on the ground.
26+ tilted := povray.Transform{
27+ Object: povray.Box{Min: povray.Vec3{-1, 0, -1}, Max: povray.Vec3{1, 2, 1}},
28+ Matrix: povray.Translate(-3, 0, -2).Multiply(povray.RotateX(math.Pi / 5)),
29+ }
30+ scene.AddObject(tilted, povray.Material{
31+ Diffuse: povray.Color{0.9, 0.3, 0.2}, Specular: povray.White,
32+ Shininess: 40, Ambient: 0.1,
33+ })
34+
35+ // A stretched ellipsoid (scaled sphere), mirror-finished.
36+ ellipsoid := povray.Transform{
37+ Object: povray.Sphere{Center: povray.Vec3{0, 0, 0}, Radius: 1},
38+ Matrix: povray.Translate(3, 1.2, -3).Multiply(povray.Scale(1.6, 1, 1)).Multiply(povray.RotateY(math.Pi / 6)),
39+ }
40+ scene.AddObject(ellipsoid, povray.Material{
41+ Diffuse: povray.Color{0.2, 0.7, 0.9}, Specular: povray.White,
42+ Shininess: 80, Ambient: 0.1, Reflectivity: 0.5,
43+ })
44+
45+ // A ring of 12 boxes, each rotated around Y and tilted around X.
46+ ring := make([]povray.Object, 0, 12)
47+ for i := 0; i < 12; i++ {
48+ a := 2 * math.Pi * float64(i) / 12
49+ m := povray.Translate(0, 0, 0).
50+ Multiply(povray.RotateY(a)).
51+ Multiply(povray.Translate(0, 0, -5.5)).
52+ Multiply(povray.RotateX(math.Pi / 4))
53+ ring = append(ring, povray.Transform{
54+ Object: povray.Box{Min: povray.Vec3{-0.4, 0, -0.4}, Max: povray.Vec3{0.4, 1.6, 0.4}},
55+ Matrix: m,
56+ })
57+ }
58+ scene.AddObject(povray.Group{Objects: ring}, povray.Material{
59+ Diffuse: povray.Color{0.85, 0.75, 0.3}, Specular: povray.White,
60+ Shininess: 30, Ambient: 0.15,
61+ })
62+
63+ // A glass sphere for refraction, sitting on the ground.
64+ scene.AddObject(povray.Sphere{Center: povray.Vec3{0, 1, 1}, Radius: 1},
65+ povray.Material{Diffuse: povray.White, Transparency: 0.9, IOR: 1.5,
66+ Shininess: 100, Specular: povray.White, Ambient: 0.05})
67+
68+ cam := povray.NewCamera(povray.Vec3{0, 3.5, 12})
69+ cam.LookAt = povray.Vec3{0, 1, -2}
70+
71+ renderer := povray.Renderer{Scene: scene, Width: 800, Height: 600, Samples: 2}
72+ img := renderer.Render(cam)
73+ if err := img.SavePNG("transform.png"); err != nil {
74+ log.Fatal(err)
75+ }
76+ fmt.Println("wrote transform.png")
77+}
new file mode 100644
@@ -0,0 +1,77 @@
1+// Command transform renders a scene built entirely from transformed
2+// primitives: tilted boxes, a stretched ellipsoid and a ring of rotated
3+// boxes, to show that objects can live in any orientation.
4+package main
5+
6+import (
7+ "fmt"
8+ "log"
9+ "math"
10+
11+ "github.com/user/povray"
12+)
13+
14+func main() {
15+ scene := povray.NewScene()
16+ scene.MaxDepth = 4
17+ scene.Lights = []povray.Light{
18+ povray.NewLight(povray.Vec3{6, 10, 8}, povray.White),
19+ povray.NewLight(povray.Vec3{-8, 4, -4}, povray.Color{0.5, 0.6, 1.0}),
20+ }
21+
22+ ground := povray.Material{Diffuse: povray.Color{0.18, 0.18, 0.22}, Ambient: 0.35}
23+ scene.AddObject(povray.NewPlaneXY(0), ground)
24+
25+ // A box tilted 45° around X, leaning on the ground.
26+ tilted := povray.Transform{
27+ Object: povray.Box{Min: povray.Vec3{-1, 0, -1}, Max: povray.Vec3{1, 2, 1}},
28+ Matrix: povray.Translate(-3, 0, -2).Multiply(povray.RotateX(math.Pi / 5)),
29+ }
30+ scene.AddObject(tilted, povray.Material{
31+ Diffuse: povray.Color{0.9, 0.3, 0.2}, Specular: povray.White,
32+ Shininess: 40, Ambient: 0.1,
33+ })
34+
35+ // A stretched ellipsoid (scaled sphere), mirror-finished.
36+ ellipsoid := povray.Transform{
37+ Object: povray.Sphere{Center: povray.Vec3{0, 0, 0}, Radius: 1},
38+ Matrix: povray.Translate(3, 1.2, -3).Multiply(povray.Scale(1.6, 1, 1)).Multiply(povray.RotateY(math.Pi / 6)),
39+ }
40+ scene.AddObject(ellipsoid, povray.Material{
41+ Diffuse: povray.Color{0.2, 0.7, 0.9}, Specular: povray.White,
42+ Shininess: 80, Ambient: 0.1, Reflectivity: 0.5,
43+ })
44+
45+ // A ring of 12 boxes, each rotated around Y and tilted around X.
46+ ring := make([]povray.Object, 0, 12)
47+ for i := 0; i < 12; i++ {
48+ a := 2 * math.Pi * float64(i) / 12
49+ m := povray.Translate(0, 0, 0).
50+ Multiply(povray.RotateY(a)).
51+ Multiply(povray.Translate(0, 0, -5.5)).
52+ Multiply(povray.RotateX(math.Pi / 4))
53+ ring = append(ring, povray.Transform{
54+ Object: povray.Box{Min: povray.Vec3{-0.4, 0, -0.4}, Max: povray.Vec3{0.4, 1.6, 0.4}},
55+ Matrix: m,
56+ })
57+ }
58+ scene.AddObject(povray.Group{Objects: ring}, povray.Material{
59+ Diffuse: povray.Color{0.85, 0.75, 0.3}, Specular: povray.White,
60+ Shininess: 30, Ambient: 0.15,
61+ })
62+
63+ // A glass sphere for refraction, sitting on the ground.
64+ scene.AddObject(povray.Sphere{Center: povray.Vec3{0, 1, 1}, Radius: 1},
65+ povray.Material{Diffuse: povray.White, Transparency: 0.9, IOR: 1.5,
66+ Shininess: 100, Specular: povray.White, Ambient: 0.05})
67+
68+ cam := povray.NewCamera(povray.Vec3{0, 3.5, 12})
69+ cam.LookAt = povray.Vec3{0, 1, -2}
70+
71+ renderer := povray.Renderer{Scene: scene, Width: 800, Height: 600, Samples: 2}
72+ img := renderer.Render(cam)
73+ if err := img.SavePNG("transform.png"); err != nil {
74+ log.Fatal(err)
75+ }
76+ fmt.Println("wrote transform.png")
77+}
added demos/qwen-style/workspace/go.mod +3 -0
new file mode 100644
@@ -0,0 +1,3 @@
1+module github.com/user/povray
2+
3+go 1.27.3
new file mode 100644
@@ -0,0 +1,3 @@
1+module github.com/user/povray
2+
3+go 1.27.3
added demos/qwen-style/workspace/material.go +75 -0
new file mode 100644
@@ -0,0 +1,75 @@
1+package povray
2+
3+// Color is an RGB value, any magnitude (values > 1 are clipped or
4+// tone-mapped at render time).
5+type Color struct {
6+ R, G, B float64
7+}
8+
9+func (c Color) Add(o Color) Color { return Color{c.R + o.R, c.G + o.G, c.B + o.B} }
10+func (c Color) Scale(s float64) Color {
11+ return Color{c.R * s, c.G * s, c.B * s}
12+}
13+func (c Color) Mul(o Color) Color { return Color{c.R * o.R, c.G * o.G, c.B * o.B} }
14+
15+// Common colors.
16+var (
17+ White = Color{1, 1, 1}
18+ Black = Color{0, 0, 0}
19+ Red = Color{0.9, 0.1, 0.1}
20+ Green = Color{0.1, 0.8, 0.2}
21+ Blue = Color{0.15, 0.3, 0.9}
22+ Yellow = Color{0.95, 0.85, 0.2}
23+ Orange = Color{0.95, 0.5, 0.1}
24+ Purple = Color{0.6, 0.2, 0.8}
25+ Cyan = Color{0.1, 0.8, 0.9}
26+ Gray = Color{0.5, 0.5, 0.5}
27+ Silver = Color{0.85, 0.85, 0.88}
28+ Gold = Color{1.0, 0.75, 0.25}
29+)
30+
31+// Material describes how a surface reacts to light.
32+type Material struct {
33+ // Diffuse is the base surface color.
34+ Diffuse Color
35+ // Specular is the highlight color (White for a plain white glint).
36+ Specular Color
37+ // Shininess controls highlight size (higher = tighter).
38+ Shininess float64
39+ // Ambient is the fraction of diffuse kept with no light at all.
40+ Ambient float64
41+ // Reflectivity is the mirror strength, 0..1.
42+ Reflectivity float64
43+ // Transparency is the glass strength, 0..1 (1 = fully see-through).
44+ Transparency float64
45+ // IOR is the index of refraction for transparent surfaces.
46+ IOR float64
47+ // Emission is the light the surface itself radiates (used by the
48+ // path tracer; see LightMaterial). Values above 1 make a brighter
49+ // light.
50+ Emission Color
51+}
52+
53+// DefaultMaterial is a plain white matte surface.
54+func DefaultMaterial() Material {
55+ return Material{Diffuse: White, Specular: White, Shininess: 10, Ambient: 0.1}
56+}
57+
58+// LightMaterial makes an emissive surface — a light source for the path
59+// tracer. intensity scales the color (1 is a dim panel, 10 a bright one).
60+func LightMaterial(c Color, intensity float64) Material {
61+ return Material{Emission: c.Scale(intensity), Specular: White, Shininess: 10}
62+}
63+
64+// Light is a point light.
65+type Light struct {
66+ Color Color
67+ Position Vec3
68+ // Intensity scales the light (default 1).
69+ Intensity float64
70+}
71+
72+// NewLight makes a light with full intensity.
73+func NewLight(pos Vec3, c Color) Light {
74+ return Light{Color: c, Position: pos, Intensity: 1}
75+}
new file mode 100644
@@ -0,0 +1,75 @@
1+package povray
2+
3+// Color is an RGB value, any magnitude (values > 1 are clipped or
4+// tone-mapped at render time).
5+type Color struct {
6+ R, G, B float64
7+}
8+
9+func (c Color) Add(o Color) Color { return Color{c.R + o.R, c.G + o.G, c.B + o.B} }
10+func (c Color) Scale(s float64) Color {
11+ return Color{c.R * s, c.G * s, c.B * s}
12+}
13+func (c Color) Mul(o Color) Color { return Color{c.R * o.R, c.G * o.G, c.B * o.B} }
14+
15+// Common colors.
16+var (
17+ White = Color{1, 1, 1}
18+ Black = Color{0, 0, 0}
19+ Red = Color{0.9, 0.1, 0.1}
20+ Green = Color{0.1, 0.8, 0.2}
21+ Blue = Color{0.15, 0.3, 0.9}
22+ Yellow = Color{0.95, 0.85, 0.2}
23+ Orange = Color{0.95, 0.5, 0.1}
24+ Purple = Color{0.6, 0.2, 0.8}
25+ Cyan = Color{0.1, 0.8, 0.9}
26+ Gray = Color{0.5, 0.5, 0.5}
27+ Silver = Color{0.85, 0.85, 0.88}
28+ Gold = Color{1.0, 0.75, 0.25}
29+)
30+
31+// Material describes how a surface reacts to light.
32+type Material struct {
33+ // Diffuse is the base surface color.
34+ Diffuse Color
35+ // Specular is the highlight color (White for a plain white glint).
36+ Specular Color
37+ // Shininess controls highlight size (higher = tighter).
38+ Shininess float64
39+ // Ambient is the fraction of diffuse kept with no light at all.
40+ Ambient float64
41+ // Reflectivity is the mirror strength, 0..1.
42+ Reflectivity float64
43+ // Transparency is the glass strength, 0..1 (1 = fully see-through).
44+ Transparency float64
45+ // IOR is the index of refraction for transparent surfaces.
46+ IOR float64
47+ // Emission is the light the surface itself radiates (used by the
48+ // path tracer; see LightMaterial). Values above 1 make a brighter
49+ // light.
50+ Emission Color
51+}
52+
53+// DefaultMaterial is a plain white matte surface.
54+func DefaultMaterial() Material {
55+ return Material{Diffuse: White, Specular: White, Shininess: 10, Ambient: 0.1}
56+}
57+
58+// LightMaterial makes an emissive surface — a light source for the path
59+// tracer. intensity scales the color (1 is a dim panel, 10 a bright one).
60+func LightMaterial(c Color, intensity float64) Material {
61+ return Material{Emission: c.Scale(intensity), Specular: White, Shininess: 10}
62+}
63+
64+// Light is a point light.
65+type Light struct {
66+ Color Color
67+ Position Vec3
68+ // Intensity scales the light (default 1).
69+ Intensity float64
70+}
71+
72+// NewLight makes a light with full intensity.
73+func NewLight(pos Vec3, c Color) Light {
74+ return Light{Color: c, Position: pos, Intensity: 1}
75+}
added demos/qwen-style/workspace/objects.go +129 -0
new file mode 100644
@@ -0,0 +1,129 @@
1+package povray
2+
3+import "math"
4+
5+// Object is anything a ray can intersect.
6+type Object interface {
7+ Intersect(r Ray) (Hit, bool)
8+}
9+
10+// Sphere
11+type Sphere struct {
12+ Center Vec3
13+ Radius float64
14+}
15+
16+func (s Sphere) Intersect(r Ray) (Hit, bool) {
17+ oc := r.Origin.Sub(s.Center)
18+ b := 2 * oc.Dot(r.Dir)
19+ c := oc.Dot(oc) - s.Radius*s.Radius
20+ disc := b*b - 4*c
21+ if disc < 0 {
22+ return Hit{}, false
23+ }
24+ var t float64
25+ sqrtDisc := math.Sqrt(disc)
26+ t0 := (-b - sqrtDisc) / 2
27+ t1 := (-b + sqrtDisc) / 2
28+ const eps = 1e-4
29+ switch {
30+ case t0 > eps:
31+ t = t0
32+ case t1 > eps:
33+ t = t1
34+ default:
35+ return Hit{}, false
36+ }
37+ p := r.Origin.Add(r.Dir.Scale(t))
38+ return Hit{Point: p, Dist: t, Normal: p.Sub(s.Center).Norm()}, true
39+}
40+
41+// Plane is the infinite surface ax + by + cz + d = 0, with the normal
42+// pointing in the (a,b,c) direction.
43+type Plane struct {
44+ Normal Vec3
45+ D float64
46+}
47+
48+// NewPlaneXY makes the horizontal plane y = h.
49+func NewPlaneXY(h float64) Plane { return Plane{Normal: Vec3{0, 1, 0}, D: -h} }
50+
51+func (p Plane) Intersect(r Ray) (Hit, bool) {
52+ denom := p.Normal.Dot(r.Dir)
53+ if math.Abs(denom) < 1e-8 {
54+ return Hit{}, false
55+ }
56+ t := -(p.Normal.Dot(r.Origin) + p.D) / denom
57+ if t < 1e-4 {
58+ return Hit{}, false
59+ }
60+ pt := r.Origin.Add(r.Dir.Scale(t))
61+ n := p.Normal
62+ if n.Dot(r.Dir) > 0 {
63+ n = n.Neg()
64+ }
65+ return Hit{Point: pt, Dist: t, Normal: n}, true
66+}
67+
68+// Box is an axis-aligned box.
69+type Box struct {
70+ Min, Max Vec3
71+}
72+
73+func (b Box) Intersect(r Ray) (Hit, bool) {
74+ var tmin, tmax float64
75+ tmin, tmax = -1e30, 1e30
76+ var n Vec3
77+ for i, o := range [3]float64{r.Origin.X, r.Origin.Y, r.Origin.Z} {
78+ d := [3]float64{r.Dir.X, r.Dir.Y, r.Dir.Z}[i]
79+ mn := [3]float64{b.Min.X, b.Min.Y, b.Min.Z}[i]
80+ mx := [3]float64{b.Max.X, b.Max.Y, b.Max.Z}[i]
81+ if math.Abs(d) < 1e-8 {
82+ if o < mn || o > mx {
83+ return Hit{}, false
84+ }
85+ continue
86+ }
87+ t0 := (mn - o) / d
88+ t1 := (mx - o) / d
89+ if t0 > t1 {
90+ t0, t1 = t1, t0
91+ }
92+ if t0 > tmin {
93+ tmin = t0
94+ if i == 0 {
95+ n = Vec3{-math.Copysign(1, d), 0, 0}
96+ } else if i == 1 {
97+ n = Vec3{0, -math.Copysign(1, d), 0}
98+ } else {
99+ n = Vec3{0, 0, -math.Copysign(1, d)}
100+ }
101+ }
102+ if t1 < tmax {
103+ tmax = t1
104+ }
105+ }
106+ if tmax < math.Max(tmin, 1e-4) {
107+ return Hit{}, false
108+ }
109+ t := tmin
110+ if t < 1e-4 {
111+ t = tmax
112+ }
113+ return Hit{Point: r.Origin.Add(r.Dir.Scale(t)), Dist: t, Normal: n}, true
114+}
115+
116+// Group intersects all children and keeps the closest hit.
117+type Group struct {
118+ Objects []Object
119+}
120+
121+func (g Group) Intersect(r Ray) (Hit, bool) {
122+ best, found := Hit{}, false
123+ for _, o := range g.Objects {
124+ if h, ok := o.Intersect(r); ok && (!found || h.Dist < best.Dist) {
125+ best, found = h, true
126+ }
127+ }
128+ return best, found
129+}
new file mode 100644
@@ -0,0 +1,129 @@
1+package povray
2+
3+import "math"
4+
5+// Object is anything a ray can intersect.
6+type Object interface {
7+ Intersect(r Ray) (Hit, bool)
8+}
9+
10+// Sphere
11+type Sphere struct {
12+ Center Vec3
13+ Radius float64
14+}
15+
16+func (s Sphere) Intersect(r Ray) (Hit, bool) {
17+ oc := r.Origin.Sub(s.Center)
18+ b := 2 * oc.Dot(r.Dir)
19+ c := oc.Dot(oc) - s.Radius*s.Radius
20+ disc := b*b - 4*c
21+ if disc < 0 {
22+ return Hit{}, false
23+ }
24+ var t float64
25+ sqrtDisc := math.Sqrt(disc)
26+ t0 := (-b - sqrtDisc) / 2
27+ t1 := (-b + sqrtDisc) / 2
28+ const eps = 1e-4
29+ switch {
30+ case t0 > eps:
31+ t = t0
32+ case t1 > eps:
33+ t = t1
34+ default:
35+ return Hit{}, false
36+ }
37+ p := r.Origin.Add(r.Dir.Scale(t))
38+ return Hit{Point: p, Dist: t, Normal: p.Sub(s.Center).Norm()}, true
39+}
40+
41+// Plane is the infinite surface ax + by + cz + d = 0, with the normal
42+// pointing in the (a,b,c) direction.
43+type Plane struct {
44+ Normal Vec3
45+ D float64
46+}
47+
48+// NewPlaneXY makes the horizontal plane y = h.
49+func NewPlaneXY(h float64) Plane { return Plane{Normal: Vec3{0, 1, 0}, D: -h} }
50+
51+func (p Plane) Intersect(r Ray) (Hit, bool) {
52+ denom := p.Normal.Dot(r.Dir)
53+ if math.Abs(denom) < 1e-8 {
54+ return Hit{}, false
55+ }
56+ t := -(p.Normal.Dot(r.Origin) + p.D) / denom
57+ if t < 1e-4 {
58+ return Hit{}, false
59+ }
60+ pt := r.Origin.Add(r.Dir.Scale(t))
61+ n := p.Normal
62+ if n.Dot(r.Dir) > 0 {
63+ n = n.Neg()
64+ }
65+ return Hit{Point: pt, Dist: t, Normal: n}, true
66+}
67+
68+// Box is an axis-aligned box.
69+type Box struct {
70+ Min, Max Vec3
71+}
72+
73+func (b Box) Intersect(r Ray) (Hit, bool) {
74+ var tmin, tmax float64
75+ tmin, tmax = -1e30, 1e30
76+ var n Vec3
77+ for i, o := range [3]float64{r.Origin.X, r.Origin.Y, r.Origin.Z} {
78+ d := [3]float64{r.Dir.X, r.Dir.Y, r.Dir.Z}[i]
79+ mn := [3]float64{b.Min.X, b.Min.Y, b.Min.Z}[i]
80+ mx := [3]float64{b.Max.X, b.Max.Y, b.Max.Z}[i]
81+ if math.Abs(d) < 1e-8 {
82+ if o < mn || o > mx {
83+ return Hit{}, false
84+ }
85+ continue
86+ }
87+ t0 := (mn - o) / d
88+ t1 := (mx - o) / d
89+ if t0 > t1 {
90+ t0, t1 = t1, t0
91+ }
92+ if t0 > tmin {
93+ tmin = t0
94+ if i == 0 {
95+ n = Vec3{-math.Copysign(1, d), 0, 0}
96+ } else if i == 1 {
97+ n = Vec3{0, -math.Copysign(1, d), 0}
98+ } else {
99+ n = Vec3{0, 0, -math.Copysign(1, d)}
100+ }
101+ }
102+ if t1 < tmax {
103+ tmax = t1
104+ }
105+ }
106+ if tmax < math.Max(tmin, 1e-4) {
107+ return Hit{}, false
108+ }
109+ t := tmin
110+ if t < 1e-4 {
111+ t = tmax
112+ }
113+ return Hit{Point: r.Origin.Add(r.Dir.Scale(t)), Dist: t, Normal: n}, true
114+}
115+
116+// Group intersects all children and keeps the closest hit.
117+type Group struct {
118+ Objects []Object
119+}
120+
121+func (g Group) Intersect(r Ray) (Hit, bool) {
122+ best, found := Hit{}, false
123+ for _, o := range g.Objects {
124+ if h, ok := o.Intersect(r); ok && (!found || h.Dist < best.Dist) {
125+ best, found = h, true
126+ }
127+ }
128+ return best, found
129+}
added demos/qwen-style/workspace/pathtracer.go +148 -0
new file mode 100644
@@ -0,0 +1,148 @@
1+package povray
2+
3+import (
4+ "math"
5+ "math/rand/v2"
6+ "sync"
7+)
8+
9+// PathTracer renders a scene with path tracing: each ray bounces around
10+// the scene, scattering diffusely at matte surfaces, until it hits an
11+// emissive surface or leaves the scene. This gives global illumination
12+// and soft shadows, at the cost of a longer render (use Samples to
13+// control quality vs. time).
14+//
15+// Lights for path tracing are emissive surfaces: give an object a
16+// material with Emission set (see LightMaterial). Point lights in
17+// Scene.Lights are ignored by the path tracer.
18+type PathTracer struct {
19+ Scene *Scene
20+ // Width and Height of the image in pixels.
21+ Width, Height int
22+ // Samples is the number of primary rays per pixel.
23+ Samples int
24+ // Threads is the number of worker goroutines (0 = 4).
25+ Threads int
26+}
27+
28+// Render renders the scene and returns the image. Each worker goroutine
29+// uses its own random stream, so a given configuration always produces
30+// the same image.
31+func (p *PathTracer) Render(cam Camera) *Image {
32+ samples := p.Samples
33+ if samples < 1 {
34+ samples = 1
35+ }
36+ threads := p.Threads
37+ if threads <= 0 {
38+ threads = 4
39+ }
40+ img := NewImage(p.Width, p.Height)
41+
42+ var wg sync.WaitGroup
43+ rowsPer := (p.Height + threads - 1) / threads
44+ for c := 0; c < threads; c++ {
45+ y0, y1 := c*rowsPer, (c+1)*rowsPer
46+ if y1 > p.Height {
47+ y1 = p.Height
48+ }
49+ if y0 >= y1 {
50+ break
51+ }
52+ wg.Add(1)
53+ go func(y0, y1 int, rng *rand.Rand) {
54+ defer wg.Done()
55+ for y := y0; y < y1; y++ {
56+ for x := 0; x < p.Width; x++ {
57+ var col Color
58+ for smp := 0; smp < samples; smp++ {
59+ u := float64(x) + rng.Float64()
60+ v := float64(y) + rng.Float64()
61+ col = col.Add(p.trace(cam.RayAtUV(u, v, p.Width, p.Height), 0, rng))
62+ }
63+ img.Set(x, y, col.Scale(1.0/float64(samples)))
64+ }
65+ }
66+ }(y0, y1, rand.New(rand.NewPCG(uint64(12345+c), uint64(987654321+c))))
67+ }
68+ wg.Wait()
69+ return img
70+}
71+
72+// trace follows one path through the scene and returns the accumulated
73+// radiance.
74+func (p *PathTracer) trace(ray Ray, depth int, rng *rand.Rand) Color {
75+ // Hard bounce limit: glass and mirrors can bounce a ray forever;
76+ // past 50 bounces the remaining contribution is negligible.
77+ if depth > 50 {
78+ return Black
79+ }
80+ s := p.Scene
81+ idx, hit := closestHit(s.Objects, ray)
82+ if idx < 0 {
83+ return s.Background
84+ }
85+ hit.Flip(ray)
86+ mat := s.Materials[idx]
87+
88+ // An emissive surface radiates its own light.
89+ col := mat.Emission
90+
91+ // Specular: mirror the ray and keep going.
92+ if mat.Reflectivity > 0 {
93+ r := ray.Dir.Sub(hit.Normal.Scale(2 * ray.Dir.Dot(hit.Normal))).Norm()
94+ rr := Ray{Origin: hit.Point.Add(hit.Normal.Scale(1e-4)), Dir: r}
95+ col = col.Add(p.trace(rr, depth+1, rng).Scale(mat.Reflectivity))
96+ }
97+
98+ // Glass: bend the ray through the surface and keep going.
99+ if mat.Transparency > 0 {
100+ ior := 1.0
101+ if mat.IOR > 0 {
102+ ior = mat.IOR
103+ }
104+ if refr, ok := refract(ray.Dir, hit.Normal, 1/ior); ok {
105+ tr := Ray{Origin: hit.Point.Add(hit.Normal.Scale(1e-4)), Dir: refr}
106+ col = col.Add(p.trace(tr, depth+1, rng).Scale(mat.Transparency))
107+ }
108+ }
109+
110+ // Diffuse: scatter in a cosine-weighted random direction. This is
111+ // what carries light from one surface to another (global
112+ // illumination).
113+ if depth < 8 && (mat.Diffuse.R > 0 || mat.Diffuse.G > 0 || mat.Diffuse.B > 0) {
114+ d := cosineSample(hit.Normal, rng)
115+ dr := Ray{Origin: hit.Point.Add(hit.Normal.Scale(1e-4)), Dir: d}
116+ col = col.Add(p.trace(dr, depth+1, rng).Mul(mat.Diffuse))
117+ }
118+
119+ // Russian roulette: terminate the path with probability 1-max(col),
120+ // unbiased.
121+ q := math.Max(col.R, math.Max(col.G, col.B))
122+ q = math.Min(1, math.Max(q, 0.05))
123+ if rng.Float64() > q {
124+ return col
125+ }
126+ return col.Scale(1 / q)
127+}
128+
129+// cosineSample returns a random direction in the hemisphere around n,
130+// weighted by cos(theta) — the distribution a perfect diffuse surface
131+// scatters into.
132+func cosineSample(n Vec3, rng *rand.Rand) Vec3 {
133+ // A random point on the unit disk, mapped onto the hemisphere.
134+ r := math.Sqrt(rng.Float64())
135+ phi := 2 * math.Pi * rng.Float64()
136+ rx, ry := r*math.Cos(phi), r*math.Sin(phi)
137+ rz := math.Sqrt(math.Max(0, 1-r*r))
138+ // A basis with n as its z axis.
139+ var up Vec3
140+ if math.Abs(n.Y) < 0.99 {
141+ up = Vec3{0, 1, 0}
142+ } else {
143+ up = Vec3{1, 0, 0}
144+ }
145+ u := n.Cross(up).Norm()
146+ v := n.Cross(u)
147+ return u.Scale(rx).Add(v.Scale(ry)).Add(n.Scale(rz))
148+}
new file mode 100644
@@ -0,0 +1,148 @@
1+package povray
2+
3+import (
4+ "math"
5+ "math/rand/v2"
6+ "sync"
7+)
8+
9+// PathTracer renders a scene with path tracing: each ray bounces around
10+// the scene, scattering diffusely at matte surfaces, until it hits an
11+// emissive surface or leaves the scene. This gives global illumination
12+// and soft shadows, at the cost of a longer render (use Samples to
13+// control quality vs. time).
14+//
15+// Lights for path tracing are emissive surfaces: give an object a
16+// material with Emission set (see LightMaterial). Point lights in
17+// Scene.Lights are ignored by the path tracer.
18+type PathTracer struct {
19+ Scene *Scene
20+ // Width and Height of the image in pixels.
21+ Width, Height int
22+ // Samples is the number of primary rays per pixel.
23+ Samples int
24+ // Threads is the number of worker goroutines (0 = 4).
25+ Threads int
26+}
27+
28+// Render renders the scene and returns the image. Each worker goroutine
29+// uses its own random stream, so a given configuration always produces
30+// the same image.
31+func (p *PathTracer) Render(cam Camera) *Image {
32+ samples := p.Samples
33+ if samples < 1 {
34+ samples = 1
35+ }
36+ threads := p.Threads
37+ if threads <= 0 {
38+ threads = 4
39+ }
40+ img := NewImage(p.Width, p.Height)
41+
42+ var wg sync.WaitGroup
43+ rowsPer := (p.Height + threads - 1) / threads
44+ for c := 0; c < threads; c++ {
45+ y0, y1 := c*rowsPer, (c+1)*rowsPer
46+ if y1 > p.Height {
47+ y1 = p.Height
48+ }
49+ if y0 >= y1 {
50+ break
51+ }
52+ wg.Add(1)
53+ go func(y0, y1 int, rng *rand.Rand) {
54+ defer wg.Done()
55+ for y := y0; y < y1; y++ {
56+ for x := 0; x < p.Width; x++ {
57+ var col Color
58+ for smp := 0; smp < samples; smp++ {
59+ u := float64(x) + rng.Float64()
60+ v := float64(y) + rng.Float64()
61+ col = col.Add(p.trace(cam.RayAtUV(u, v, p.Width, p.Height), 0, rng))
62+ }
63+ img.Set(x, y, col.Scale(1.0/float64(samples)))
64+ }
65+ }
66+ }(y0, y1, rand.New(rand.NewPCG(uint64(12345+c), uint64(987654321+c))))
67+ }
68+ wg.Wait()
69+ return img
70+}
71+
72+// trace follows one path through the scene and returns the accumulated
73+// radiance.
74+func (p *PathTracer) trace(ray Ray, depth int, rng *rand.Rand) Color {
75+ // Hard bounce limit: glass and mirrors can bounce a ray forever;
76+ // past 50 bounces the remaining contribution is negligible.
77+ if depth > 50 {
78+ return Black
79+ }
80+ s := p.Scene
81+ idx, hit := closestHit(s.Objects, ray)
82+ if idx < 0 {
83+ return s.Background
84+ }
85+ hit.Flip(ray)
86+ mat := s.Materials[idx]
87+
88+ // An emissive surface radiates its own light.
89+ col := mat.Emission
90+
91+ // Specular: mirror the ray and keep going.
92+ if mat.Reflectivity > 0 {
93+ r := ray.Dir.Sub(hit.Normal.Scale(2 * ray.Dir.Dot(hit.Normal))).Norm()
94+ rr := Ray{Origin: hit.Point.Add(hit.Normal.Scale(1e-4)), Dir: r}
95+ col = col.Add(p.trace(rr, depth+1, rng).Scale(mat.Reflectivity))
96+ }
97+
98+ // Glass: bend the ray through the surface and keep going.
99+ if mat.Transparency > 0 {
100+ ior := 1.0
101+ if mat.IOR > 0 {
102+ ior = mat.IOR
103+ }
104+ if refr, ok := refract(ray.Dir, hit.Normal, 1/ior); ok {
105+ tr := Ray{Origin: hit.Point.Add(hit.Normal.Scale(1e-4)), Dir: refr}
106+ col = col.Add(p.trace(tr, depth+1, rng).Scale(mat.Transparency))
107+ }
108+ }
109+
110+ // Diffuse: scatter in a cosine-weighted random direction. This is
111+ // what carries light from one surface to another (global
112+ // illumination).
113+ if depth < 8 && (mat.Diffuse.R > 0 || mat.Diffuse.G > 0 || mat.Diffuse.B > 0) {
114+ d := cosineSample(hit.Normal, rng)
115+ dr := Ray{Origin: hit.Point.Add(hit.Normal.Scale(1e-4)), Dir: d}
116+ col = col.Add(p.trace(dr, depth+1, rng).Mul(mat.Diffuse))
117+ }
118+
119+ // Russian roulette: terminate the path with probability 1-max(col),
120+ // unbiased.
121+ q := math.Max(col.R, math.Max(col.G, col.B))
122+ q = math.Min(1, math.Max(q, 0.05))
123+ if rng.Float64() > q {
124+ return col
125+ }
126+ return col.Scale(1 / q)
127+}
128+
129+// cosineSample returns a random direction in the hemisphere around n,
130+// weighted by cos(theta) — the distribution a perfect diffuse surface
131+// scatters into.
132+func cosineSample(n Vec3, rng *rand.Rand) Vec3 {
133+ // A random point on the unit disk, mapped onto the hemisphere.
134+ r := math.Sqrt(rng.Float64())
135+ phi := 2 * math.Pi * rng.Float64()
136+ rx, ry := r*math.Cos(phi), r*math.Sin(phi)
137+ rz := math.Sqrt(math.Max(0, 1-r*r))
138+ // A basis with n as its z axis.
139+ var up Vec3
140+ if math.Abs(n.Y) < 0.99 {
141+ up = Vec3{0, 1, 0}
142+ } else {
143+ up = Vec3{1, 0, 0}
144+ }
145+ u := n.Cross(up).Norm()
146+ v := n.Cross(u)
147+ return u.Scale(rx).Add(v.Scale(ry)).Add(n.Scale(rz))
148+}
added demos/qwen-style/workspace/png.go +62 -0
new file mode 100644
@@ -0,0 +1,62 @@
1+package povray
2+
3+import (
4+ "bytes"
5+ "compress/zlib"
6+ "encoding/binary"
7+ "hash/crc32"
8+ "os"
9+)
10+
11+// SavePNG writes the image as a PNG file (truecolor, 8 bits per channel).
12+// It uses only the standard library.
13+func (img *Image) SavePNG(path string) error {
14+ var body bytes.Buffer
15+
16+ // Signature.
17+ body.Write([]byte{137, 80, 78, 71, 13, 10, 26, 10})
18+
19+ // IHDR: width, height, bit depth 8, color type 2 (RGB),
20+ // compression 0, filter 0, interlace 0.
21+ var ihdr [13]byte
22+ binary.BigEndian.PutUint32(ihdr[0:4], uint32(img.Width))
23+ binary.BigEndian.PutUint32(ihdr[4:8], uint32(img.Height))
24+ ihdr[8] = 8 // bit depth
25+ ihdr[9] = 2 // color type: truecolor
26+ body.Write(pngChunk("IHDR", ihdr[:]))
27+
28+ // IDAT: each scanline prefixed with filter byte 0, then zlib-compressed.
29+ raw := make([]byte, 0, img.Height*(1+img.Width*3))
30+ for y := 0; y < img.Height; y++ {
31+ raw = append(raw, 0)
32+ row := img.Pix[y*img.Width*3 : (y+1)*img.Width*3]
33+ raw = append(raw, row...)
34+ }
35+ var compressed bytes.Buffer
36+ zw, err := zlib.NewWriterLevel(&compressed, zlib.DefaultCompression)
37+ if err != nil {
38+ return err
39+ }
40+ if _, err := zw.Write(raw); err != nil {
41+ return err
42+ }
43+ if err := zw.Close(); err != nil {
44+ return err
45+ }
46+ body.Write(pngChunk("IDAT", compressed.Bytes()))
47+
48+ // IEND.
49+ body.Write(pngChunk("IEND", nil))
50+
51+ return os.WriteFile(path, body.Bytes(), 0o644)
52+}
53+
54+// pngChunk builds a PNG chunk: 4-byte length, type, data, CRC32(type+data).
55+func pngChunk(typ string, data []byte) []byte {
56+ out := make([]byte, 12+len(data))
57+ binary.BigEndian.PutUint32(out[0:4], uint32(len(data)))
58+ copy(out[4:8], typ)
59+ copy(out[8:], data)
60+ binary.BigEndian.PutUint32(out[8+len(data):], crc32.ChecksumIEEE(out[4:8+len(data)]))
61+ return out
62+}
new file mode 100644
@@ -0,0 +1,62 @@
1+package povray
2+
3+import (
4+ "bytes"
5+ "compress/zlib"
6+ "encoding/binary"
7+ "hash/crc32"
8+ "os"
9+)
10+
11+// SavePNG writes the image as a PNG file (truecolor, 8 bits per channel).
12+// It uses only the standard library.
13+func (img *Image) SavePNG(path string) error {
14+ var body bytes.Buffer
15+
16+ // Signature.
17+ body.Write([]byte{137, 80, 78, 71, 13, 10, 26, 10})
18+
19+ // IHDR: width, height, bit depth 8, color type 2 (RGB),
20+ // compression 0, filter 0, interlace 0.
21+ var ihdr [13]byte
22+ binary.BigEndian.PutUint32(ihdr[0:4], uint32(img.Width))
23+ binary.BigEndian.PutUint32(ihdr[4:8], uint32(img.Height))
24+ ihdr[8] = 8 // bit depth
25+ ihdr[9] = 2 // color type: truecolor
26+ body.Write(pngChunk("IHDR", ihdr[:]))
27+
28+ // IDAT: each scanline prefixed with filter byte 0, then zlib-compressed.
29+ raw := make([]byte, 0, img.Height*(1+img.Width*3))
30+ for y := 0; y < img.Height; y++ {
31+ raw = append(raw, 0)
32+ row := img.Pix[y*img.Width*3 : (y+1)*img.Width*3]
33+ raw = append(raw, row...)
34+ }
35+ var compressed bytes.Buffer
36+ zw, err := zlib.NewWriterLevel(&compressed, zlib.DefaultCompression)
37+ if err != nil {
38+ return err
39+ }
40+ if _, err := zw.Write(raw); err != nil {
41+ return err
42+ }
43+ if err := zw.Close(); err != nil {
44+ return err
45+ }
46+ body.Write(pngChunk("IDAT", compressed.Bytes()))
47+
48+ // IEND.
49+ body.Write(pngChunk("IEND", nil))
50+
51+ return os.WriteFile(path, body.Bytes(), 0o644)
52+}
53+
54+// pngChunk builds a PNG chunk: 4-byte length, type, data, CRC32(type+data).
55+func pngChunk(typ string, data []byte) []byte {
56+ out := make([]byte, 12+len(data))
57+ binary.BigEndian.PutUint32(out[0:4], uint32(len(data)))
58+ copy(out[4:8], typ)
59+ copy(out[8:], data)
60+ binary.BigEndian.PutUint32(out[8+len(data):], crc32.ChecksumIEEE(out[4:8+len(data)]))
61+ return out
62+}
added demos/qwen-style/workspace/povray_test.go +336 -0
new file mode 100644
@@ -0,0 +1,336 @@
1+package povray
2+
3+import (
4+ "bytes"
5+ "encoding/binary"
6+ "math"
7+ "os"
8+ "testing"
9+)
10+
11+func TestSphereIntersect(t *testing.T) {
12+ s := Sphere{Center: Vec3{0, 0, 0}, Radius: 1}
13+ // Head-on hit.
14+ h, ok := s.Intersect(Ray{Origin: Vec3{0, 0, 5}, Dir: Vec3{0, 0, -1}})
15+ if !ok {
16+ t.Fatal("expected a hit")
17+ }
18+ if h.Dist != 4 {
19+ t.Errorf("dist = %v, want 4", h.Dist)
20+ }
21+ if h.Point.Z != 1 {
22+ t.Errorf("point = %v, want z=1", h.Point)
23+ }
24+ // Miss.
25+ if _, ok := s.Intersect(Ray{Origin: Vec3{0, 5, 5}, Dir: Vec3{0, 0, -1}}); ok {
26+ t.Error("expected a miss")
27+ }
28+ // Ray starting inside: should still hit the far side.
29+ h, ok = s.Intersect(Ray{Origin: Vec3{0, 0, 0}, Dir: Vec3{0, 0, -1}})
30+ if !ok || h.Dist <= 0 {
31+ t.Errorf("inside hit = %+v, %v", h, ok)
32+ }
33+}
34+
35+func TestPlaneIntersect(t *testing.T) {
36+ p := NewPlaneXY(2)
37+ h, ok := p.Intersect(Ray{Origin: Vec3{0, 0, 0}, Dir: Vec3{0, 1, 0}})
38+ if !ok || h.Dist != 2 {
39+ t.Errorf("hit = %+v, %v", h, ok)
40+ }
41+ // Parallel ray misses.
42+ if _, ok := p.Intersect(Ray{Origin: Vec3{0, 0, 0}, Dir: Vec3{1, 0, 0}}); ok {
43+ t.Error("expected a miss for parallel ray")
44+ }
45+}
46+
47+func TestBoxIntersect(t *testing.T) {
48+ b := Box{Min: Vec3{-1, -1, -1}, Max: Vec3{1, 1, 1}}
49+ h, ok := b.Intersect(Ray{Origin: Vec3{0, 0, 5}, Dir: Vec3{0, 0, -1}})
50+ if !ok || h.Dist != 4 || h.Normal.Z != 1 {
51+ t.Errorf("hit = %+v, %v", h, ok)
52+ }
53+ if _, ok := b.Intersect(Ray{Origin: Vec3{5, 0, 0}, Dir: Vec3{0, 0, -1}}); ok {
54+ t.Error("expected a miss")
55+ }
56+}
57+
58+func TestGroupClosest(t *testing.T) {
59+ g := Group{Objects: []Object{
60+ Sphere{Center: Vec3{0, 0, -2}, Radius: 0.5},
61+ Sphere{Center: Vec3{0, 0, -5}, Radius: 0.5},
62+ }}
63+ h, ok := g.Intersect(Ray{Origin: Vec3{0, 0, 0}, Dir: Vec3{0, 0, -1}})
64+ if !ok || h.Dist != 1.5 {
65+ t.Errorf("expected closest hit at 1.5, got %+v, %v", h, ok)
66+ }
67+}
68+
69+func TestRefract(t *testing.T) {
70+ // Straight through: direction parallel to normal is unchanged.
71+ d, ok := refract(Vec3{0, 0, -1}, Vec3{0, 0, 1}, 1.5)
72+ if !ok || d.Z >= 0 {
73+ t.Errorf("got %+v, %v", d, ok)
74+ }
75+ // Grazing incidence: total internal reflection.
76+ if _, ok := refract(Vec3{0.999, 0, -0.04}, Vec3{0, 0, 1}, 1.5); ok {
77+ t.Error("expected total internal reflection")
78+ }
79+}
80+
81+func TestTransformMat4(t *testing.T) {
82+ // Identity leaves points and dirs alone.
83+ if p := Identity().TransformPoint(Vec3{1, 2, 3}); p != (Vec3{1, 2, 3}) {
84+ t.Errorf("identity point = %v", p)
85+ }
86+ // Translate moves a point, not a direction.
87+ m := Translate(1, -2, 3)
88+ if p := m.TransformPoint(Vec3{0, 0, 0}); p != (Vec3{1, -2, 3}) {
89+ t.Errorf("translate point = %v", p)
90+ }
91+ if d := m.TransformDir(Vec3{1, 0, 0}); d != (Vec3{1, 0, 0}) {
92+ t.Errorf("translate dir = %v", d)
93+ }
94+ // Scale stretches a direction.
95+ if d := Scale(2, 3, 4).TransformDir(Vec3{1, 1, 1}); d != (Vec3{2, 3, 4}) {
96+ t.Errorf("scale dir = %v", d)
97+ }
98+ // RotateY by pi/2 sends +Z to +X.
99+ d := RotateY(math.Pi / 2).TransformDir(Vec3{0, 0, 1})
100+ if math.Abs(d.X-1) > 1e-9 || math.Abs(d.Z) > 1e-9 {
101+ t.Errorf("rotateY dir = %v", d)
102+ }
103+ // Multiply: translate then scale (scale first, then translate).
104+ m = Translate(1, 0, 0).Multiply(Scale(2, 2, 2))
105+ if p := m.TransformPoint(Vec3{1, 0, 0}); p != (Vec3{3, 0, 0}) {
106+ t.Errorf("composed point = %v", p)
107+ }
108+ // Inverse round-trips a point; singular matrix reports failure.
109+ m = Translate(1, 2, 3).Multiply(RotateX(0.7)).Multiply(Scale(2, 3, 4))
110+ inv, ok := m.Inverse()
111+ if !ok {
112+ t.Fatal("expected an inverse")
113+ }
114+ p := Vec3{1.3, -0.4, 2.2}
115+ if rt := inv.TransformPoint(m.TransformPoint(p)); rt.Sub(p).Len() > 1e-9 {
116+ t.Errorf("round trip = %v", rt)
117+ }
118+ if _, ok := Scale(0, 1, 1).Inverse(); ok {
119+ t.Error("expected singular matrix")
120+ }
121+ // Non-uniform scale: normals go through the inverse transpose.
122+ // A sphere scaled by (2,1,1) is the ellipsoid x²/4+y²+z²=1; at the
123+ // point where the local normal is (1,1,0), the world normal is
124+ // (0.5,1,0)/|(0.5,1,0)| = (0.447, 0.894, 0).
125+ n := Scale(2, 1, 1).TransformNormal(Vec3{1, 1, 0})
126+ want := Vec3{0.5, 1, 0}.Norm()
127+ if n.Sub(want).Len() > 1e-9 {
128+ t.Errorf("scaled normal = %v, want %v", n, want)
129+ }
130+}
131+
132+func TestTransformObject(t *testing.T) {
133+ // A translated sphere: hit at the shifted point, normal preserved.
134+ s := Transform{Object: Sphere{Center: Vec3{0, 0, 0}, Radius: 1}, Matrix: Translate(0, 0, -5)}
135+ h, ok := s.Intersect(Ray{Origin: Vec3{0, 0, 0}, Dir: Vec3{0, 0, -1}})
136+ if !ok || math.Abs(h.Dist-4) > 1e-6 || h.Point.Z != -4 {
137+ t.Errorf("hit = %+v, %v", h, ok)
138+ }
139+ if h.Normal.Z != 1 {
140+ t.Errorf("normal = %v, want (0,0,1)", h.Normal)
141+ }
142+ // A box rotated 45° around Z is symmetric about the Z axis, so a
143+ // head-on -Z ray still hits the front face at the same distance.
144+ b := Transform{Object: Box{Min: Vec3{-1, -1, -1}, Max: Vec3{1, 1, 1}}, Matrix: RotateZ(math.Pi / 4)}
145+ h, ok = b.Intersect(Ray{Origin: Vec3{0, 0, 5}, Dir: Vec3{0, 0, -1}})
146+ if !ok || math.Abs(h.Dist-4) > 1e-6 {
147+ t.Errorf("rotated box hit = %+v, %v", h, ok)
148+ }
149+ // Rotate the box 45° around X. A -Z ray at y=0.5 strikes the tilted
150+ // former top face (local y=1); its world normal is RotateX(45°)·(0,1,0)
151+ // = (0, cos45, sin45).
152+ b = Transform{Object: Box{Min: Vec3{-1, -1, -1}, Max: Vec3{1, 1, 1}}, Matrix: RotateX(math.Pi / 4)}
153+ h, ok = b.Intersect(Ray{Origin: Vec3{0, 0.5, 5}, Dir: Vec3{0, 0, -1}})
154+ if !ok {
155+ t.Fatal("expected a hit on the rotated box")
156+ }
157+ if math.Abs(h.Normal.Y-math.Sqrt2/2) > 1e-6 || math.Abs(h.Normal.Z-math.Sqrt2/2) > 1e-6 {
158+ t.Errorf("rotated normal = %v, want (0, 0.707, 0.707)", h.Normal)
159+ }
160+ // Miss: a ray that misses the local object misses the transformed one.
161+ if _, ok := s.Intersect(Ray{Origin: Vec3{3, 0, 0}, Dir: Vec3{0, 0, -1}}); ok {
162+ t.Error("expected a miss")
163+ }
164+}
165+
166+func TestRayAtUV(t *testing.T) {
167+ cam := NewCamera(Vec3{0, 0, 5})
168+ // The center of an odd-sized image aims straight at the look-at point.
169+ c := cam.RayAt(5, 5, 11, 11)
170+ if math.Abs(c.Dir.X) > 1e-9 || math.Abs(c.Dir.Y) > 1e-9 || c.Dir.Z > 0 {
171+ t.Errorf("center ray = %v, want (0,0,-1)", c.Dir)
172+ }
173+ // RayAt(px, py) is the center of the pixel: same as RayAtUV(px+0.5, py+0.5).
174+ c2 := cam.RayAtUV(5.5, 5.5, 11, 11)
175+ if c.Dir.Sub(c2.Dir).Len() > 1e-12 {
176+ t.Errorf("RayAt != RayAtUV center: %v vs %v", c.Dir, c2.Dir)
177+ }
178+ // A sample in the top-left quadrant points up and left.
179+ tl := cam.RayAtUV(0.25, 0.25, 11, 11)
180+ if tl.Dir.X >= 0 || tl.Dir.Y <= 0 {
181+ t.Errorf("top-left sample = %v, want X<0, Y>0", tl.Dir)
182+ }
183+ // A sample in the bottom-right quadrant points down and right.
184+ br := cam.RayAtUV(10.75, 10.75, 11, 11)
185+ if br.Dir.X <= 0 || br.Dir.Y >= 0 {
186+ t.Errorf("bottom-right sample = %v, want X>0, Y<0", br.Dir)
187+ }
188+}
189+
190+func TestSupersampling(t *testing.T) {
191+ // A scene with a hard edge: a red sphere against a mid-gray
192+ // background. With 1 sample every pixel is pure red or pure gray;
193+ // with 2x2 samples the silhouette pixels must be a blend.
194+ build := func() *Scene {
195+ s := NewScene()
196+ s.AddObject(Sphere{Center: Vec3{0, 0, 0}, Radius: 1},
197+ Material{Diffuse: Color{1, 0, 0}, Ambient: 1})
198+ s.Background = Color{0.5, 0.5, 0.5}
199+ return s
200+ }
201+ cam := NewCamera(Vec3{0, 0, 5})
202+
203+ // Count pixels in row 20 (where the sphere's silhouette has a steep
204+ // slope, so a pixel's 2x2 sub-samples straddle the edge) whose green
205+ // channel is strictly between the sphere (pure red, G=0) and the
206+ // background (gray, G≈186 after gamma): a "mixed" pixel is an
207+ // anti-aliased edge.
208+ mixed := func(img *Image) int {
209+ var n int
210+ for x := 0; x < img.Width; x++ {
211+ i := (20*img.Width + x) * 3
212+ if img.Pix[i+1] > 5 && img.Pix[i+1] < 181 {
213+ n++
214+ }
215+ }
216+ return n
217+ }
218+
219+ r1 := Renderer{Scene: build(), Width: 64, Height: 48, Samples: 1}
220+ if m := mixed(r1.Render(cam)); m > 0 {
221+ t.Errorf("1-sample render has %d mixed pixels, want 0", m)
222+ }
223+
224+ r2 := Renderer{Scene: build(), Width: 64, Height: 48, Samples: 2}
225+ if m := mixed(r2.Render(cam)); m == 0 {
226+ t.Error("2x2 render has no mixed pixels: edge not anti-aliased")
227+ }
228+}
229+
230+func TestPathTracer(t *testing.T) {
231+ // A room: white floor, a sphere, and an emissive panel above.
232+ // Path tracing must light the floor from the panel, and the floor
233+ // behind the sphere (toward the camera) must be in shadow.
234+ build := func() *Scene {
235+ s := NewScene()
236+ s.Background = Black
237+ s.AddObject(NewPlaneXY(0), Material{Diffuse: White})
238+ s.AddObject(Sphere{Center: Vec3{0, 1, 0}, Radius: 1}, Material{Diffuse: White})
239+ // A downward-facing emissive panel.
240+ panel := Transform{
241+ Object: Box{Min: Vec3{-2, 0, -2}, Max: Vec3{2, 0.1, 2}},
242+ Matrix: Translate(0, 8, 0),
243+ }
244+ s.AddObject(panel, LightMaterial(White, 12))
245+ return s
246+ }
247+ cam := NewCamera(Vec3{0, 2, 8})
248+ p := PathTracer{Scene: build(), Width: 64, Height: 48, Samples: 8}
249+ img := p.Render(cam)
250+
251+ // The floor occupies the bottom half of the image. It must be
252+ // lit (not black) and must have a bright region (direct light from
253+ // the panel) and a dark region (the sphere's shadow).
254+ var min, max byte
255+ for y := 24; y < 48; y++ {
256+ for x := 0; x < 64; x++ {
257+ v := img.Pix[(y*64+x)*3]
258+ if x == 0 || v < min {
259+ min = v
260+ }
261+ if x == 0 || v > max {
262+ max = v
263+ }
264+ }
265+ }
266+ if max < 40 {
267+ t.Errorf("floor not lit by the panel: max = %d", max)
268+ }
269+ if float64(max) < 2*float64(min+1) {
270+ t.Errorf("no shadow visible: min = %d, max = %d", min, max)
271+ }
272+
273+ // Determinism: the same configuration must give the same image.
274+ img2 := p.Render(cam)
275+ if !bytes.Equal(img.Pix, img2.Pix) {
276+ t.Error("two renders of the same scene differ")
277+ }
278+}
279+
280+func TestRenderSmoke(t *testing.T) {
281+ s := NewScene()
282+ s.AddObject(Sphere{Center: Vec3{0, 1, 0}, Radius: 1},
283+ Material{Diffuse: Red, Specular: White, Shininess: 30, Ambient: 0.1})
284+ s.AddObject(NewPlaneXY(0), Material{Diffuse: Gray, Ambient: 0.3})
285+ s.Lights = []Light{NewLight(Vec3{4, 8, 6}, White)}
286+
287+ cam := NewCamera(Vec3{0, 2, 8})
288+ r := Renderer{Scene: s, Width: 64, Height: 48}
289+ img := r.Render(cam)
290+ if img.Width != 64 || img.Height != 48 {
291+ t.Fatalf("image size = %dx%d", img.Width, img.Height)
292+ }
293+ // The center pixel should hit the red sphere: red channel dominant.
294+ i := (24*64 + 32) * 3
295+ if img.Pix[i] < 60 {
296+ t.Errorf("center pixel too dark: %v", img.Pix[i:i+3])
297+ }
298+ if img.Pix[i+1] > img.Pix[i] || img.Pix[i+2] > img.Pix[i] {
299+ t.Errorf("center pixel not red-dominant: %v", img.Pix[i:i+3])
300+ }
301+}
302+
303+func TestSavePNG(t *testing.T) {
304+ img := NewImage(4, 3)
305+ for y := 0; y < 3; y++ {
306+ for x := 0; x < 4; x++ {
307+ img.Set(x, y, Color{float64(x) / 3, float64(y) / 2, 1})
308+ }
309+ }
310+ path := t.TempDir() + "/test.png"
311+ if err := img.SavePNG(path); err != nil {
312+ t.Fatal(err)
313+ }
314+ data, err := os.ReadFile(path)
315+ if err != nil {
316+ t.Fatal(err)
317+ }
318+ // PNG signature.
319+ sig := []byte{137, 80, 78, 71, 13, 10, 26, 10}
320+ if !bytes.Equal(data[:8], sig) {
321+ t.Fatalf("bad signature: %v", data[:8])
322+ }
323+ // IHDR must say 4x3, 8-bit truecolor.
324+ if binary.BigEndian.Uint32(data[16:20]) != 4 || binary.BigEndian.Uint32(data[20:24]) != 3 {
325+ t.Errorf("IHDR size wrong: %v", data[16:24])
326+ }
327+ if data[24] != 8 || data[25] != 2 {
328+ t.Errorf("IHDR depth/type wrong: %d %d", data[24], data[25])
329+ }
330+ // The file must end with an IEND chunk: length 0, "IEND", CRC AE426082.
331+ if !bytes.Equal(data[len(data)-8:len(data)-4], []byte("IEND")) ||
332+ !bytes.Equal(data[len(data)-4:], []byte{0xAE, 0x42, 0x60, 0x82}) ||
333+ !bytes.Equal(data[len(data)-12:len(data)-8], []byte{0, 0, 0, 0}) {
334+ t.Errorf("missing IEND trailer: %v", data[len(data)-12:])
335+ }
336+}
new file mode 100644
@@ -0,0 +1,336 @@
1+package povray
2+
3+import (
4+ "bytes"
5+ "encoding/binary"
6+ "math"
7+ "os"
8+ "testing"
9+)
10+
11+func TestSphereIntersect(t *testing.T) {
12+ s := Sphere{Center: Vec3{0, 0, 0}, Radius: 1}
13+ // Head-on hit.
14+ h, ok := s.Intersect(Ray{Origin: Vec3{0, 0, 5}, Dir: Vec3{0, 0, -1}})
15+ if !ok {
16+ t.Fatal("expected a hit")
17+ }
18+ if h.Dist != 4 {
19+ t.Errorf("dist = %v, want 4", h.Dist)
20+ }
21+ if h.Point.Z != 1 {
22+ t.Errorf("point = %v, want z=1", h.Point)
23+ }
24+ // Miss.
25+ if _, ok := s.Intersect(Ray{Origin: Vec3{0, 5, 5}, Dir: Vec3{0, 0, -1}}); ok {
26+ t.Error("expected a miss")
27+ }
28+ // Ray starting inside: should still hit the far side.
29+ h, ok = s.Intersect(Ray{Origin: Vec3{0, 0, 0}, Dir: Vec3{0, 0, -1}})
30+ if !ok || h.Dist <= 0 {
31+ t.Errorf("inside hit = %+v, %v", h, ok)
32+ }
33+}
34+
35+func TestPlaneIntersect(t *testing.T) {
36+ p := NewPlaneXY(2)
37+ h, ok := p.Intersect(Ray{Origin: Vec3{0, 0, 0}, Dir: Vec3{0, 1, 0}})
38+ if !ok || h.Dist != 2 {
39+ t.Errorf("hit = %+v, %v", h, ok)
40+ }
41+ // Parallel ray misses.
42+ if _, ok := p.Intersect(Ray{Origin: Vec3{0, 0, 0}, Dir: Vec3{1, 0, 0}}); ok {
43+ t.Error("expected a miss for parallel ray")
44+ }
45+}
46+
47+func TestBoxIntersect(t *testing.T) {
48+ b := Box{Min: Vec3{-1, -1, -1}, Max: Vec3{1, 1, 1}}
49+ h, ok := b.Intersect(Ray{Origin: Vec3{0, 0, 5}, Dir: Vec3{0, 0, -1}})
50+ if !ok || h.Dist != 4 || h.Normal.Z != 1 {
51+ t.Errorf("hit = %+v, %v", h, ok)
52+ }
53+ if _, ok := b.Intersect(Ray{Origin: Vec3{5, 0, 0}, Dir: Vec3{0, 0, -1}}); ok {
54+ t.Error("expected a miss")
55+ }
56+}
57+
58+func TestGroupClosest(t *testing.T) {
59+ g := Group{Objects: []Object{
60+ Sphere{Center: Vec3{0, 0, -2}, Radius: 0.5},
61+ Sphere{Center: Vec3{0, 0, -5}, Radius: 0.5},
62+ }}
63+ h, ok := g.Intersect(Ray{Origin: Vec3{0, 0, 0}, Dir: Vec3{0, 0, -1}})
64+ if !ok || h.Dist != 1.5 {
65+ t.Errorf("expected closest hit at 1.5, got %+v, %v", h, ok)
66+ }
67+}
68+
69+func TestRefract(t *testing.T) {
70+ // Straight through: direction parallel to normal is unchanged.
71+ d, ok := refract(Vec3{0, 0, -1}, Vec3{0, 0, 1}, 1.5)
72+ if !ok || d.Z >= 0 {
73+ t.Errorf("got %+v, %v", d, ok)
74+ }
75+ // Grazing incidence: total internal reflection.
76+ if _, ok := refract(Vec3{0.999, 0, -0.04}, Vec3{0, 0, 1}, 1.5); ok {
77+ t.Error("expected total internal reflection")
78+ }
79+}
80+
81+func TestTransformMat4(t *testing.T) {
82+ // Identity leaves points and dirs alone.
83+ if p := Identity().TransformPoint(Vec3{1, 2, 3}); p != (Vec3{1, 2, 3}) {
84+ t.Errorf("identity point = %v", p)
85+ }
86+ // Translate moves a point, not a direction.
87+ m := Translate(1, -2, 3)
88+ if p := m.TransformPoint(Vec3{0, 0, 0}); p != (Vec3{1, -2, 3}) {
89+ t.Errorf("translate point = %v", p)
90+ }
91+ if d := m.TransformDir(Vec3{1, 0, 0}); d != (Vec3{1, 0, 0}) {
92+ t.Errorf("translate dir = %v", d)
93+ }
94+ // Scale stretches a direction.
95+ if d := Scale(2, 3, 4).TransformDir(Vec3{1, 1, 1}); d != (Vec3{2, 3, 4}) {
96+ t.Errorf("scale dir = %v", d)
97+ }
98+ // RotateY by pi/2 sends +Z to +X.
99+ d := RotateY(math.Pi / 2).TransformDir(Vec3{0, 0, 1})
100+ if math.Abs(d.X-1) > 1e-9 || math.Abs(d.Z) > 1e-9 {
101+ t.Errorf("rotateY dir = %v", d)
102+ }
103+ // Multiply: translate then scale (scale first, then translate).
104+ m = Translate(1, 0, 0).Multiply(Scale(2, 2, 2))
105+ if p := m.TransformPoint(Vec3{1, 0, 0}); p != (Vec3{3, 0, 0}) {
106+ t.Errorf("composed point = %v", p)
107+ }
108+ // Inverse round-trips a point; singular matrix reports failure.
109+ m = Translate(1, 2, 3).Multiply(RotateX(0.7)).Multiply(Scale(2, 3, 4))
110+ inv, ok := m.Inverse()
111+ if !ok {
112+ t.Fatal("expected an inverse")
113+ }
114+ p := Vec3{1.3, -0.4, 2.2}
115+ if rt := inv.TransformPoint(m.TransformPoint(p)); rt.Sub(p).Len() > 1e-9 {
116+ t.Errorf("round trip = %v", rt)
117+ }
118+ if _, ok := Scale(0, 1, 1).Inverse(); ok {
119+ t.Error("expected singular matrix")
120+ }
121+ // Non-uniform scale: normals go through the inverse transpose.
122+ // A sphere scaled by (2,1,1) is the ellipsoid x²/4+y²+z²=1; at the
123+ // point where the local normal is (1,1,0), the world normal is
124+ // (0.5,1,0)/|(0.5,1,0)| = (0.447, 0.894, 0).
125+ n := Scale(2, 1, 1).TransformNormal(Vec3{1, 1, 0})
126+ want := Vec3{0.5, 1, 0}.Norm()
127+ if n.Sub(want).Len() > 1e-9 {
128+ t.Errorf("scaled normal = %v, want %v", n, want)
129+ }
130+}
131+
132+func TestTransformObject(t *testing.T) {
133+ // A translated sphere: hit at the shifted point, normal preserved.
134+ s := Transform{Object: Sphere{Center: Vec3{0, 0, 0}, Radius: 1}, Matrix: Translate(0, 0, -5)}
135+ h, ok := s.Intersect(Ray{Origin: Vec3{0, 0, 0}, Dir: Vec3{0, 0, -1}})
136+ if !ok || math.Abs(h.Dist-4) > 1e-6 || h.Point.Z != -4 {
137+ t.Errorf("hit = %+v, %v", h, ok)
138+ }
139+ if h.Normal.Z != 1 {
140+ t.Errorf("normal = %v, want (0,0,1)", h.Normal)
141+ }
142+ // A box rotated 45° around Z is symmetric about the Z axis, so a
143+ // head-on -Z ray still hits the front face at the same distance.
144+ b := Transform{Object: Box{Min: Vec3{-1, -1, -1}, Max: Vec3{1, 1, 1}}, Matrix: RotateZ(math.Pi / 4)}
145+ h, ok = b.Intersect(Ray{Origin: Vec3{0, 0, 5}, Dir: Vec3{0, 0, -1}})
146+ if !ok || math.Abs(h.Dist-4) > 1e-6 {
147+ t.Errorf("rotated box hit = %+v, %v", h, ok)
148+ }
149+ // Rotate the box 45° around X. A -Z ray at y=0.5 strikes the tilted
150+ // former top face (local y=1); its world normal is RotateX(45°)·(0,1,0)
151+ // = (0, cos45, sin45).
152+ b = Transform{Object: Box{Min: Vec3{-1, -1, -1}, Max: Vec3{1, 1, 1}}, Matrix: RotateX(math.Pi / 4)}
153+ h, ok = b.Intersect(Ray{Origin: Vec3{0, 0.5, 5}, Dir: Vec3{0, 0, -1}})
154+ if !ok {
155+ t.Fatal("expected a hit on the rotated box")
156+ }
157+ if math.Abs(h.Normal.Y-math.Sqrt2/2) > 1e-6 || math.Abs(h.Normal.Z-math.Sqrt2/2) > 1e-6 {
158+ t.Errorf("rotated normal = %v, want (0, 0.707, 0.707)", h.Normal)
159+ }
160+ // Miss: a ray that misses the local object misses the transformed one.
161+ if _, ok := s.Intersect(Ray{Origin: Vec3{3, 0, 0}, Dir: Vec3{0, 0, -1}}); ok {
162+ t.Error("expected a miss")
163+ }
164+}
165+
166+func TestRayAtUV(t *testing.T) {
167+ cam := NewCamera(Vec3{0, 0, 5})
168+ // The center of an odd-sized image aims straight at the look-at point.
169+ c := cam.RayAt(5, 5, 11, 11)
170+ if math.Abs(c.Dir.X) > 1e-9 || math.Abs(c.Dir.Y) > 1e-9 || c.Dir.Z > 0 {
171+ t.Errorf("center ray = %v, want (0,0,-1)", c.Dir)
172+ }
173+ // RayAt(px, py) is the center of the pixel: same as RayAtUV(px+0.5, py+0.5).
174+ c2 := cam.RayAtUV(5.5, 5.5, 11, 11)
175+ if c.Dir.Sub(c2.Dir).Len() > 1e-12 {
176+ t.Errorf("RayAt != RayAtUV center: %v vs %v", c.Dir, c2.Dir)
177+ }
178+ // A sample in the top-left quadrant points up and left.
179+ tl := cam.RayAtUV(0.25, 0.25, 11, 11)
180+ if tl.Dir.X >= 0 || tl.Dir.Y <= 0 {
181+ t.Errorf("top-left sample = %v, want X<0, Y>0", tl.Dir)
182+ }
183+ // A sample in the bottom-right quadrant points down and right.
184+ br := cam.RayAtUV(10.75, 10.75, 11, 11)
185+ if br.Dir.X <= 0 || br.Dir.Y >= 0 {
186+ t.Errorf("bottom-right sample = %v, want X>0, Y<0", br.Dir)
187+ }
188+}
189+
190+func TestSupersampling(t *testing.T) {
191+ // A scene with a hard edge: a red sphere against a mid-gray
192+ // background. With 1 sample every pixel is pure red or pure gray;
193+ // with 2x2 samples the silhouette pixels must be a blend.
194+ build := func() *Scene {
195+ s := NewScene()
196+ s.AddObject(Sphere{Center: Vec3{0, 0, 0}, Radius: 1},
197+ Material{Diffuse: Color{1, 0, 0}, Ambient: 1})
198+ s.Background = Color{0.5, 0.5, 0.5}
199+ return s
200+ }
201+ cam := NewCamera(Vec3{0, 0, 5})
202+
203+ // Count pixels in row 20 (where the sphere's silhouette has a steep
204+ // slope, so a pixel's 2x2 sub-samples straddle the edge) whose green
205+ // channel is strictly between the sphere (pure red, G=0) and the
206+ // background (gray, G≈186 after gamma): a "mixed" pixel is an
207+ // anti-aliased edge.
208+ mixed := func(img *Image) int {
209+ var n int
210+ for x := 0; x < img.Width; x++ {
211+ i := (20*img.Width + x) * 3
212+ if img.Pix[i+1] > 5 && img.Pix[i+1] < 181 {
213+ n++
214+ }
215+ }
216+ return n
217+ }
218+
219+ r1 := Renderer{Scene: build(), Width: 64, Height: 48, Samples: 1}
220+ if m := mixed(r1.Render(cam)); m > 0 {
221+ t.Errorf("1-sample render has %d mixed pixels, want 0", m)
222+ }
223+
224+ r2 := Renderer{Scene: build(), Width: 64, Height: 48, Samples: 2}
225+ if m := mixed(r2.Render(cam)); m == 0 {
226+ t.Error("2x2 render has no mixed pixels: edge not anti-aliased")
227+ }
228+}
229+
230+func TestPathTracer(t *testing.T) {
231+ // A room: white floor, a sphere, and an emissive panel above.
232+ // Path tracing must light the floor from the panel, and the floor
233+ // behind the sphere (toward the camera) must be in shadow.
234+ build := func() *Scene {
235+ s := NewScene()
236+ s.Background = Black
237+ s.AddObject(NewPlaneXY(0), Material{Diffuse: White})
238+ s.AddObject(Sphere{Center: Vec3{0, 1, 0}, Radius: 1}, Material{Diffuse: White})
239+ // A downward-facing emissive panel.
240+ panel := Transform{
241+ Object: Box{Min: Vec3{-2, 0, -2}, Max: Vec3{2, 0.1, 2}},
242+ Matrix: Translate(0, 8, 0),
243+ }
244+ s.AddObject(panel, LightMaterial(White, 12))
245+ return s
246+ }
247+ cam := NewCamera(Vec3{0, 2, 8})
248+ p := PathTracer{Scene: build(), Width: 64, Height: 48, Samples: 8}
249+ img := p.Render(cam)
250+
251+ // The floor occupies the bottom half of the image. It must be
252+ // lit (not black) and must have a bright region (direct light from
253+ // the panel) and a dark region (the sphere's shadow).
254+ var min, max byte
255+ for y := 24; y < 48; y++ {
256+ for x := 0; x < 64; x++ {
257+ v := img.Pix[(y*64+x)*3]
258+ if x == 0 || v < min {
259+ min = v
260+ }
261+ if x == 0 || v > max {
262+ max = v
263+ }
264+ }
265+ }
266+ if max < 40 {
267+ t.Errorf("floor not lit by the panel: max = %d", max)
268+ }
269+ if float64(max) < 2*float64(min+1) {
270+ t.Errorf("no shadow visible: min = %d, max = %d", min, max)
271+ }
272+
273+ // Determinism: the same configuration must give the same image.
274+ img2 := p.Render(cam)
275+ if !bytes.Equal(img.Pix, img2.Pix) {
276+ t.Error("two renders of the same scene differ")
277+ }
278+}
279+
280+func TestRenderSmoke(t *testing.T) {
281+ s := NewScene()
282+ s.AddObject(Sphere{Center: Vec3{0, 1, 0}, Radius: 1},
283+ Material{Diffuse: Red, Specular: White, Shininess: 30, Ambient: 0.1})
284+ s.AddObject(NewPlaneXY(0), Material{Diffuse: Gray, Ambient: 0.3})
285+ s.Lights = []Light{NewLight(Vec3{4, 8, 6}, White)}
286+
287+ cam := NewCamera(Vec3{0, 2, 8})
288+ r := Renderer{Scene: s, Width: 64, Height: 48}
289+ img := r.Render(cam)
290+ if img.Width != 64 || img.Height != 48 {
291+ t.Fatalf("image size = %dx%d", img.Width, img.Height)
292+ }
293+ // The center pixel should hit the red sphere: red channel dominant.
294+ i := (24*64 + 32) * 3
295+ if img.Pix[i] < 60 {
296+ t.Errorf("center pixel too dark: %v", img.Pix[i:i+3])
297+ }
298+ if img.Pix[i+1] > img.Pix[i] || img.Pix[i+2] > img.Pix[i] {
299+ t.Errorf("center pixel not red-dominant: %v", img.Pix[i:i+3])
300+ }
301+}
302+
303+func TestSavePNG(t *testing.T) {
304+ img := NewImage(4, 3)
305+ for y := 0; y < 3; y++ {
306+ for x := 0; x < 4; x++ {
307+ img.Set(x, y, Color{float64(x) / 3, float64(y) / 2, 1})
308+ }
309+ }
310+ path := t.TempDir() + "/test.png"
311+ if err := img.SavePNG(path); err != nil {
312+ t.Fatal(err)
313+ }
314+ data, err := os.ReadFile(path)
315+ if err != nil {
316+ t.Fatal(err)
317+ }
318+ // PNG signature.
319+ sig := []byte{137, 80, 78, 71, 13, 10, 26, 10}
320+ if !bytes.Equal(data[:8], sig) {
321+ t.Fatalf("bad signature: %v", data[:8])
322+ }
323+ // IHDR must say 4x3, 8-bit truecolor.
324+ if binary.BigEndian.Uint32(data[16:20]) != 4 || binary.BigEndian.Uint32(data[20:24]) != 3 {
325+ t.Errorf("IHDR size wrong: %v", data[16:24])
326+ }
327+ if data[24] != 8 || data[25] != 2 {
328+ t.Errorf("IHDR depth/type wrong: %d %d", data[24], data[25])
329+ }
330+ // The file must end with an IEND chunk: length 0, "IEND", CRC AE426082.
331+ if !bytes.Equal(data[len(data)-8:len(data)-4], []byte("IEND")) ||
332+ !bytes.Equal(data[len(data)-4:], []byte{0xAE, 0x42, 0x60, 0x82}) ||
333+ !bytes.Equal(data[len(data)-12:len(data)-8], []byte{0, 0, 0, 0}) {
334+ t.Errorf("missing IEND trailer: %v", data[len(data)-12:])
335+ }
336+}
added demos/qwen-style/workspace/renderer.go +256 -0
new file mode 100644
@@ -0,0 +1,256 @@
1+package povray
2+
3+import (
4+ "fmt"
5+ "math"
6+ "os"
7+ "sync"
8+)
9+
10+// Scene bundles everything a render needs.
11+type Scene struct {
12+ // Objects are the geometry; pass a Group to combine several.
13+ Objects []Object
14+ // Materials are looked up by index; Objects[i] uses Materials[i].
15+ Materials []Material
16+ // Lights light the scene.
17+ Lights []Light
18+ // Background is the color of empty space.
19+ Background Color
20+ // Gamma applied at the end (2.2 is a good default).
21+ Gamma float64
22+ // MaxDepth limits reflection/refraction bounces.
23+ MaxDepth int
24+}
25+
26+// NewScene makes a scene with a white background, gamma 2.2 and 8 bounces.
27+func NewScene() *Scene {
28+ return &Scene{Background: White, Gamma: 2.2, MaxDepth: 8}
29+}
30+
31+// AddObject registers an object and its material.
32+func (s *Scene) AddObject(o Object, m Material) {
33+ s.Objects = append(s.Objects, o)
34+ s.Materials = append(s.Materials, m)
35+}
36+
37+// AddObjects registers several objects sharing one material.
38+func (s *Scene) AddObjects(o []Object, m Material) {
39+ for _, x := range o {
40+ s.AddObject(x, m)
41+ }
42+}
43+
44+// Renderer renders scenes.
45+type Renderer struct {
46+ Scene *Scene
47+ // Width and Height of the image in pixels.
48+ Width, Height int
49+ // Threads is the number of worker goroutines (0 = GOMAXPROCS).
50+ Threads int
51+ // Samples is the number of rays per pixel, arranged in a grid
52+ // (1 = no anti-aliasing; 2 means 2x2 = 4 rays, 3 means 3x3 = 9).
53+ Samples int
54+}
55+
56+// Render renders the scene from the camera and returns the image.
57+func (r *Renderer) Render(cam Camera) *Image {
58+ s := r.Scene
59+ img := NewImage(r.Width, r.Height)
60+ if s.Gamma <= 0 {
61+ s.Gamma = 2.2
62+ }
63+ if s.MaxDepth <= 0 {
64+ s.MaxDepth = 8
65+ }
66+ threads := r.Threads
67+ if threads <= 0 {
68+ threads = 4
69+ }
70+ samples := r.Samples
71+ if samples < 1 {
72+ samples = 1
73+ }
74+
75+ var wg sync.WaitGroup
76+ chunks := threads
77+ rowsPer := (r.Height + chunks - 1) / chunks
78+ for c := 0; c < chunks; c++ {
79+ y0 := c * rowsPer
80+ y1 := y0 + rowsPer
81+ if y1 > r.Height {
82+ y1 = r.Height
83+ }
84+ if y0 >= y1 {
85+ break
86+ }
87+ wg.Add(1)
88+ go func(y0, y1 int) {
89+ defer wg.Done()
90+ for y := y0; y < y1; y++ {
91+ for x := 0; x < r.Width; x++ {
92+ var col Color
93+ for sy := 0; sy < samples; sy++ {
94+ for sx := 0; sx < samples; sx++ {
95+ u := float64(x) + (float64(sx)+0.5)/float64(samples)
96+ v := float64(y) + (float64(sy)+0.5)/float64(samples)
97+ col = col.Add(r.trace(cam.RayAtUV(u, v, r.Width, r.Height), 0))
98+ }
99+ }
100+ img.Set(x, y, col.Scale(1.0/float64(samples*samples)))
101+ }
102+ }
103+ }(y0, y1)
104+ }
105+ wg.Wait()
106+ return img
107+}
108+
109+// trace follows a ray until it leaves the scene or the bounce budget.
110+func (r *Renderer) trace(ray Ray, depth int) Color {
111+ s := r.Scene
112+ if depth > s.MaxDepth {
113+ return Black
114+ }
115+
116+ idx, hit := closestHit(s.Objects, ray)
117+ if idx < 0 {
118+ return s.Background
119+ }
120+ hit.Flip(ray)
121+ mat := s.Materials[idx]
122+
123+ col := r.shade(ray, hit, mat, depth)
124+
125+ // Reflection: mirror the ray, keep the surface color as a tint.
126+ if mat.Reflectivity > 0 {
127+ reflect := ray.Dir.Sub(hit.Normal.Scale(2 * ray.Dir.Dot(hit.Normal))).Norm()
128+ rr := Ray{Origin: hit.Point.Add(hit.Normal.Scale(1e-4)), Dir: reflect}
129+ col = col.Add(r.trace(rr, depth+1).Mul(mat.Diffuse).Scale(mat.Reflectivity))
130+ }
131+
132+ // Refraction: bend the ray through the surface.
133+ if mat.Transparency > 0 {
134+ ior := 1.0
135+ if mat.IOR > 0 {
136+ ior = mat.IOR
137+ }
138+ refr, ok := refract(ray.Dir, hit.Normal, 1/ior)
139+ if ok {
140+ tr := Ray{Origin: hit.Point.Add(hit.Normal.Scale(1e-4)), Dir: refr}
141+ col = col.Add(r.trace(tr, depth+1).Scale(mat.Transparency))
142+ }
143+ }
144+ return col
145+}
146+
147+// shade computes ambient + diffuse + specular with shadows for one hit.
148+func (r *Renderer) shade(ray Ray, hit Hit, mat Material, depth int) Color {
149+ s := r.Scene
150+ col := mat.Diffuse.Scale(mat.Ambient)
151+ for _, l := range s.Lights {
152+ toLight := l.Position.Sub(hit.Point)
153+ dist := toLight.Len()
154+ if dist < 1e-6 {
155+ continue
156+ }
157+ dir := toLight.Scale(1 / dist)
158+ // Shadow: is the light occluded?
159+ if occluded(s.Objects, s.Materials, Ray{Origin: hit.Point.Add(hit.Normal.Scale(1e-4)), Dir: dir}, dist) {
160+ continue
161+ }
162+ // Inverse-square falloff, clamped so close lights don't blow out.
163+ fall := 1.0 / math.Max(1.0, dist*dist*0.1)
164+ lc := l.Color.Scale(l.Intensity * fall)
165+
166+ diff := math.Max(0, hit.Normal.Dot(dir))
167+ col = col.Add(mat.Diffuse.Mul(lc).Scale(diff))
168+
169+ if mat.Shininess > 0 && diff > 0 {
170+ half := dir.Add(ray.Dir.Neg()).Norm()
171+ spec := math.Max(0, hit.Normal.Dot(half))
172+ col = col.Add(mat.Specular.Mul(lc).Scale(math.Pow(spec, mat.Shininess)))
173+ }
174+ }
175+ return col
176+}
177+
178+// closestHit finds the nearest object hit for the ray.
179+func closestHit(objects []Object, ray Ray) (int, Hit) {
180+ bestIdx, best := -1, Hit{}
181+ for i, o := range objects {
182+ if h, ok := o.Intersect(ray); ok && (bestIdx < 0 || h.Dist < best.Dist) {
183+ bestIdx, best = i, h
184+ }
185+ }
186+ return bestIdx, best
187+}
188+
189+// occluded reports whether an opaque object is hit before maxDist.
190+func occluded(objects []Object, mats []Material, ray Ray, maxDist float64) bool {
191+ for i, o := range objects {
192+ if mats[i].Transparency > 0.5 {
193+ continue
194+ }
195+ if h, ok := o.Intersect(ray); ok && h.Dist < maxDist {
196+ return true
197+ }
198+ }
199+ return false
200+}
201+
202+// refract bends dir across a surface with normal n and ratio ior
203+// (1/ior when exiting the medium). Returns (direction, ok).
204+func refract(dir, n Vec3, ior float64) (Vec3, bool) {
205+ cosI := -dir.Dot(n)
206+ sin2T := ior * ior * (1 - cosI*cosI)
207+ if sin2T > 1 {
208+ return Vec3{}, false // total internal reflection
209+ }
210+ cosT := math.Sqrt(1 - sin2T)
211+ return dir.Scale(ior).Sub(n.Scale(ior*cosT - cosI)).Norm(), true
212+}
213+
214+// Image is a plain RGB image.
215+type Image struct {
216+ Width, Height int
217+ Pix []byte // RGB, row-major, 3 bytes per pixel
218+}
219+
220+// NewImage allocates a black image.
221+func NewImage(w, h int) *Image {
222+ return &Image{Width: w, Height: h, Pix: make([]byte, w*h*3)}
223+}
224+
225+// Set stores a color at (x, y) with gamma correction and clipping.
226+func (img *Image) Set(x, y int, c Color) {
227+ i := (y*img.Width + x) * 3
228+ img.Pix[i] = toByte(c.R)
229+ img.Pix[i+1] = toByte(c.G)
230+ img.Pix[i+2] = toByte(c.B)
231+}
232+
233+func toByte(v float64) byte {
234+ if v < 0 {
235+ v = 0
236+ }
237+ if v > 1 {
238+ v = 1
239+ }
240+ return byte(255 * math.Pow(v, 1/2.2))
241+}
242+
243+// SavePPM writes the image as a binary PPM (P6) file.
244+func (img *Image) SavePPM(path string) error {
245+ f, err := os.Create(path)
246+ if err != nil {
247+ return err
248+ }
249+ defer f.Close()
250+ _, err = fmt.Fprintf(f, "P6\n%d %d\n255\n", img.Width, img.Height)
251+ if err != nil {
252+ return err
253+ }
254+ _, err = f.Write(img.Pix)
255+ return err
256+}
new file mode 100644
@@ -0,0 +1,256 @@
1+package povray
2+
3+import (
4+ "fmt"
5+ "math"
6+ "os"
7+ "sync"
8+)
9+
10+// Scene bundles everything a render needs.
11+type Scene struct {
12+ // Objects are the geometry; pass a Group to combine several.
13+ Objects []Object
14+ // Materials are looked up by index; Objects[i] uses Materials[i].
15+ Materials []Material
16+ // Lights light the scene.
17+ Lights []Light
18+ // Background is the color of empty space.
19+ Background Color
20+ // Gamma applied at the end (2.2 is a good default).
21+ Gamma float64
22+ // MaxDepth limits reflection/refraction bounces.
23+ MaxDepth int
24+}
25+
26+// NewScene makes a scene with a white background, gamma 2.2 and 8 bounces.
27+func NewScene() *Scene {
28+ return &Scene{Background: White, Gamma: 2.2, MaxDepth: 8}
29+}
30+
31+// AddObject registers an object and its material.
32+func (s *Scene) AddObject(o Object, m Material) {
33+ s.Objects = append(s.Objects, o)
34+ s.Materials = append(s.Materials, m)
35+}
36+
37+// AddObjects registers several objects sharing one material.
38+func (s *Scene) AddObjects(o []Object, m Material) {
39+ for _, x := range o {
40+ s.AddObject(x, m)
41+ }
42+}
43+
44+// Renderer renders scenes.
45+type Renderer struct {
46+ Scene *Scene
47+ // Width and Height of the image in pixels.
48+ Width, Height int
49+ // Threads is the number of worker goroutines (0 = GOMAXPROCS).
50+ Threads int
51+ // Samples is the number of rays per pixel, arranged in a grid
52+ // (1 = no anti-aliasing; 2 means 2x2 = 4 rays, 3 means 3x3 = 9).
53+ Samples int
54+}
55+
56+// Render renders the scene from the camera and returns the image.
57+func (r *Renderer) Render(cam Camera) *Image {
58+ s := r.Scene
59+ img := NewImage(r.Width, r.Height)
60+ if s.Gamma <= 0 {
61+ s.Gamma = 2.2
62+ }
63+ if s.MaxDepth <= 0 {
64+ s.MaxDepth = 8
65+ }
66+ threads := r.Threads
67+ if threads <= 0 {
68+ threads = 4
69+ }
70+ samples := r.Samples
71+ if samples < 1 {
72+ samples = 1
73+ }
74+
75+ var wg sync.WaitGroup
76+ chunks := threads
77+ rowsPer := (r.Height + chunks - 1) / chunks
78+ for c := 0; c < chunks; c++ {
79+ y0 := c * rowsPer
80+ y1 := y0 + rowsPer
81+ if y1 > r.Height {
82+ y1 = r.Height
83+ }
84+ if y0 >= y1 {
85+ break
86+ }
87+ wg.Add(1)
88+ go func(y0, y1 int) {
89+ defer wg.Done()
90+ for y := y0; y < y1; y++ {
91+ for x := 0; x < r.Width; x++ {
92+ var col Color
93+ for sy := 0; sy < samples; sy++ {
94+ for sx := 0; sx < samples; sx++ {
95+ u := float64(x) + (float64(sx)+0.5)/float64(samples)
96+ v := float64(y) + (float64(sy)+0.5)/float64(samples)
97+ col = col.Add(r.trace(cam.RayAtUV(u, v, r.Width, r.Height), 0))
98+ }
99+ }
100+ img.Set(x, y, col.Scale(1.0/float64(samples*samples)))
101+ }
102+ }
103+ }(y0, y1)
104+ }
105+ wg.Wait()
106+ return img
107+}
108+
109+// trace follows a ray until it leaves the scene or the bounce budget.
110+func (r *Renderer) trace(ray Ray, depth int) Color {
111+ s := r.Scene
112+ if depth > s.MaxDepth {
113+ return Black
114+ }
115+
116+ idx, hit := closestHit(s.Objects, ray)
117+ if idx < 0 {
118+ return s.Background
119+ }
120+ hit.Flip(ray)
121+ mat := s.Materials[idx]
122+
123+ col := r.shade(ray, hit, mat, depth)
124+
125+ // Reflection: mirror the ray, keep the surface color as a tint.
126+ if mat.Reflectivity > 0 {
127+ reflect := ray.Dir.Sub(hit.Normal.Scale(2 * ray.Dir.Dot(hit.Normal))).Norm()
128+ rr := Ray{Origin: hit.Point.Add(hit.Normal.Scale(1e-4)), Dir: reflect}
129+ col = col.Add(r.trace(rr, depth+1).Mul(mat.Diffuse).Scale(mat.Reflectivity))
130+ }
131+
132+ // Refraction: bend the ray through the surface.
133+ if mat.Transparency > 0 {
134+ ior := 1.0
135+ if mat.IOR > 0 {
136+ ior = mat.IOR
137+ }
138+ refr, ok := refract(ray.Dir, hit.Normal, 1/ior)
139+ if ok {
140+ tr := Ray{Origin: hit.Point.Add(hit.Normal.Scale(1e-4)), Dir: refr}
141+ col = col.Add(r.trace(tr, depth+1).Scale(mat.Transparency))
142+ }
143+ }
144+ return col
145+}
146+
147+// shade computes ambient + diffuse + specular with shadows for one hit.
148+func (r *Renderer) shade(ray Ray, hit Hit, mat Material, depth int) Color {
149+ s := r.Scene
150+ col := mat.Diffuse.Scale(mat.Ambient)
151+ for _, l := range s.Lights {
152+ toLight := l.Position.Sub(hit.Point)
153+ dist := toLight.Len()
154+ if dist < 1e-6 {
155+ continue
156+ }
157+ dir := toLight.Scale(1 / dist)
158+ // Shadow: is the light occluded?
159+ if occluded(s.Objects, s.Materials, Ray{Origin: hit.Point.Add(hit.Normal.Scale(1e-4)), Dir: dir}, dist) {
160+ continue
161+ }
162+ // Inverse-square falloff, clamped so close lights don't blow out.
163+ fall := 1.0 / math.Max(1.0, dist*dist*0.1)
164+ lc := l.Color.Scale(l.Intensity * fall)
165+
166+ diff := math.Max(0, hit.Normal.Dot(dir))
167+ col = col.Add(mat.Diffuse.Mul(lc).Scale(diff))
168+
169+ if mat.Shininess > 0 && diff > 0 {
170+ half := dir.Add(ray.Dir.Neg()).Norm()
171+ spec := math.Max(0, hit.Normal.Dot(half))
172+ col = col.Add(mat.Specular.Mul(lc).Scale(math.Pow(spec, mat.Shininess)))
173+ }
174+ }
175+ return col
176+}
177+
178+// closestHit finds the nearest object hit for the ray.
179+func closestHit(objects []Object, ray Ray) (int, Hit) {
180+ bestIdx, best := -1, Hit{}
181+ for i, o := range objects {
182+ if h, ok := o.Intersect(ray); ok && (bestIdx < 0 || h.Dist < best.Dist) {
183+ bestIdx, best = i, h
184+ }
185+ }
186+ return bestIdx, best
187+}
188+
189+// occluded reports whether an opaque object is hit before maxDist.
190+func occluded(objects []Object, mats []Material, ray Ray, maxDist float64) bool {
191+ for i, o := range objects {
192+ if mats[i].Transparency > 0.5 {
193+ continue
194+ }
195+ if h, ok := o.Intersect(ray); ok && h.Dist < maxDist {
196+ return true
197+ }
198+ }
199+ return false
200+}
201+
202+// refract bends dir across a surface with normal n and ratio ior
203+// (1/ior when exiting the medium). Returns (direction, ok).
204+func refract(dir, n Vec3, ior float64) (Vec3, bool) {
205+ cosI := -dir.Dot(n)
206+ sin2T := ior * ior * (1 - cosI*cosI)
207+ if sin2T > 1 {
208+ return Vec3{}, false // total internal reflection
209+ }
210+ cosT := math.Sqrt(1 - sin2T)
211+ return dir.Scale(ior).Sub(n.Scale(ior*cosT - cosI)).Norm(), true
212+}
213+
214+// Image is a plain RGB image.
215+type Image struct {
216+ Width, Height int
217+ Pix []byte // RGB, row-major, 3 bytes per pixel
218+}
219+
220+// NewImage allocates a black image.
221+func NewImage(w, h int) *Image {
222+ return &Image{Width: w, Height: h, Pix: make([]byte, w*h*3)}
223+}
224+
225+// Set stores a color at (x, y) with gamma correction and clipping.
226+func (img *Image) Set(x, y int, c Color) {
227+ i := (y*img.Width + x) * 3
228+ img.Pix[i] = toByte(c.R)
229+ img.Pix[i+1] = toByte(c.G)
230+ img.Pix[i+2] = toByte(c.B)
231+}
232+
233+func toByte(v float64) byte {
234+ if v < 0 {
235+ v = 0
236+ }
237+ if v > 1 {
238+ v = 1
239+ }
240+ return byte(255 * math.Pow(v, 1/2.2))
241+}
242+
243+// SavePPM writes the image as a binary PPM (P6) file.
244+func (img *Image) SavePPM(path string) error {
245+ f, err := os.Create(path)
246+ if err != nil {
247+ return err
248+ }
249+ defer f.Close()
250+ _, err = fmt.Fprintf(f, "P6\n%d %d\n255\n", img.Width, img.Height)
251+ if err != nil {
252+ return err
253+ }
254+ _, err = f.Write(img.Pix)
255+ return err
256+}
added demos/qwen-style/workspace/transform.go +181 -0
new file mode 100644
@@ -0,0 +1,181 @@
1+package povray
2+
3+import "math"
4+
5+// Mat4 is a 4x4 transformation matrix, row-major (m[row][col]).
6+// It maps a world point p to m.TransformPoint(p).
7+type Mat4 [4][4]float64
8+
9+// Identity returns the 4x4 identity matrix.
10+func Identity() Mat4 {
11+ var m Mat4
12+ for i := range m {
13+ m[i][i] = 1
14+ }
15+ return m
16+}
17+
18+// Multiply returns the product m * o, i.e. a transformation that
19+// applies o first, then m.
20+func (m Mat4) Multiply(o Mat4) Mat4 {
21+ var r Mat4
22+ for i := 0; i < 4; i++ {
23+ for j := 0; j < 4; j++ {
24+ var s float64
25+ for k := 0; k < 4; k++ {
26+ s += m[i][k] * o[k][j]
27+ }
28+ r[i][j] = s
29+ }
30+ }
31+ return r
32+}
33+
34+// Translate returns a translation matrix.
35+func Translate(x, y, z float64) Mat4 {
36+ m := Identity()
37+ m[0][3], m[1][3], m[2][3] = x, y, z
38+ return m
39+}
40+
41+// Scale returns a (possibly non-uniform) scale matrix.
42+func Scale(x, y, z float64) Mat4 {
43+ m := Identity()
44+ m[0][0], m[1][1], m[2][2] = x, y, z
45+ return m
46+}
47+
48+// RotateX returns a rotation of angle a (radians) around the X axis.
49+func RotateX(a float64) Mat4 {
50+ c, s := math.Cos(a), math.Sin(a)
51+ return Mat4{
52+ {1, 0, 0, 0},
53+ {0, c, -s, 0},
54+ {0, s, c, 0},
55+ {0, 0, 0, 1},
56+ }
57+}
58+
59+// RotateY returns a rotation of angle a (radians) around the Y axis.
60+func RotateY(a float64) Mat4 {
61+ c, s := math.Cos(a), math.Sin(a)
62+ return Mat4{
63+ {c, 0, s, 0},
64+ {0, 1, 0, 0},
65+ {-s, 0, c, 0},
66+ {0, 0, 0, 1},
67+ }
68+}
69+
70+// RotateZ returns a rotation of angle a (radians) around the Z axis.
71+func RotateZ(a float64) Mat4 {
72+ c, s := math.Cos(a), math.Sin(a)
73+ return Mat4{
74+ {c, -s, 0, 0},
75+ {s, c, 0, 0},
76+ {0, 0, 1, 0},
77+ {0, 0, 0, 1},
78+ }
79+}
80+
81+// TransformPoint applies the matrix to a point (translation included).
82+func (m Mat4) TransformPoint(p Vec3) Vec3 {
83+ return Vec3{
84+ m[0][0]*p.X + m[0][1]*p.Y + m[0][2]*p.Z + m[0][3],
85+ m[1][0]*p.X + m[1][1]*p.Y + m[1][2]*p.Z + m[1][3],
86+ m[2][0]*p.X + m[2][1]*p.Y + m[2][2]*p.Z + m[2][3],
87+ }
88+}
89+
90+// TransformDir applies the matrix to a direction (no translation).
91+// The result is not normalized; the intersection math does not require it.
92+func (m Mat4) TransformDir(d Vec3) Vec3 {
93+ return Vec3{
94+ m[0][0]*d.X + m[0][1]*d.Y + m[0][2]*d.Z,
95+ m[1][0]*d.X + m[1][1]*d.Y + m[1][2]*d.Z,
96+ m[2][0]*d.X + m[2][1]*d.Y + m[2][2]*d.Z,
97+ }
98+}
99+
100+// Inverse returns the inverse matrix. ok is false if the matrix is
101+// singular.
102+func (m Mat4) Inverse() (Mat4, bool) {
103+ // Gauss-Jordan on the augmented system [m | I].
104+ a := make([][]float64, 4)
105+ for i := range a {
106+ a[i] = make([]float64, 8)
107+ copy(a[i][:4], m[i][:])
108+ a[i][4+i] = 1
109+ }
110+ for col := 0; col < 4; col++ {
111+ piv := col
112+ for r := col + 1; r < 4; r++ {
113+ if math.Abs(a[r][col]) > math.Abs(a[piv][col]) {
114+ piv = r
115+ }
116+ }
117+ if math.Abs(a[piv][col]) < 1e-12 {
118+ return Mat4{}, false
119+ }
120+ a[col], a[piv] = a[piv], a[col]
121+ pv := a[col][col]
122+ for c := col; c < 8; c++ {
123+ a[col][c] /= pv
124+ }
125+ for r := 0; r < 4; r++ {
126+ if r == col || a[r][col] == 0 {
127+ continue
128+ }
129+ f := a[r][col]
130+ for c := col; c < 8; c++ {
131+ a[r][c] -= f * a[col][c]
132+ }
133+ }
134+ }
135+ var inv Mat4
136+ for i := 0; i < 4; i++ {
137+ copy(inv[i][:], a[i][4:])
138+ }
139+ return inv, true
140+}
141+
142+// TransformNormal maps a surface normal into world space using the
143+// inverse-transpose of the linear part (correct under non-uniform
144+// scale), and returns it normalized.
145+func (m Mat4) TransformNormal(n Vec3) Vec3 {
146+ inv, ok := m.Inverse()
147+ if !ok {
148+ return n
149+ }
150+ return Vec3{
151+ inv[0][0]*n.X + inv[1][0]*n.Y + inv[2][0]*n.Z,
152+ inv[0][1]*n.X + inv[1][1]*n.Y + inv[2][1]*n.Z,
153+ inv[0][2]*n.X + inv[1][2]*n.Y + inv[2][2]*n.Z,
154+ }.Norm()
155+}
156+
157+// Transform is an object wrapped in a 4x4 matrix: the ray is mapped
158+// into the object's local space, intersected there, and the hit is
159+// mapped back to world space.
160+type Transform struct {
161+ Object Object
162+ Matrix Mat4
163+}
164+
165+func (t Transform) Intersect(r Ray) (Hit, bool) {
166+ inv, ok := t.Matrix.Inverse()
167+ if !ok {
168+ return Hit{}, false
169+ }
170+ lr := Ray{Origin: inv.TransformPoint(r.Origin), Dir: inv.TransformDir(r.Dir)}
171+ h, ok := t.Object.Intersect(lr)
172+ if !ok {
173+ return Hit{}, false
174+ }
175+ p := t.Matrix.TransformPoint(h.Point)
176+ return Hit{
177+ Point: p,
178+ Dist: p.Sub(r.Origin).Len(),
179+ Normal: t.Matrix.TransformNormal(h.Normal),
180+ }, true
181+}
new file mode 100644
@@ -0,0 +1,181 @@
1+package povray
2+
3+import "math"
4+
5+// Mat4 is a 4x4 transformation matrix, row-major (m[row][col]).
6+// It maps a world point p to m.TransformPoint(p).
7+type Mat4 [4][4]float64
8+
9+// Identity returns the 4x4 identity matrix.
10+func Identity() Mat4 {
11+ var m Mat4
12+ for i := range m {
13+ m[i][i] = 1
14+ }
15+ return m
16+}
17+
18+// Multiply returns the product m * o, i.e. a transformation that
19+// applies o first, then m.
20+func (m Mat4) Multiply(o Mat4) Mat4 {
21+ var r Mat4
22+ for i := 0; i < 4; i++ {
23+ for j := 0; j < 4; j++ {
24+ var s float64
25+ for k := 0; k < 4; k++ {
26+ s += m[i][k] * o[k][j]
27+ }
28+ r[i][j] = s
29+ }
30+ }
31+ return r
32+}
33+
34+// Translate returns a translation matrix.
35+func Translate(x, y, z float64) Mat4 {
36+ m := Identity()
37+ m[0][3], m[1][3], m[2][3] = x, y, z
38+ return m
39+}
40+
41+// Scale returns a (possibly non-uniform) scale matrix.
42+func Scale(x, y, z float64) Mat4 {
43+ m := Identity()
44+ m[0][0], m[1][1], m[2][2] = x, y, z
45+ return m
46+}
47+
48+// RotateX returns a rotation of angle a (radians) around the X axis.
49+func RotateX(a float64) Mat4 {
50+ c, s := math.Cos(a), math.Sin(a)
51+ return Mat4{
52+ {1, 0, 0, 0},
53+ {0, c, -s, 0},
54+ {0, s, c, 0},
55+ {0, 0, 0, 1},
56+ }
57+}
58+
59+// RotateY returns a rotation of angle a (radians) around the Y axis.
60+func RotateY(a float64) Mat4 {
61+ c, s := math.Cos(a), math.Sin(a)
62+ return Mat4{
63+ {c, 0, s, 0},
64+ {0, 1, 0, 0},
65+ {-s, 0, c, 0},
66+ {0, 0, 0, 1},
67+ }
68+}
69+
70+// RotateZ returns a rotation of angle a (radians) around the Z axis.
71+func RotateZ(a float64) Mat4 {
72+ c, s := math.Cos(a), math.Sin(a)
73+ return Mat4{
74+ {c, -s, 0, 0},
75+ {s, c, 0, 0},
76+ {0, 0, 1, 0},
77+ {0, 0, 0, 1},
78+ }
79+}
80+
81+// TransformPoint applies the matrix to a point (translation included).
82+func (m Mat4) TransformPoint(p Vec3) Vec3 {
83+ return Vec3{
84+ m[0][0]*p.X + m[0][1]*p.Y + m[0][2]*p.Z + m[0][3],
85+ m[1][0]*p.X + m[1][1]*p.Y + m[1][2]*p.Z + m[1][3],
86+ m[2][0]*p.X + m[2][1]*p.Y + m[2][2]*p.Z + m[2][3],
87+ }
88+}
89+
90+// TransformDir applies the matrix to a direction (no translation).
91+// The result is not normalized; the intersection math does not require it.
92+func (m Mat4) TransformDir(d Vec3) Vec3 {
93+ return Vec3{
94+ m[0][0]*d.X + m[0][1]*d.Y + m[0][2]*d.Z,
95+ m[1][0]*d.X + m[1][1]*d.Y + m[1][2]*d.Z,
96+ m[2][0]*d.X + m[2][1]*d.Y + m[2][2]*d.Z,
97+ }
98+}
99+
100+// Inverse returns the inverse matrix. ok is false if the matrix is
101+// singular.
102+func (m Mat4) Inverse() (Mat4, bool) {
103+ // Gauss-Jordan on the augmented system [m | I].
104+ a := make([][]float64, 4)
105+ for i := range a {
106+ a[i] = make([]float64, 8)
107+ copy(a[i][:4], m[i][:])
108+ a[i][4+i] = 1
109+ }
110+ for col := 0; col < 4; col++ {
111+ piv := col
112+ for r := col + 1; r < 4; r++ {
113+ if math.Abs(a[r][col]) > math.Abs(a[piv][col]) {
114+ piv = r
115+ }
116+ }
117+ if math.Abs(a[piv][col]) < 1e-12 {
118+ return Mat4{}, false
119+ }
120+ a[col], a[piv] = a[piv], a[col]
121+ pv := a[col][col]
122+ for c := col; c < 8; c++ {
123+ a[col][c] /= pv
124+ }
125+ for r := 0; r < 4; r++ {
126+ if r == col || a[r][col] == 0 {
127+ continue
128+ }
129+ f := a[r][col]
130+ for c := col; c < 8; c++ {
131+ a[r][c] -= f * a[col][c]
132+ }
133+ }
134+ }
135+ var inv Mat4
136+ for i := 0; i < 4; i++ {
137+ copy(inv[i][:], a[i][4:])
138+ }
139+ return inv, true
140+}
141+
142+// TransformNormal maps a surface normal into world space using the
143+// inverse-transpose of the linear part (correct under non-uniform
144+// scale), and returns it normalized.
145+func (m Mat4) TransformNormal(n Vec3) Vec3 {
146+ inv, ok := m.Inverse()
147+ if !ok {
148+ return n
149+ }
150+ return Vec3{
151+ inv[0][0]*n.X + inv[1][0]*n.Y + inv[2][0]*n.Z,
152+ inv[0][1]*n.X + inv[1][1]*n.Y + inv[2][1]*n.Z,
153+ inv[0][2]*n.X + inv[1][2]*n.Y + inv[2][2]*n.Z,
154+ }.Norm()
155+}
156+
157+// Transform is an object wrapped in a 4x4 matrix: the ray is mapped
158+// into the object's local space, intersected there, and the hit is
159+// mapped back to world space.
160+type Transform struct {
161+ Object Object
162+ Matrix Mat4
163+}
164+
165+func (t Transform) Intersect(r Ray) (Hit, bool) {
166+ inv, ok := t.Matrix.Inverse()
167+ if !ok {
168+ return Hit{}, false
169+ }
170+ lr := Ray{Origin: inv.TransformPoint(r.Origin), Dir: inv.TransformDir(r.Dir)}
171+ h, ok := t.Object.Intersect(lr)
172+ if !ok {
173+ return Hit{}, false
174+ }
175+ p := t.Matrix.TransformPoint(h.Point)
176+ return Hit{
177+ Point: p,
178+ Dist: p.Sub(r.Origin).Len(),
179+ Normal: t.Matrix.TransformNormal(h.Normal),
180+ }, true
181+}
added demos/qwen-style/workspace/vector.go +54 -0
new file mode 100644
@@ -0,0 +1,54 @@
1+package povray
2+
3+import "math"
4+
5+// Vec3 is a 3D vector.
6+type Vec3 struct {
7+ X, Y, Z float64
8+}
9+
10+func (v Vec3) Add(o Vec3) Vec3 { return Vec3{v.X + o.X, v.Y + o.Y, v.Z + o.Z} }
11+func (v Vec3) Sub(o Vec3) Vec3 { return Vec3{v.X - o.X, v.Y - o.Y, v.Z - o.Z} }
12+func (v Vec3) Scale(s float64) Vec3 {
13+ return Vec3{v.X * s, v.Y * s, v.Z * s}
14+}
15+func (v Vec3) Mul(o Vec3) Vec3 { return Vec3{v.X * o.X, v.Y * o.Y, v.Z * o.Z} }
16+
17+func (v Vec3) Dot(o Vec3) float64 { return v.X*o.X + v.Y*o.Y + v.Z*o.Z }
18+func (v Vec3) Cross(o Vec3) Vec3 {
19+ return Vec3{
20+ v.Y*o.Z - v.Z*o.Y,
21+ v.Z*o.X - v.X*o.Z,
22+ v.X*o.Y - v.Y*o.X,
23+ }
24+}
25+
26+func (v Vec3) Len() float64 { return math.Sqrt(v.Dot(v)) }
27+func (v Vec3) Norm() Vec3 {
28+ l := v.Len()
29+ if l == 0 {
30+ return Vec3{}
31+ }
32+ return v.Scale(1 / l)
33+}
34+func (v Vec3) Neg() Vec3 { return Vec3{-v.X, -v.Y, -v.Z} }
35+
36+// Ray is a point and a (usually normalized) direction.
37+type Ray struct {
38+ Origin, Dir Vec3
39+}
40+
41+// Hit describes where a ray met a surface.
42+type Hit struct {
43+ Point Vec3
44+ Dist float64
45+ Normal Vec3
46+}
47+
48+// Flip turns the normal toward the ray origin if needed, so lighting
49+// always sees the face the ray struck.
50+func (h *Hit) Flip(r Ray) {
51+ if h.Normal.Dot(r.Dir) > 0 {
52+ h.Normal = h.Normal.Neg()
53+ }
54+}
new file mode 100644
@@ -0,0 +1,54 @@
1+package povray
2+
3+import "math"
4+
5+// Vec3 is a 3D vector.
6+type Vec3 struct {
7+ X, Y, Z float64
8+}
9+
10+func (v Vec3) Add(o Vec3) Vec3 { return Vec3{v.X + o.X, v.Y + o.Y, v.Z + o.Z} }
11+func (v Vec3) Sub(o Vec3) Vec3 { return Vec3{v.X - o.X, v.Y - o.Y, v.Z - o.Z} }
12+func (v Vec3) Scale(s float64) Vec3 {
13+ return Vec3{v.X * s, v.Y * s, v.Z * s}
14+}
15+func (v Vec3) Mul(o Vec3) Vec3 { return Vec3{v.X * o.X, v.Y * o.Y, v.Z * o.Z} }
16+
17+func (v Vec3) Dot(o Vec3) float64 { return v.X*o.X + v.Y*o.Y + v.Z*o.Z }
18+func (v Vec3) Cross(o Vec3) Vec3 {
19+ return Vec3{
20+ v.Y*o.Z - v.Z*o.Y,
21+ v.Z*o.X - v.X*o.Z,
22+ v.X*o.Y - v.Y*o.X,
23+ }
24+}
25+
26+func (v Vec3) Len() float64 { return math.Sqrt(v.Dot(v)) }
27+func (v Vec3) Norm() Vec3 {
28+ l := v.Len()
29+ if l == 0 {
30+ return Vec3{}
31+ }
32+ return v.Scale(1 / l)
33+}
34+func (v Vec3) Neg() Vec3 { return Vec3{-v.X, -v.Y, -v.Z} }
35+
36+// Ray is a point and a (usually normalized) direction.
37+type Ray struct {
38+ Origin, Dir Vec3
39+}
40+
41+// Hit describes where a ray met a surface.
42+type Hit struct {
43+ Point Vec3
44+ Dist float64
45+ Normal Vec3
46+}
47+
48+// Flip turns the normal toward the ray origin if needed, so lighting
49+// always sees the face the ray struck.
50+func (h *Hit) Flip(r Ray) {
51+ if h.Normal.Dot(r.Dir) > 0 {
52+ h.Normal = h.Normal.Neg()
53+ }
54+}
added demos/smollm3-demo/.gitignore +1 -0
new file mode 100644
@@ -0,0 +1 @@
1+!.mm
new file mode 100644
@@ -0,0 +1 @@
1+!.mm
added demos/smollm3-demo/.mm/agent.yaml +124 -0
new file mode 100644
@@ -0,0 +1,124 @@
1+# I'm Smollm3 — a coding agent: reads and changes the project, runs commands.
2+#
3+# Written by `mm init`. Every value says why it has that value; change
4+# them as you watch the agent work:
5+# https://rickub.com/bots-garden/mini-me/blob/main/guides/agent-settings-for-small-models.md
6+#
7+# model family: smollm3
8+# usage: developer
9+# machine: small — a laptop, models up to about 8B
10+# model facts: read from llmman: native window 65536 tokens; reasoning switch enable_thinking
11+#
12+# Where the numbers come from (the tag before their comment):
13+# [card] from the model card or the GGUF, not measured — re-read the new card
14+# [guide] the settings guide's row for this machine
15+# [rule] a rule of thumb, not specific to this model
16+#
17+# mm or mm tui started from the project root (it finds .mm/agent.yaml)
18+
19+provider: llmman
20+# The model. Chosen for it (family smollm3): sampling (temperature, top_p,
21+# max_tokens) and sampling.chat_template_kwargs (enable_thinking). Change the
22+# model and redo them together — running mm init again is the quick way.
23+model: "hf.co/unsloth/SmolLM3-3B-GGUF:Q4_K_M"
24+# No baseUrl: llmman's defaults apply. For the record:
25+# baseUrl: "http://127.0.0.1:17434/v1"
26+# fallback: "http://host.docker.internal:17434/v1" # tried when baseUrl does not answer
27+
28+# 0 = mm asks the server for the window it serves (the model's native window
29+# is 65536 tokens; the server may serve less)
30+contextWindow: 0
31+# your answer: every command and file change waits for your answer
32+yolo: false
33+# usage developer
34+bashTool: true
35+editTools: true
36+planTool: true
37+askUserTool: true
38+# family smollm3: its chat template reads the tools from xml_tools and writes
39+# its calls as <tool_call> text — mm sends them there and reads the calls
40+# back
41+toolFormat: xml_tools
42+# no skills: add a .mm/skills/ folder and set skillsDir: .mm/skills to use
43+# some (see
44+# https://rickub.com/bots-garden/mini-me/blob/main/docs/en/how-to/add-a-skill.md)
45+skillsDir: no-skills
46+# usage developer
47+maxTurns: 40
48+# [guide] settings guide, row "Small model (≤ 8B), short tasks"
49+maxOutput: 8000
50+firstTokenTimeout: 3m
51+# [guide] aligned on bashTimeout, as the settings guide says when bash is on
52+# (section 3)
53+watchdogTimeout: 30s
54+# [guide] settings guide, row "Small model (≤ 8B), short tasks"
55+bashTimeout: 30s
56+
57+sampling:
58+ # [rule] precise temperature for a developer agent without reasoning (family
59+ # smollm3)
60+ temperature: 0.2
61+ # [card] family smollm3: SmolLM3-3B model card ("We recommend setting
62+ # temperature=0.6 and top_p=0.95 in the sampling parameters";
63+ # generation_config.json: temperature 0.6, top_p 0.95) — one
64+ # recommendation, used for both modes; reasoning on by default in the
65+ # template (enable_thinking defaults to true; a /think or /no_think flag in
66+ # the system prompt overrides the kwarg); precise: rule
67+ top_p: 0.95
68+ # [rule] room for an answer or a whole file in one tool call
69+ max_tokens: 4096
70+ # one tool call per reply: small models run a check before the change it
71+ # checks has landed otherwise
72+ parallel_tool_calls: false
73+ chat_template_kwargs:
74+ # reasoning off: family smollm3 is kept off — see the note at the end of
75+ # the file (read from the chat template)
76+ enable_thinking: false
77+
78+context:
79+ # compress the history before the window is full (settings guide, every row)
80+ enabled: true
81+ # mm's default: 75 % of the window the server serves, a quarter left for the
82+ # next question and its tool outputs
83+ threshold: 75
84+
85+sessions:
86+ # your answer: every conversation is saved; /sessions lists them, /resume
87+ # reopens one
88+ enabled: true
89+ # relative to the directory mm is started from: one folder per project
90+ dir: .mm/sessions
91+
92+trace:
93+ enabled: true
94+ dir: .mm/traces
95+
96+agentsMd:
97+ # An AGENTS.md in the project directory is added to the system prompt.
98+ enabled: true
99+
100+acp:
101+ name: i'm smollm3
102+ title: "I'm Smollm3 (developer)"
103+
104+system: |
105+ Your name is I'm Smollm3.
106+ You are a coding agent working in a terminal, in the user's project.
107+ You have a bash tool, file tools (read_file, write_file, edit_file) and a
108+ plan tool.
109+ Read before you change: look at the files and run the commands that tell
110+ you how the project works. Make the smallest change that does the job,
111+ then run the tests or the build to check it.
112+ Never state the output of a command you did not run in this answer.
113+ When a task has several steps, make a plan and update it as you go.
114+
115+# This model is kept with reasoning off (enable_thinking: false), whatever
116+# the usage: measured 2026-11-13 on SmolLM3-3B Q4_K_M with toolFormat:
117+# xml_tools, with reasoning on it repeats its call after the tool's answer
118+# (3 runs out of 3), with it off it answers (6 out of 6). Tasks of several
119+# steps stay fragile at 3B.
120+
121+# Next: skills https://rickub.com/bots-garden/mini-me/blob/main/docs/en/how-to/add-a-skill.md
122+# MCP servers https://rickub.com/bots-garden/mini-me/blob/main/docs/en/tutorials/mcp-getting-started.md
123+# hooks https://rickub.com/bots-garden/mini-me/blob/main/docs/en/how-to/guard-the-agent-with-hooks.md
124+# sessions https://rickub.com/bots-garden/mini-me/blob/main/docs/en/how-to/keep-sessions.md
new file mode 100644
@@ -0,0 +1,124 @@
1+# I'm Smollm3 — a coding agent: reads and changes the project, runs commands.
2+#
3+# Written by `mm init`. Every value says why it has that value; change
4+# them as you watch the agent work:
5+# https://rickub.com/bots-garden/mini-me/blob/main/guides/agent-settings-for-small-models.md
6+#
7+# model family: smollm3
8+# usage: developer
9+# machine: small — a laptop, models up to about 8B
10+# model facts: read from llmman: native window 65536 tokens; reasoning switch enable_thinking
11+#
12+# Where the numbers come from (the tag before their comment):
13+# [card] from the model card or the GGUF, not measured — re-read the new card
14+# [guide] the settings guide's row for this machine
15+# [rule] a rule of thumb, not specific to this model
16+#
17+# mm or mm tui started from the project root (it finds .mm/agent.yaml)
18+
19+provider: llmman
20+# The model. Chosen for it (family smollm3): sampling (temperature, top_p,
21+# max_tokens) and sampling.chat_template_kwargs (enable_thinking). Change the
22+# model and redo them together — running mm init again is the quick way.
23+model: "hf.co/unsloth/SmolLM3-3B-GGUF:Q4_K_M"
24+# No baseUrl: llmman's defaults apply. For the record:
25+# baseUrl: "http://127.0.0.1:17434/v1"
26+# fallback: "http://host.docker.internal:17434/v1" # tried when baseUrl does not answer
27+
28+# 0 = mm asks the server for the window it serves (the model's native window
29+# is 65536 tokens; the server may serve less)
30+contextWindow: 0
31+# your answer: every command and file change waits for your answer
32+yolo: false
33+# usage developer
34+bashTool: true
35+editTools: true
36+planTool: true
37+askUserTool: true
38+# family smollm3: its chat template reads the tools from xml_tools and writes
39+# its calls as <tool_call> text — mm sends them there and reads the calls
40+# back
41+toolFormat: xml_tools
42+# no skills: add a .mm/skills/ folder and set skillsDir: .mm/skills to use
43+# some (see
44+# https://rickub.com/bots-garden/mini-me/blob/main/docs/en/how-to/add-a-skill.md)
45+skillsDir: no-skills
46+# usage developer
47+maxTurns: 40
48+# [guide] settings guide, row "Small model (≤ 8B), short tasks"
49+maxOutput: 8000
50+firstTokenTimeout: 3m
51+# [guide] aligned on bashTimeout, as the settings guide says when bash is on
52+# (section 3)
53+watchdogTimeout: 30s
54+# [guide] settings guide, row "Small model (≤ 8B), short tasks"
55+bashTimeout: 30s
56+
57+sampling:
58+ # [rule] precise temperature for a developer agent without reasoning (family
59+ # smollm3)
60+ temperature: 0.2
61+ # [card] family smollm3: SmolLM3-3B model card ("We recommend setting
62+ # temperature=0.6 and top_p=0.95 in the sampling parameters";
63+ # generation_config.json: temperature 0.6, top_p 0.95) — one
64+ # recommendation, used for both modes; reasoning on by default in the
65+ # template (enable_thinking defaults to true; a /think or /no_think flag in
66+ # the system prompt overrides the kwarg); precise: rule
67+ top_p: 0.95
68+ # [rule] room for an answer or a whole file in one tool call
69+ max_tokens: 4096
70+ # one tool call per reply: small models run a check before the change it
71+ # checks has landed otherwise
72+ parallel_tool_calls: false
73+ chat_template_kwargs:
74+ # reasoning off: family smollm3 is kept off — see the note at the end of
75+ # the file (read from the chat template)
76+ enable_thinking: false
77+
78+context:
79+ # compress the history before the window is full (settings guide, every row)
80+ enabled: true
81+ # mm's default: 75 % of the window the server serves, a quarter left for the
82+ # next question and its tool outputs
83+ threshold: 75
84+
85+sessions:
86+ # your answer: every conversation is saved; /sessions lists them, /resume
87+ # reopens one
88+ enabled: true
89+ # relative to the directory mm is started from: one folder per project
90+ dir: .mm/sessions
91+
92+trace:
93+ enabled: true
94+ dir: .mm/traces
95+
96+agentsMd:
97+ # An AGENTS.md in the project directory is added to the system prompt.
98+ enabled: true
99+
100+acp:
101+ name: i'm smollm3
102+ title: "I'm Smollm3 (developer)"
103+
104+system: |
105+ Your name is I'm Smollm3.
106+ You are a coding agent working in a terminal, in the user's project.
107+ You have a bash tool, file tools (read_file, write_file, edit_file) and a
108+ plan tool.
109+ Read before you change: look at the files and run the commands that tell
110+ you how the project works. Make the smallest change that does the job,
111+ then run the tests or the build to check it.
112+ Never state the output of a command you did not run in this answer.
113+ When a task has several steps, make a plan and update it as you go.
114+
115+# This model is kept with reasoning off (enable_thinking: false), whatever
116+# the usage: measured 2026-11-13 on SmolLM3-3B Q4_K_M with toolFormat:
117+# xml_tools, with reasoning on it repeats its call after the tool's answer
118+# (3 runs out of 3), with it off it answers (6 out of 6). Tasks of several
119+# steps stay fragile at 3B.
120+
121+# Next: skills https://rickub.com/bots-garden/mini-me/blob/main/docs/en/how-to/add-a-skill.md
122+# MCP servers https://rickub.com/bots-garden/mini-me/blob/main/docs/en/tutorials/mcp-getting-started.md
123+# hooks https://rickub.com/bots-garden/mini-me/blob/main/docs/en/how-to/guard-the-agent-with-hooks.md
124+# sessions https://rickub.com/bots-garden/mini-me/blob/main/docs/en/how-to/keep-sessions.md