@hraness/slopcamera 3.3.5 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +6 -4
  2. package/apps/desktop/application/operation.ts +1 -1
  3. package/apps/desktop/cli/capability-manifest.ts +13 -0
  4. package/apps/desktop/cli/commands.ts +22 -20
  5. package/apps/desktop/cli/help.ts +12 -1
  6. package/apps/desktop/cli/html-overlay-renderer.ts +16 -0
  7. package/apps/desktop/cli/portable-surface.ts +2 -0
  8. package/apps/desktop/code/public.ts +1 -0
  9. package/apps/desktop/core/visual-style-look.ts +34 -0
  10. package/apps/desktop/dist/cli/main.js +134 -110
  11. package/apps/desktop/html-overlay/runtime.ts +15 -0
  12. package/dist/cli.js +6 -2
  13. package/dist/{index-11cb717y.js → index-abnk4h9c.js} +3 -2
  14. package/dist/index.js +1 -1
  15. package/docs/README.md +1 -0
  16. package/examples/style-portfolio/ANIMATION-STUDIES.md +34 -0
  17. package/examples/style-portfolio/FILM-PLATE.md +19 -0
  18. package/examples/style-portfolio/README.md +137 -0
  19. package/examples/style-portfolio/animation-studies.html +597 -0
  20. package/examples/style-portfolio/assets/instrument-serif-OFL.txt +93 -0
  21. package/examples/style-portfolio/assets/instrument-serif-latin-400.woff2 +0 -0
  22. package/examples/style-portfolio/build-gallery.ts +403 -0
  23. package/examples/style-portfolio/film-plate.html +76 -0
  24. package/examples/style-portfolio/finish-film.ts +152 -0
  25. package/examples/style-portfolio/market-street/README.md +43 -0
  26. package/examples/style-portfolio/market-street/mesh.py +145 -0
  27. package/examples/style-portfolio/market-street/scene.py +760 -0
  28. package/examples/style-portfolio/market-street/source.json +1 -0
  29. package/examples/style-portfolio/market-street/street_data.py +72 -0
  30. package/examples/style-portfolio/render-film-plate.ts +128 -0
  31. package/examples/style-portfolio/render.ts +89 -0
  32. package/examples/style-portfolio/verify-animation-source.mjs +94 -0
  33. package/package.json +11 -2
  34. package/skills/slopcamera/SKILL.md +9 -0
  35. package/skills/slopcamera/references/gateway-media.md +5 -0
  36. package/skills/slopcamera/references/install.md +1 -1
  37. package/skills/slopcamera/references/music-video.md +5 -0
  38. package/skills/slopcamera/references/native-studio.md +4 -0
  39. package/skills/slopcamera/references/visual-style-direction.md +143 -0
  40. package/src/cli.ts +28 -0
  41. package/src/index.ts +1 -0
  42. package/src/version.ts +1 -1
  43. package/src/visual-style.ts +375 -0
@@ -0,0 +1,760 @@
1
+ """A short monochrome cable-car film study, authored for Slopcamera Studio.
2
+
3
+ Call build(context). The source reads no clocks, environment variables, network
4
+ resources, external fonts, or external media. Timing comes only from the job.
5
+ """
6
+
7
+ import json
8
+ import math
9
+ from pathlib import Path
10
+ import random
11
+
12
+ import bpy
13
+ from mathutils import Vector
14
+
15
+ from mesh import Batch, empty, text_label
16
+ import street_data as data
17
+
18
+
19
+ def gray(value):
20
+ return (value, value, value, 1)
21
+
22
+
23
+ def surface(name, value, roughness=.8, metallic=0, texture='stone'):
24
+ mat = bpy.data.materials.new(name)
25
+ mat.diffuse_color = gray(value)
26
+ mat.use_nodes = True
27
+ tree = mat.node_tree
28
+ nodes, links = tree.nodes, tree.links
29
+ bsdf = nodes.get('Principled BSDF')
30
+ bsdf.inputs['Base Color'].default_value = gray(value)
31
+ bsdf.inputs['Roughness'].default_value = roughness
32
+ bsdf.inputs['Metallic'].default_value = metallic
33
+ if texture is None:
34
+ return mat
35
+ geometry = nodes.new('ShaderNodeNewGeometry')
36
+ noise = nodes.new('ShaderNodeTexNoise')
37
+ noise.inputs['Scale'].default_value = 2.8 if texture != 'cloth' else 160
38
+ noise.inputs['Detail'].default_value = 4
39
+ noise.inputs['Roughness'].default_value = .74
40
+ links.new(geometry.outputs['Position'], noise.inputs['Vector'])
41
+ ramp = nodes.new('ShaderNodeValToRGB')
42
+ ramp.color_ramp.elements[0].position = .16
43
+ ramp.color_ramp.elements[0].color = gray(value * .82)
44
+ ramp.color_ramp.elements[1].position = .84
45
+ ramp.color_ramp.elements[1].color = gray(min(.95, value * 1.08))
46
+ links.new(noise.outputs['Fac'], ramp.inputs[0])
47
+ links.new(ramp.outputs['Color'], bsdf.inputs['Base Color'])
48
+ bump = nodes.new('ShaderNodeBump')
49
+ bump.inputs['Strength'].default_value = .24
50
+ bump.inputs['Distance'].default_value = .035 if texture == 'stone' else .008
51
+ links.new(noise.outputs['Fac'], bump.inputs['Height'])
52
+ links.new(bump.outputs['Normal'], bsdf.inputs['Normal'])
53
+ if texture in ('brick', 'paving'):
54
+ split, combine = nodes.new('ShaderNodeSeparateXYZ'), nodes.new('ShaderNodeCombineXYZ')
55
+ links.new(geometry.outputs['Position'], split.inputs[0])
56
+ # Select X/Z or Y/Z by surface normal so exposed party walls keep
57
+ # brick joints instead of collapsing into repeated horizontal stripes.
58
+ if texture == 'paving':
59
+ links.new(split.outputs['X'], combine.inputs['X'])
60
+ links.new(split.outputs['Y'], combine.inputs['Y'])
61
+ else:
62
+ normal_abs = nodes.new('ShaderNodeVectorMath')
63
+ normal_abs.operation = 'ABSOLUTE'
64
+ normal_split = nodes.new('ShaderNodeSeparateXYZ')
65
+ links.new(geometry.outputs['Normal'], normal_abs.inputs[0])
66
+ links.new(normal_abs.outputs[0], normal_split.inputs[0])
67
+ first, second, summed = (nodes.new('ShaderNodeMath') for _ in range(3))
68
+ first.operation = second.operation = 'MULTIPLY'
69
+ summed.operation = 'ADD'
70
+ links.new(split.outputs['Y'], first.inputs[0])
71
+ links.new(normal_split.outputs['X'], first.inputs[1])
72
+ links.new(split.outputs['X'], second.inputs[0])
73
+ links.new(normal_split.outputs['Y'], second.inputs[1])
74
+ links.new(first.outputs[0], summed.inputs[0])
75
+ links.new(second.outputs[0], summed.inputs[1])
76
+ links.new(summed.outputs[0], combine.inputs['X'])
77
+ links.new(split.outputs['Z'], combine.inputs['Y'])
78
+ brick = nodes.new('ShaderNodeTexBrick')
79
+ links.new(combine.outputs[0], brick.inputs['Vector'])
80
+ brick.inputs['Scale'].default_value = 1
81
+ brick.inputs['Brick Width'].default_value = .31
82
+ brick.inputs['Row Height'].default_value = .17 if texture == 'paving' else .13
83
+ brick.inputs['Mortar Size'].default_value = .004 if texture == 'paving' else .006
84
+ brick.inputs['Mortar Smooth'].default_value = .007
85
+ brick.inputs['Color1'].default_value = gray(value * .85 if texture == 'paving' else value * .68)
86
+ brick.inputs['Color2'].default_value = gray(value * 1.08 if texture == 'paving' else value * 1.23)
87
+ brick.inputs['Mortar'].default_value = gray(value * .64 if texture == 'paving' else value * .36)
88
+ mix = nodes.new('ShaderNodeMixRGB')
89
+ mix.blend_type = 'MULTIPLY'
90
+ mix.inputs[0].default_value = .48
91
+ links.new(brick.outputs['Color'], mix.inputs[1])
92
+ links.new(ramp.outputs['Color'], mix.inputs[2])
93
+ links.new(mix.outputs[0], bsdf.inputs['Base Color'])
94
+ links.new(brick.outputs['Fac'], bump.inputs['Height'])
95
+ bump.inputs['Distance'].default_value = .025 if texture == 'paving' else .012
96
+ if texture == 'paving':
97
+ # Uneven moisture changes reflectance on the paving itself. It
98
+ # never adds opaque polygon islands above the road surface.
99
+ damp = nodes.new('ShaderNodeTexNoise')
100
+ damp.inputs['Scale'].default_value = .35
101
+ damp.inputs['Detail'].default_value = 2.4
102
+ links.new(geometry.outputs['Position'], damp.inputs['Vector'])
103
+ damp_ramp = nodes.new('ShaderNodeValToRGB')
104
+ damp_ramp.color_ramp.elements[0].position = .31
105
+ damp_ramp.color_ramp.elements[0].color = gray(.28)
106
+ damp_ramp.color_ramp.elements[1].position = .57
107
+ damp_ramp.color_ramp.elements[1].color = gray(.92)
108
+ links.new(damp.outputs['Fac'], damp_ramp.inputs[0])
109
+ links.new(damp_ramp.outputs['Color'], bsdf.inputs['Roughness'])
110
+ bsdf.inputs['Specular IOR Level'].default_value = .22
111
+ return mat
112
+
113
+
114
+ def palette():
115
+ definitions = {
116
+ 'stone': (.39, .87, 0, 'stone'),
117
+ 'stone_light': (.55, .86, 0, 'stone'),
118
+ 'brick': (.19, .92, 0, 'brick'),
119
+ 'brick_light': (.47, .87, 0, 'brick'),
120
+ 'plaster': (.52, .9, 0, 'stone'),
121
+ 'reveal': (.045, .86, 0, None),
122
+ 'glass': (.065, .18, .22, None),
123
+ 'glass_gray': (.15, .25, .15, None),
124
+ 'sash': (.36, .77, 0, 'stone'),
125
+ 'curtain': (.55, .95, 0, 'cloth'),
126
+ 'road': (.17, .76, 0, 'paving'),
127
+ 'sidewalk': (.34, .92, 0, 'stone'),
128
+ 'iron': (.026, .59, .24, 'stone'),
129
+ 'rail': (.35, .22, .75, None),
130
+ 'roof': (.16, .65, .15, 'stone'),
131
+ 'paint': (.11, .72, 0, 'stone'),
132
+ 'cream': (.71, .8, 0, 'cloth'),
133
+ 'canvas': (.39, .91, 0, 'cloth'),
134
+ 'wood': (.085, .86, 0, 'stone'),
135
+ 'leather': (.045, .56, 0, 'cloth'),
136
+ 'coat': (.055, .94, 0, 'cloth'),
137
+ 'coat_gray': (.2, .96, 0, 'cloth'),
138
+ 'skin': (.39, .76, 0, None),
139
+ 'horse': (.11, .77, 0, None),
140
+ 'horse_light': (.24, .81, 0, None),
141
+ 'white': (.8, .9, 0, None),
142
+ }
143
+ return {key: surface(key.replace('_', ' '), *values) for key, values in definitions.items()}
144
+
145
+
146
+ def arch(batch, material, x, y, spring, width, rise, depth, facing='side'):
147
+ """Dark arched opening on a facade plane, with rectangular lower part."""
148
+ points = [(math.cos(t * math.pi / 16) * width / 2,
149
+ math.sin(t * math.pi / 16) * rise) for t in range(17)]
150
+ if facing == 'front':
151
+ vertices = [(x + a, y, spring + z) for a, z in points]
152
+ else:
153
+ vertices = [(x, y + a, spring + z) for a, z in points]
154
+ batch.mesh(material, vertices, [tuple(range(len(vertices)))])
155
+
156
+
157
+ def window(batch, p, face, direction, y, z, width, height, rng, arched=False):
158
+ fx = face + direction * .045
159
+ # The glazing is in front of the solid backing mass; the outer jambs
160
+ # project farther. This keeps the inset visible without costly booleans.
161
+ batch.box(p['reveal'], (fx, y, z), (.055, width + .22, height + .2))
162
+ glass = p['glass'] if rng.random() < .68 else p['glass_gray']
163
+ batch.box(glass, (fx + direction * .038, y, z), (.026, width, height))
164
+ if rng.random() < .32:
165
+ curtain_height = height * rng.uniform(.19, .48)
166
+ batch.box(p['curtain'], (fx + direction * .058, y, z + (height - curtain_height) / 2),
167
+ (.014, width - .05, curtain_height))
168
+ for offset in (-width / 2 - .11, width / 2 + .11):
169
+ batch.box(p['stone_light'], (fx + direction * .1, y + offset, z), (.17, .13, height + .32))
170
+ batch.box(p['sash'], (fx + direction * .11, y, z), (.06, .055, height))
171
+ batch.box(p['sash'], (fx + direction * .11, y, z + .06), (.07, width, .055))
172
+ batch.box(p['stone_light'], (fx + direction * .15, y, z - height / 2 - .16),
173
+ (.39, width + .49, .14))
174
+ if arched:
175
+ arch(batch, p['reveal'], fx + direction * .01, y, z + height / 2,
176
+ width + .1, width / 2, .02)
177
+ # Individually laid voussoirs give a semicircular masonry silhouette.
178
+ for i in range(11):
179
+ angle = math.pi * (i + .5) / 11
180
+ by = y + math.cos(angle) * (width / 2 + .16)
181
+ bz = z + height / 2 + math.sin(angle) * (width / 2 + .16)
182
+ batch.box(p['stone_light'], (fx + direction * .1, by, bz),
183
+ (.18, .24, .19), rotation=(angle - math.pi / 2, 0, 0))
184
+ else:
185
+ batch.box(p['stone_light'], (fx + direction * .09, y, z + height / 2 + .2),
186
+ (.22, width + .48, .16))
187
+
188
+
189
+ def awning(batch, p, face, direction, y, width):
190
+ tip = face + direction * 1.7
191
+ batch.mesh(p['canvas'], [(face, y - width / 2, 4.0), (face, y + width / 2, 4.0),
192
+ (tip, y + width / 2, 3.25), (tip, y - width / 2, 3.25)],
193
+ [(0, 1, 2, 3)])
194
+ for i in range(max(3, int(width / .28))):
195
+ by = y - width / 2 + .14 + i * .28
196
+ batch.box(p['canvas'], (tip, by, 3.12), (.055, .28, .28))
197
+ batch.ellipsoid(p['canvas'], (tip, by, 2.99), (.035, .139, .055), 8, 4)
198
+ for by in (y - width / 2 + .04, y + width / 2 - .04):
199
+ batch.beam(p['iron'], (face, by, 3.0), (tip, by, 3.24), .02)
200
+
201
+
202
+ def oriel(batch, p, face, direction, y, z, rng):
203
+ """Faceted projecting sash bay with dark angled cheek windows."""
204
+ profile = [(0, -1.04), (.33, -1.04), (.66, -.71),
205
+ (.66, .71), (.33, 1.04), (0, 1.04)]
206
+ vertices = [(face + direction * out, y + along, z + dz)
207
+ for dz in (-1.56, 1.56) for out, along in profile]
208
+ n = len(profile)
209
+ faces = [tuple(reversed(range(n))), tuple(range(n, n * 2))]
210
+ faces += [(i, (i + 1) % n, (i + 1) % n + n, i + n) for i in range(n)]
211
+ batch.mesh(p['stone'], vertices, faces)
212
+ window(batch, p, face + direction * .67, direction, y, z, 1.03, 2.02, rng)
213
+ for side in (-1, 1):
214
+ cheek = [(face + direction * .36, y + side * 1.025, z - .98),
215
+ (face + direction * .645, y + side * .741, z - .98),
216
+ (face + direction * .645, y + side * .741, z + .98),
217
+ (face + direction * .36, y + side * 1.025, z + .98)]
218
+ batch.mesh(p['glass'], cheek, [(0, 1, 2, 3)])
219
+ for dz in (-1.51, 1.52):
220
+ batch.box(p['stone_light'], (face + direction * .34, y, z + dz), (.88, 2.28, .16))
221
+
222
+
223
+ def facade(batch, p, rng, side, parcel, index):
224
+ y, width, height, bays, material, roof = parcel
225
+ face = side * (data.ROAD_WIDTH / 2 + data.SIDEWALK_WIDTH)
226
+ direction = -side
227
+ depth = rng.uniform(14, 21)
228
+ center_x = face + side * depth / 2
229
+ batch.box(p[material], (center_x, y, height / 2), (depth, width, height))
230
+ # Ground-floor pilasters, individual storefronts, and transom divisions.
231
+ spacing = width / bays
232
+ for bay in range(bays):
233
+ by = y - width / 2 + (bay + .5) * spacing
234
+ batch.box(p['reveal'], (face + direction * .045, by, 2.0), (.08, spacing - .31, 3.52))
235
+ batch.box(p['glass'], (face + direction * .093, by, 1.92), (.04, spacing - .55, 3.2))
236
+ for z in (.53, 2.78, 3.55):
237
+ batch.box(p['wood'], (face + direction * .13, by, z), (.10, spacing - .25, .12))
238
+ batch.box(p['stone_light'], (face + direction * .14, by - spacing / 2, 2.06), (.31, .24, 4.12))
239
+ if (bay + index) % 3 == 0 and y > 75:
240
+ awning(batch, p, face, direction, by, spacing - .18)
241
+ for z, protrusion, thick in ((4.25, .25, .24), (4.55, .31, .13),
242
+ (height - .5, .31, .2), (height, .58, .24),
243
+ (height + .24, .71, .18)):
244
+ batch.box(p['stone_light'], (face + direction * protrusion / 2, y, z),
245
+ (protrusion, width + .22, thick))
246
+ rows = max(3, int((height - 5.7) / 3.45))
247
+ for row in range(rows):
248
+ z = 6.5 + row * (height - 7.6) / max(rows - 1, 1)
249
+ for bay in range(bays):
250
+ by = y - width / 2 + (bay + .5) * spacing
251
+ if material in ('stone', 'plaster') and bay % 3 == 1 and y > 90:
252
+ oriel(batch, p, face, direction, by, z, rng)
253
+ else:
254
+ window(batch, p, face, direction, by, z, min(1.48, spacing * .51),
255
+ 1.95 if height < 28 else 2.1, rng,
256
+ arched=roof == 'pediment' and row == rows - 1)
257
+ if row in (0, rows - 2):
258
+ batch.box(p['stone_light'], (face + direction * .1, y, z - 1.36),
259
+ (.25, width, .12))
260
+ # Deep cornice brackets, parapets, corner quoins and drain pipes break
261
+ # repetition at both architectural and street scales.
262
+ for i in range(max(1, int(width / .72))):
263
+ by = y - width / 2 + .35 + i * .72
264
+ batch.box(p['stone_light'], (face + direction * .2, by, height - .17), (.6, .17, .34))
265
+ batch.box(p[material], (face - direction * .14, y, height + .58), (.30, width, .8))
266
+ for by in (y - width / 2 + .22, y + width / 2 - .22):
267
+ for z in range(5, int(height), 2):
268
+ batch.box(p['stone_light'], (face + direction * .08, by, z), (.20, .58, .45))
269
+ batch.beam(p['iron'], (face + direction * .12, y + width / 2 - .8, .3),
270
+ (face + direction * .12, y + width / 2 - .8, height), .045)
271
+ if roof == 'mansard':
272
+ batch.box(p['roof'], (center_x, y, height + 1.0), (depth, width, 1.7))
273
+ for bay in range(bays):
274
+ by = y - width / 2 + (bay + .5) * spacing
275
+ batch.box(p['stone_light'], (face + side * .8, by, height + 1.1), (1.5, 1.25, 1.45))
276
+ batch.box(p['glass'], (face + side * .04, by, height + 1.1), (.03, .73, 1.04))
277
+ batch.mesh(p['roof'], [(face - direction * .03, by - .79, height + 1.76),
278
+ (face - direction * .03, by + .79, height + 1.76),
279
+ (face - direction * .03, by, height + 2.39)], [(0, 1, 2)])
280
+ if roof == 'pediment':
281
+ batch.mesh(p['stone_light'], [(face + direction * .35, y - width * .23, height + .58),
282
+ (face + direction * .35, y + width * .23, height + .58),
283
+ (face + direction * .35, y, height + 3.1)], [(0, 1, 2)])
284
+ for chimney in range(2):
285
+ batch.box(p['brick'], (center_x + side * chimney * 3, y - width * .28, height + 1.2), (1, 1.3, 2.4))
286
+ if y > 90:
287
+ # Invented descriptive businesses; these are never asserted to be
288
+ # occupants of a particular historical parcel.
289
+ words = ['TEA & COFFEE', 'WHOLESALE DRY GOODS', 'HARDWARE', 'WINES & SPIRITS', 'BOOT & SHOE CO.']
290
+ batch.box(p['paint'], (face + direction * .2, y, 4.92), (.22, width * .83, .75))
291
+ text_label('Authored shop lettering', words[index % len(words)],
292
+ (face + direction * .34, y, 4.70), (direction, 0, 0), .46, p['cream'])
293
+ if index in (2, 4):
294
+ # An authored wall advertisement interrupts a large exposed party
295
+ # wall. Its generic wording is not attributed to an actual occupant.
296
+ batch.box(p['paint'], (center_x, y + width / 2 + .035, height * .73),
297
+ (depth * .76, .06, height * .20))
298
+ text_label('Authored painted wall advertisement', 'WHOLESALE\nTEA & COFFEE',
299
+ (center_x, y + width / 2 + .079, height * .70), (0, 1, 0),
300
+ .80, p['cream'])
301
+
302
+
303
+ def ferry(batch, p):
304
+ y = data.FERRY_Y
305
+ batch.box(p['stone'], (0, y - 9, 10.1), (86, 18, 20.2))
306
+ for i in range(19):
307
+ x = (i - 9) * 4.24
308
+ batch.box(p['reveal'], (x, y + .05, 3.05), (2.98, .10, 6.1))
309
+ arch(batch, p['reveal'], x, y + .11, 6.08, 2.98, 1.49, .1, 'front')
310
+ for j in range(12):
311
+ angle = math.pi * (j + .5) / 12
312
+ batch.box(p['stone_light'], (x + math.cos(angle) * 1.66, y + .19,
313
+ 6.08 + math.sin(angle) * 1.66),
314
+ (.39, .32, .31), rotation=(0, math.pi / 2 - angle, 0))
315
+ for offset in (-1.68, 1.68):
316
+ batch.box(p['stone_light'], (x + offset, y + .20, 3.07), (.3, .43, 6.14))
317
+ for z in (11.3, 15.4):
318
+ batch.box(p['reveal'], (x, y + .06, z), (1.82, .12, 2.64))
319
+ batch.box(p['sash'], (x, y + .14, z), (.075, .09, 2.64))
320
+ batch.box(p['sash'], (x, y + .14, z + .15), (1.83, .09, .07))
321
+ batch.box(p['stone_light'], (x, y + .28, z - 1.43), (2.17, .59, .17))
322
+ for z, protrusion in ((8.3, .3), (18.0, .3), (19.6, .62), (20.3, .8)):
323
+ batch.box(p['stone_light'], (0, y + protrusion / 2, z), (87, protrusion, .31))
324
+ # The recognizable campanile establishes the destination throughout.
325
+ batch.box(p['stone'], (0, y - 4.7, 31.1), (11.1, 11.3, 49.8))
326
+ for x in (-5.42, 5.42):
327
+ batch.box(p['stone_light'], (x, y + 1.04, 32), (.58, .53, 45))
328
+ for z in (22.0, 31.0, 42.0):
329
+ for x in (-2.72, 0, 2.72):
330
+ batch.box(p['reveal'], (x, y + 1.03, z), (1.25, .1, 4.32))
331
+ arch(batch, p['reveal'], x, y + 1.11, z + 2.16, 1.25, .625, .1, 'front')
332
+ for bar in range(9):
333
+ batch.box(p['sash'], (x, y + 1.18, z - 1.88 + bar * .45), (1.29, .09, .12))
334
+ for z, w in ((46.6, 12.0), (47.2, 12.8), (56.0, 12.0), (56.6, 13.0)):
335
+ batch.box(p['stone_light'], (0, y - 4.6, z), (w, 12.4, .44))
336
+ # Clock face with radial hour marks and hands at an authored 3:17.
337
+ batch.cone(p['iron'], (0, y + 1.21, 51.4), 2.34, .18, segments=64, rotation=(-math.pi / 2, 0, 0))
338
+ batch.cone(p['cream'], (0, y + 1.33, 51.4), 2.12, .14, segments=64, rotation=(-math.pi / 2, 0, 0))
339
+ for tick in range(60):
340
+ a = math.tau * tick / 60
341
+ r1 = 1.68 if tick % 5 == 0 else 1.88
342
+ batch.beam(p['iron'], (math.sin(a) * r1, y + 1.44, 51.4 + math.cos(a) * r1),
343
+ (math.sin(a) * 2.01, y + 1.44, 51.4 + math.cos(a) * 2.01), .035 if tick % 5 == 0 else .012, 6)
344
+ for fraction, length, radius in ((17 / 60, 1.65, .055), ((3 + 17 / 60) / 12, 1.08, .08)):
345
+ a = fraction * math.tau
346
+ batch.beam(p['iron'], (0, y + 1.48, 51.4),
347
+ (math.sin(a) * length, y + 1.48, 51.4 + math.cos(a) * length), radius)
348
+ batch.box(p['reveal'], (0, y - 4.6, 60.2), (9.4, 9.4, 6.7))
349
+ for x in (-4.9, -1.6, 1.6, 4.9):
350
+ batch.cone(p['stone_light'], (x, y + .2, 60), .35, 6.8, segments=12)
351
+ batch.box(p['stone_light'], (0, y - 4.6, 63.6), (12.0, 12.0, .55))
352
+ batch.mesh(p['roof'], [(-5.9, y + 1.3, 63.9), (5.9, y + 1.3, 63.9),
353
+ (5.9, y - 10.5, 63.9), (-5.9, y - 10.5, 63.9),
354
+ (0, y - 4.6, 71.7)], [(0, 1, 4), (1, 2, 4), (2, 3, 4), (3, 0, 4)])
355
+ batch.cone(p['iron'], (0, y - 4.6, 72.6), .14, 2.0, segments=10)
356
+
357
+
358
+ def street(batch, p, rng):
359
+ batch.box(p['road'], (0, 106, -.12), (26, 260, .2))
360
+ for side in (-1, 1):
361
+ sx = side * (13 + data.SIDEWALK_WIDTH / 2)
362
+ batch.box(p['sidewalk'], (sx, 110, .10), (data.SIDEWALK_WIDTH, 260, .22))
363
+ # Individual curb blocks leave subtle seams and uneven catches of light.
364
+ for index in range(166):
365
+ batch.box(p['stone_light'], (side * 13.05, -17.4 + index * 1.55, .17),
366
+ (.32, 1.50, .34 + rng.uniform(-.02, .02)))
367
+ for y in range(-10, 240, 4):
368
+ batch.box(p['reveal'], (sx, y, .215), (5.25, .013, .006))
369
+ # Cable-car slots and rails; no overhead traction catenary before the quake.
370
+ for x in (-2.1, 2.1):
371
+ batch.box(p['iron'], (x, 111, .018), (.074, 258, .019))
372
+ for dx in (-.71, .71):
373
+ batch.box(p['rail'], (x + dx, 111, .025), (.067, 258, .026))
374
+ batch.box(p['iron'], (x + dx + .045, 111, .02), (.034, 258, .02))
375
+ for side in (-1, 1):
376
+ for y in (31, 80, 127, 173, 219):
377
+ x = side * 13.7
378
+ # Modest period-typical utility posts, not falsely named landmarks.
379
+ batch.cone(p['iron'], (x, y, 2.95), .065, 5.9, segments=10)
380
+ batch.cone(p['iron'], (x, y, .42), .17, .82, top=.10, segments=10)
381
+ batch.cone(p['iron'], (x, y, 5.95), .22, .18, top=.1, segments=10)
382
+ batch.box(p['cream'], (x, y, 5.66), (.27, .27, .45))
383
+ for dx in (-.16, .16):
384
+ batch.box(p['iron'], (x + dx, y, 5.66), (.025, .33, .51))
385
+ # Small goods along shops add a human scale without blocking foot traffic.
386
+ for side, y in ((-1, 140), (1, 162), (1, 90), (-1, 201)):
387
+ for i in range(3):
388
+ x = side * (17.0 - i * .62)
389
+ batch.box(p['wood'], (x, y + i * .75, .6), (.61, .70, 1.0))
390
+ for z in (.31, .56, .81, 1.06):
391
+ batch.box(p['iron'], (x - side * .315, y + i * .75, z), (.012, .70, .024))
392
+
393
+
394
+ def person(p, name, position, rng, heading=0, dress=False, scale=1.0):
395
+ rig = empty(name)
396
+ rig.location = position
397
+ rig.rotation_euler.z = heading
398
+ rig.scale = (scale,) * 3
399
+ body = Batch(name + ' torso')
400
+ coat = p['coat_gray'] if rng.random() < .32 else p['coat']
401
+ if dress:
402
+ body.ellipsoid(coat, (0, 0, 1.14), (.24, .145, .34), 20, 12)
403
+ body.cone(coat, (0, 0, .61), .37, .8, top=.19, segments=24, smooth=True)
404
+ body.ellipsoid(coat, (0, .015, .94), (.23, .16, .17))
405
+ else:
406
+ # A continuous tailored coat encloses shoulders, waist and hem. Small
407
+ # radial folds read as cloth without a stack of disconnected shapes.
408
+ vertices, faces = [], []
409
+ sections = [(.60, .245, .16), (.94, .20, .133),
410
+ (1.24, .25, .15), (1.38, .22, .123), (1.43, .11, .09)]
411
+ for z, width, depth in sections:
412
+ for j in range(24):
413
+ angle = math.tau * j / 24
414
+ fold = 1 + .022 * math.cos(angle * 6)
415
+ vertices.append((math.cos(angle) * width * fold,
416
+ math.sin(angle) * depth * fold, z))
417
+ for section in range(len(sections) - 1):
418
+ for j in range(24):
419
+ k = (j + 1) % 24
420
+ faces.append((section * 24 + j, (section + 1) * 24 + j,
421
+ (section + 1) * 24 + k, section * 24 + k))
422
+ body.mesh(coat, vertices, faces, smooth=True)
423
+ body.box(p['white'], (0, -.129, 1.37), (.075, .012, .095))
424
+ body.box(p['iron'], (0, -.141, 1.34), (.025, .012, .095))
425
+ body.cone(p['skin'], (0, 0, 1.52), .064, .14, segments=12)
426
+ body.ellipsoid(p['skin'], (0, -.008, 1.65), (.112, .098, .147), 16, 12)
427
+ body.ellipsoid(p['skin'], (0, -.098, 1.65), (.024, .043, .033), 10, 6)
428
+ body.ellipsoid(p['iron'], (0, .015, 1.74), (.116, .097, .084))
429
+ # Brim, crown and band produce an identifiable silhouette at street scale.
430
+ hat_r = .23 if dress else .165
431
+ body.cone(p['iron'], (0, 0, 1.79), hat_r, .028, top=hat_r, segments=24)
432
+ body.cone(p['iron'], (0, 0, 1.87), .108, .14, top=.10, segments=20)
433
+ if dress:
434
+ body.ellipsoid(p['cream'], (.11, 0, 1.89), (.055, .07, .095), 10, 6)
435
+ body.finish(rig)
436
+ limbs = []
437
+ for side in (-1, 1):
438
+ leg = empty(name + ' leg', rig)
439
+ leg.location = (side * .105, 0, .85)
440
+ geometry = Batch(name + ' trousers')
441
+ geometry.cone(coat, (0, 0, -.42), .072, .72, top=.091, segments=16, smooth=True)
442
+ geometry.ellipsoid(p['iron'], (0, -.048, -.77), (.083, .137, .057), 16, 8)
443
+ geometry.finish(leg)
444
+ arm = empty(name + ' arm', rig)
445
+ arm.location = (side * .20, 0, 1.37)
446
+ geometry = Batch(name + ' sleeve')
447
+ geometry.beam(coat, (0, 0, 0), (side * .034, -.025, -.48), .073, 14)
448
+ geometry.ellipsoid(p['skin'], (side * .017, -.026, -.52), (.048, .044, .069), 10, 6)
449
+ geometry.finish(arm)
450
+ limbs.append((leg, arm, side))
451
+ return rig, limbs
452
+
453
+
454
+ def animate_person(rig, limbs, start, velocity, fps, frames, phase, walking=True):
455
+ for frame in range(1, frames + 1):
456
+ t = (frame - 1) / fps
457
+ stride = math.sin(t * math.tau * 1.35 + phase) if walking else 0
458
+ rig.location = (start[0] + velocity[0] * t, start[1] + velocity[1] * t,
459
+ start[2] + (.016 * abs(stride) if walking else 0))
460
+ rig.keyframe_insert('location', frame=frame)
461
+ for leg, arm, side in limbs:
462
+ leg.rotation_euler.x = stride * .38 * side
463
+ arm.rotation_euler.x = -stride * .31 * side
464
+ leg.keyframe_insert('rotation_euler', frame=frame)
465
+ arm.keyframe_insert('rotation_euler', frame=frame)
466
+
467
+
468
+ def cable_car(p, name, position, heading=0):
469
+ rig = empty(name)
470
+ rig.location = position
471
+ rig.rotation_euler.z = heading
472
+ batch = Batch(name)
473
+ for x in (-.74, .74):
474
+ for y in (-2.16, 2.16):
475
+ batch.wheel(p['iron'], (x, y, .48), .43, .10, 10)
476
+ batch.box(p['wood'], (0, 0, .92), (2.2, 7.05, .25))
477
+ batch.box(p['paint'], (0, .90, 1.75), (2.12, 3.92, 1.24))
478
+ # A dark open saloon with visible narrow wood frames, not an opaque box
479
+ # with a window decal hidden inside it.
480
+ batch.box(p['reveal'], (0, .85, 2.30), (1.97, 3.79, 1.10))
481
+ for side in (-1, 1):
482
+ for y in (-.8, .10, 1.0, 1.9, 2.8):
483
+ batch.box(p['cream'], (side * 1.075, y, 2.27), (.12, .085, 1.2))
484
+ for z in (1.7, 2.82):
485
+ batch.box(p['cream'], (side * 1.075, .95, z), (.14, 3.91, .1))
486
+ batch.box(p['wood'], (side * .77, -2.03, 1.37), (.45, 2.25, .14))
487
+ batch.box(p['wood'], (side * .92, -2.03, 1.7), (.09, 2.25, .62))
488
+ for y in (-3.39, -2.2, -1.12):
489
+ batch.beam(p['cream'], (side * 1.01, y, 1.0), (side * 1.01, y, 3.08), .046)
490
+ batch.box(p['wood'], (side * 1.13, -.3, .71), (.36, 5.86, .14))
491
+ batch.box(p['cream'], (0, -.11, 3.04), (2.45, 7.45, .18))
492
+ batch.box(p['roof'], (0, .30, 3.21), (1.76, 5.95, .20))
493
+ for i in range(14):
494
+ batch.box(p['iron'], (0, -2.41 + i * .41, 3.30), (1.81, .04, .16))
495
+ batch.box(p['paint'], (0, -3.53, 1.40), (2.08, .12, .79))
496
+ batch.cone(p['cream'], (0, -3.62, 1.73), .18, .12, segments=24, rotation=(math.pi / 2, 0, 0))
497
+ batch.box(p['iron'], (0, -3.7, .64), (1.62, .24, .16))
498
+ batch.finish(rig)
499
+ text_label(name + ' number', '24', (0, -3.61, 1.01), (0, -1, 0), .24, p['cream'], rig)
500
+ return rig
501
+
502
+
503
+ def wagon(p, rng, name, position, heading=0):
504
+ rig = empty(name)
505
+ rig.location = position
506
+ rig.rotation_euler.z = heading
507
+ b = Batch(name)
508
+ for x in (-.82, .82):
509
+ for y, radius in ((-1.05, .52), (1.13, .65)):
510
+ b.wheel(p['wood'], (x, y, radius), radius, .095, 14)
511
+ b.box(p['wood'], (0, 0, 1.02), (1.58, 3.15, .14))
512
+ for side in (-1, 1):
513
+ for z in (1.19, 1.41, 1.63):
514
+ b.box(p['wood'], (side * .76, 0, z), (.085, 3.04, .18))
515
+ for y in (-1.31, 0, 1.31):
516
+ b.box(p['iron'], (side * .812, y, 1.39), (.032, .075, .72))
517
+ for y in (-1.51, 1.51):
518
+ b.box(p['wood'], (0, y, 1.4), (1.52, .08, .69))
519
+ for i in range(4):
520
+ b.box(p['canvas'], ((i % 2 - .5) * .7, (i // 2 - .5) * 1.25, 1.68), (.62, .92, .52))
521
+ # Anatomical masses overlap smoothly. Legs are articulated in a separate
522
+ # group, and the muzzle is narrower than the head rather than a cube.
523
+ horse = p['horse_light'] if rng.random() < .3 else p['horse']
524
+ hy = -3.22
525
+ b.ellipsoid(horse, (0, hy, 1.51), (.43, .91, .53), 24, 14)
526
+ b.ellipsoid(horse, (0, hy - .58, 1.50), (.39, .4, .58), 20, 12)
527
+ b.ellipsoid(horse, (0, hy + .64, 1.56), (.39, .4, .45), 20, 12)
528
+ b.beam(horse, (0, hy - .67, 1.56), (0, hy - 1.06, 2.31), .25, 16)
529
+ b.ellipsoid(horse, (0, hy - 1.10, 2.28), (.21, .27, .27), 20, 12)
530
+ b.ellipsoid(horse, (0, hy - 1.34, 2.13), (.15, .26, .17), 18, 10)
531
+ for side in (-1, 1):
532
+ b.cone(horse, (side * .105, hy - 1.02, 2.58), .066, .29, top=.01, segments=10)
533
+ b.ellipsoid(p['iron'], (side * .191, hy - 1.19, 2.31), (.016, .036, .025), 10, 6)
534
+ b.beam(p['leather'], (side * .33, hy - .61, 1.97), (side * .34, hy - .56, 1.1), .051)
535
+ b.beam(p['wood'], (side * .56, -1.56, 1.05), (side * .54, hy - .51, 1.20), .04)
536
+ b.beam(p['leather'], (side * .08, hy - 1.48, 2.11), (side * .33, -.85, 1.65), .016, 6)
537
+ b.beam(p['leather'], (0, hy - .61, 2.02), (0, hy - 1.02, 2.54), .071, 10)
538
+ b.beam(p['leather'], (0, hy + .82, 1.67), (0, hy + 1.16, .77), .079, 12)
539
+ b.finish(rig)
540
+ legs = []
541
+ for side in (-1, 1):
542
+ for front in (False, True):
543
+ leg = empty(name + ' horse leg', rig)
544
+ leg.location = (side * .28, hy + (-.58 if front else .64), 1.35)
545
+ geometry = Batch(name + ' horse leg')
546
+ geometry.beam(horse, (0, 0, 0), (0, .06 if front else -.08, -.61), .069, 12)
547
+ geometry.ellipsoid(horse, (0, .05, -.61), (.079, .083, .087), 12, 8)
548
+ geometry.beam(horse, (0, .05, -.61), (0, -.018, -1.16), .043, 10)
549
+ geometry.box(p['iron'], (0, -.065, -1.23), (.14, .23, .15))
550
+ geometry.finish(leg)
551
+ legs.append((leg, side * (1 if front else -1)))
552
+ return rig, legs
553
+
554
+
555
+ def automobile(p, name, position, heading=0):
556
+ rig = empty(name)
557
+ rig.location = position
558
+ rig.rotation_euler.z = heading
559
+ b = Batch(name)
560
+ for x in (-.82, .82):
561
+ for y in (-1.11, 1.1):
562
+ b.wheel(p['iron'], (x, y, .52), .50, .10, 12)
563
+ # Curved mudguards made as narrow segments.
564
+ for i in range(12):
565
+ angle = math.pi * (i + .5) / 12
566
+ b.box(p['paint'], (x, y + .60 * math.cos(angle), .52 + .60 * math.sin(angle)),
567
+ (.24, .18, .04), rotation=(angle - math.pi / 2, 0, 0))
568
+ b.box(p['paint'], (0, 0, .81), (1.53, 3.10, .22))
569
+ b.box(p['paint'], (0, -.94, 1.16), (1.04, 1.14, .51))
570
+ b.box(p['rail'], (0, -1.54, 1.12), (.91, .06, .49))
571
+ for x in (-.36, -.18, 0, .18, .36):
572
+ b.box(p['iron'], (x, -1.578, 1.12), (.032, .021, .4))
573
+ b.box(p['leather'], (0, .53, 1.09), (1.32, 1.01, .21))
574
+ b.box(p['leather'], (0, .98, 1.36), (1.37, .20, .61))
575
+ for x in (-.61, .61):
576
+ b.ellipsoid(p['rail'], (x, -1.39, 1.38), (.12, .16, .14), 16, 8)
577
+ b.beam(p['iron'], (x, .7, 1.08), (x, .74, 2.3), .021)
578
+ b.box(p['canvas'], (0, .59, 2.28), (1.46, 1.44, .069))
579
+ b.finish(rig)
580
+ return rig
581
+
582
+
583
+ def linear_animation(obj, start, velocity, fps, frames):
584
+ for frame in (1, max(2, frames)):
585
+ t = (frame - 1) / fps
586
+ obj.location = tuple(start[i] + velocity[i] * t for i in range(3))
587
+ obj.keyframe_insert('location', frame=frame)
588
+
589
+
590
+ def build(context):
591
+ parameters = context.get('parameters', {})
592
+ render = context.get('render') or {}
593
+ rate = render.get('frameRate') or {'numerator': data.CAPTURE_FPS, 'denominator': 1}
594
+ fps = float(rate['numerator']) / float(rate['denominator'])
595
+ frames = int(parameters.get('frames', round(data.DURATION_SECONDS * fps)))
596
+ if not 1 <= frames <= 480 or not 1 <= fps <= 60:
597
+ raise ValueError('Use 1..480 frames and a 1..60 fps job rate.')
598
+ seed = int(parameters.get('seed', 19060414))
599
+ rng = random.Random(seed)
600
+ bpy.ops.object.select_all(action='SELECT')
601
+ bpy.ops.object.delete(use_global=False)
602
+ scene = bpy.context.scene
603
+ scene.unit_settings.system = 'METRIC'
604
+ scene.unit_settings.scale_length = 1
605
+ p = palette()
606
+ architecture = Batch('Architecture and street')
607
+ street(architecture, p, rng)
608
+ ferry(architecture, p)
609
+ for side, parcels in data.PARCELS.items():
610
+ for index, parcel in enumerate(parcels):
611
+ facade(architecture, p, rng, side, parcel, index)
612
+ architecture.finish()
613
+
614
+ # Deep street activity, a near crossing, and independently moving traffic
615
+ # form the shot's changing occlusions. No figure is a cylinder-only token.
616
+ for i in range(35):
617
+ side = -1 if i % 2 else 1
618
+ start = (side * rng.uniform(14.1, 16.6), rng.uniform(15, 211), .21)
619
+ speed = rng.uniform(.45, .91) * (-1 if i % 3 else 1)
620
+ heading = 0 if speed < 0 else math.pi
621
+ human, limbs = person(p, 'Sidewalk walker %02d' % i, start, rng, heading,
622
+ dress=i % 5 == 0, scale=rng.uniform(.92, 1.06))
623
+ animate_person(human, limbs, start, (0, speed), fps, frames, rng.random() * math.tau)
624
+ for i, (x, y, speed) in enumerate(((-7.0, 120, 1.16), (8.5, 109, -.87),
625
+ (-6.5, 85, .98), (10.5, 58, -1.06))):
626
+ start = (x, y, .035)
627
+ heading = math.pi / 2 if speed > 0 else -math.pi / 2
628
+ human, limbs = person(p, 'Crossing pedestrian %02d' % i, start, rng, heading,
629
+ dress=i == 1)
630
+ animate_person(human, limbs, start, (speed, .06), fps, frames, i * 1.41)
631
+ car = cable_car(p, 'Departing cable car', (-2.1, 107, .035))
632
+ linear_animation(car, (-2.1, 107, .035), (0, -3.55, 0), fps, frames)
633
+ driver, _ = person(p, 'Departing gripsman', (0, -2.8, 1.05), rng)
634
+ driver.parent = car
635
+ car = cable_car(p, 'Oncoming cable car', (2.1, 109, .035), math.pi)
636
+ linear_animation(car, (2.1, 109, .035), (0, 3.7, 0), fps, frames)
637
+ driver, _ = person(p, 'Oncoming gripsman', (0, -2.8, 1.05), rng)
638
+ driver.parent = car
639
+ for i, (x, y, speed) in enumerate(((-8.0, 126, -1.7), (8.0, 81, 1.4), (-7.4, 51, -1.3))):
640
+ start = (x, y, .04)
641
+ wagon_rig, legs = wagon(p, rng, 'Delivery wagon %d' % i, start, 0 if speed < 0 else math.pi)
642
+ linear_animation(wagon_rig, start, (0, speed, 0), fps, frames)
643
+ driver, limbs = person(p, 'Wagon driver %d' % i, (0, -.88, 1.0), rng, scale=.93)
644
+ driver.parent = wagon_rig
645
+ for leg, arm, _ in limbs:
646
+ leg.rotation_euler.x = -1.1
647
+ arm.rotation_euler.x = -.9
648
+ for frame in range(1, frames + 1):
649
+ t = (frame - 1) / fps
650
+ for leg, offset in legs:
651
+ leg.rotation_euler.x = math.sin(t * math.tau * 1.2 + offset * math.pi / 2) * .32
652
+ leg.keyframe_insert('rotation_euler', frame=frame)
653
+ auto = automobile(p, 'Runabout crossing the middle distance', (-10, 71, .04), math.pi / 2)
654
+ linear_animation(auto, (-10, 71, .04), (2.2, .1, 0), fps, frames)
655
+ driver, limbs = person(p, 'Runabout driver', (.26, .40, .85), rng, scale=.86)
656
+ driver.parent = auto
657
+ for leg, arm, _ in limbs:
658
+ leg.rotation_euler.x = -.95
659
+ arm.rotation_euler.x = -.9
660
+
661
+ # Neutral broad daylight, contact shadows, and mild volumetric extinction
662
+ # establish depth before the separate film-transfer finishing operation.
663
+ scene.world = bpy.data.worlds.new('Pale April sky')
664
+ scene.world.use_nodes = True
665
+ world_nodes, world_links = scene.world.node_tree.nodes, scene.world.node_tree.links
666
+ background = world_nodes.get('Background')
667
+ background.inputs['Color'].default_value = gray(.70)
668
+ background.inputs['Strength'].default_value = .27
669
+ # The reference's bright sky and dark doorways require separate visible
670
+ # sky exposure and ambient fill. Raising fill to whiten the sky flattens
671
+ # every shadow in the street, which was the first proof's main failure.
672
+ visible_sky = world_nodes.new('ShaderNodeBackground')
673
+ visible_sky.inputs['Color'].default_value = gray(1)
674
+ visible_sky.inputs['Strength'].default_value = 3.2
675
+ light_path = world_nodes.new('ShaderNodeLightPath')
676
+ mix_sky = world_nodes.new('ShaderNodeMixShader')
677
+ world_links.new(light_path.outputs['Is Camera Ray'], mix_sky.inputs[0])
678
+ world_links.new(background.outputs[0], mix_sky.inputs[1])
679
+ world_links.new(visible_sky.outputs[0], mix_sky.inputs[2])
680
+ world_links.new(mix_sky.outputs[0], world_nodes.get('World Output').inputs['Surface'])
681
+ sun_data = bpy.data.lights.new('Soft oblique afternoon light', 'SUN')
682
+ sun_data.energy = 2.55
683
+ sun_data.angle = math.radians(1.6)
684
+ sun = bpy.data.objects.new(sun_data.name, sun_data)
685
+ scene.collection.objects.link(sun)
686
+ sun.rotation_euler = Vector((.74, .31, -.60)).to_track_quat('-Z', 'Y').to_euler()
687
+ fill_data = bpy.data.lights.new('Broad sky bounce', 'AREA')
688
+ fill_data.energy = 800
689
+ fill_data.shape = 'DISK'
690
+ fill_data.size = 55
691
+ fill = bpy.data.objects.new(fill_data.name, fill_data)
692
+ scene.collection.objects.link(fill)
693
+ fill.location = (-12, 130, 45)
694
+ fill.rotation_euler = (Vector((0, 100, 4)) - fill.location).to_track_quat('-Z', 'Y').to_euler()
695
+ mist = bpy.data.materials.new('Distant maritime haze')
696
+ mist.use_nodes = True
697
+ mist.node_tree.nodes.clear()
698
+ scatter = mist.node_tree.nodes.new('ShaderNodeVolumeScatter')
699
+ scatter.inputs['Color'].default_value = gray(.81)
700
+ scatter.inputs['Density'].default_value = .0014
701
+ scatter.inputs['Anisotropy'].default_value = .18
702
+ output = mist.node_tree.nodes.new('ShaderNodeOutputMaterial')
703
+ mist.node_tree.links.new(scatter.outputs[0], output.inputs['Volume'])
704
+ haze = Batch('Atmosphere')
705
+ # Fog starts at Y=80, beyond all foreground traffic. Surface blacks stay
706
+ # intact near the camera while the terminal separates in the distance.
707
+ haze.box(mist, (0, -15, 65), (450, 190, 170))
708
+ haze.finish()
709
+
710
+ camera_data = bpy.data.cameras.new('Cable-car camera')
711
+ camera_data.lens = 32
712
+ camera_data.sensor_width = 36
713
+ camera_data.sensor_fit = 'HORIZONTAL'
714
+ camera_data.clip_end = 550
715
+ camera_data.dof.use_dof = False
716
+ camera = bpy.data.objects.new(camera_data.name, camera_data)
717
+ scene.collection.objects.link(camera)
718
+ scene.camera = camera
719
+ for frame in range(1, frames + 1):
720
+ t = (frame - 1) / fps
721
+ # Mechanical vibration is deliberately subpixel to a few pixels. Film
722
+ # gate weave belongs to the later transfer, not this physical camera.
723
+ camera.location = (-2.1 + math.sin(t * 7.3) * .018,
724
+ 185 - t * 4.4, 2.72 + math.sin(t * 9.1) * .012)
725
+ target = Vector((0, data.FERRY_Y, 12.5))
726
+ camera.rotation_euler = (target - camera.location).to_track_quat('-Z', 'Y').to_euler()
727
+ camera.keyframe_insert('location', frame=frame)
728
+ camera.keyframe_insert('rotation_euler', frame=frame)
729
+ scene.render.resolution_x = int(render.get('width', 1920))
730
+ scene.render.resolution_y = int(render.get('height', 1440))
731
+ scene.render.resolution_percentage = 100
732
+ scene.render.fps = int(round(fps))
733
+ scene.render.fps_base = scene.render.fps / fps
734
+ scene.render.image_settings.color_mode = 'RGB'
735
+ scene.render.film_transparent = False
736
+ scene.view_settings.view_transform = 'AgX'
737
+ scene.view_settings.look = 'AgX - Medium High Contrast'
738
+ scene.view_settings.exposure = -.10
739
+ scene.view_settings.gamma = 1
740
+ scene.frame_start, scene.frame_end = 1, frames
741
+ scene.frame_set(int(render.get('startFrame', 1)))
742
+ metadata = {
743
+ 'kind': 'historical-film-study', 'schemaVersion': 1,
744
+ 'interpretation': data.INTERPRETATION, 'sources': data.SOURCES,
745
+ 'seed': seed, 'frames': frames, 'captureFps': fps,
746
+ 'camera': {'lensMm': 32, 'heightM': 2.72, 'speedMetersPerSecond': 4.4,
747
+ 'destination': 'Ferry Building interpretation', 'direction': '-Y'},
748
+ 'renderIntent': {'width': 1920, 'height': 1440, 'renderer': 'eevee',
749
+ 'samples': 64, 'nativeAspect': '4:3', 'viewTransform': 'AgX'},
750
+ 'meshObjects': sum(obj.type == 'MESH' for obj in scene.objects),
751
+ 'objects': len(scene.objects),
752
+ }
753
+ scene['historical_film_study'] = json.dumps(metadata, sort_keys=True)
754
+ working = context.get('workingRoot')
755
+ if working:
756
+ path = Path(working) / 'creative-metadata.json'
757
+ with path.open('x') as stream:
758
+ json.dump(metadata, stream, indent=2)
759
+ stream.write('\n')
760
+ return scene