@hraness/slopcamera 3.3.6 → 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.
- package/README.md +6 -4
- package/apps/desktop/application/operation.ts +1 -1
- package/apps/desktop/cli/capability-manifest.ts +13 -0
- package/apps/desktop/cli/commands.ts +1 -1
- package/apps/desktop/cli/help.ts +12 -1
- package/apps/desktop/cli/html-overlay-renderer.ts +16 -0
- package/apps/desktop/cli/portable-surface.ts +2 -0
- package/apps/desktop/code/public.ts +1 -0
- package/apps/desktop/core/visual-style-look.ts +34 -0
- package/apps/desktop/dist/cli/main.js +132 -108
- package/apps/desktop/html-overlay/runtime.ts +15 -0
- package/dist/cli.js +6 -2
- package/dist/{index-10bz03js.js → index-abnk4h9c.js} +3 -2
- package/dist/index.js +1 -1
- package/docs/README.md +1 -0
- package/examples/style-portfolio/ANIMATION-STUDIES.md +34 -0
- package/examples/style-portfolio/FILM-PLATE.md +19 -0
- package/examples/style-portfolio/README.md +137 -0
- package/examples/style-portfolio/animation-studies.html +597 -0
- package/examples/style-portfolio/assets/instrument-serif-OFL.txt +93 -0
- package/examples/style-portfolio/assets/instrument-serif-latin-400.woff2 +0 -0
- package/examples/style-portfolio/build-gallery.ts +403 -0
- package/examples/style-portfolio/film-plate.html +76 -0
- package/examples/style-portfolio/finish-film.ts +152 -0
- package/examples/style-portfolio/market-street/README.md +43 -0
- package/examples/style-portfolio/market-street/mesh.py +145 -0
- package/examples/style-portfolio/market-street/scene.py +760 -0
- package/examples/style-portfolio/market-street/source.json +1 -0
- package/examples/style-portfolio/market-street/street_data.py +72 -0
- package/examples/style-portfolio/render-film-plate.ts +128 -0
- package/examples/style-portfolio/render.ts +89 -0
- package/examples/style-portfolio/verify-animation-source.mjs +94 -0
- package/package.json +11 -2
- package/skills/slopcamera/SKILL.md +9 -0
- package/skills/slopcamera/references/gateway-media.md +5 -0
- package/skills/slopcamera/references/install.md +1 -1
- package/skills/slopcamera/references/music-video.md +5 -0
- package/skills/slopcamera/references/native-studio.md +4 -0
- package/skills/slopcamera/references/visual-style-direction.md +143 -0
- package/src/cli.ts +28 -0
- package/src/index.ts +1 -0
- package/src/version.ts +1 -1
- package/src/visual-style.ts +375 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/** Bounded local finishing example using Slopcamera's typed video-look compiler. */
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { createReadStream } from "node:fs";
|
|
4
|
+
import { lstat, mkdir, realpath, stat, writeFile } from "node:fs/promises";
|
|
5
|
+
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { createVisualStyleVideoLook } from "../../apps/desktop/core/visual-style-look";
|
|
9
|
+
import { compileVideoLookToFfmpeg } from "../../apps/desktop/core/video-effects";
|
|
10
|
+
|
|
11
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
|
12
|
+
const maxInputBytes = 512 * 1024 * 1024;
|
|
13
|
+
const ProbeSchema = z.strictObject({
|
|
14
|
+
programs: z.array(z.strictObject({})).max(0).optional(),
|
|
15
|
+
stream_groups: z.array(z.strictObject({})).max(0).optional(),
|
|
16
|
+
streams: z.array(z.strictObject({
|
|
17
|
+
index: z.number().int().min(0).max(31),
|
|
18
|
+
codec_type: z.enum(["video", "audio"]),
|
|
19
|
+
width: z.number().int().min(64).max(4096).multipleOf(2).optional(),
|
|
20
|
+
height: z.number().int().min(64).max(4320).multipleOf(2).optional(),
|
|
21
|
+
avg_frame_rate: z.string().regex(/^\d{1,9}\/\d{1,9}$/u).optional(),
|
|
22
|
+
nb_read_frames: z.string().regex(/^\d{1,7}$/u).optional(),
|
|
23
|
+
})).min(1).max(2),
|
|
24
|
+
format: z.strictObject({ duration: z.string().max(32).regex(/^\d+(?:\.\d+)?$/u) }),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export function parseFilmProbe(value: unknown, requireFrameCount = false) {
|
|
28
|
+
const probe = ProbeSchema.parse(value);
|
|
29
|
+
const videos = probe.streams.filter(stream => stream.codec_type === "video");
|
|
30
|
+
const video = videos[0];
|
|
31
|
+
const duration = Number(probe.format.duration);
|
|
32
|
+
if (videos.length !== 1 || video?.width === undefined || video.height === undefined
|
|
33
|
+
|| video.avg_frame_rate === undefined || !Number.isFinite(duration) || duration <= 0 || duration > 30) {
|
|
34
|
+
throw new Error("Use one video stream and at most one audio stream, with at most 30 seconds and even 64–4096 × 64–4320 dimensions.");
|
|
35
|
+
}
|
|
36
|
+
const [numerator, denominator] = video.avg_frame_rate.split("/").map(Number);
|
|
37
|
+
const frameRate = numerator! / denominator!;
|
|
38
|
+
const frameCount = video.nb_read_frames === undefined ? undefined : Number(video.nb_read_frames);
|
|
39
|
+
if (!Number.isFinite(frameRate) || frameRate <= 0 || frameRate > 120
|
|
40
|
+
|| (frameCount !== undefined && (frameCount < 1 || frameCount > 3600))
|
|
41
|
+
|| (requireFrameCount && frameCount === undefined)) {
|
|
42
|
+
throw new Error("Use a finite video rate no higher than 120 fps and at most 3600 decoded frames.");
|
|
43
|
+
}
|
|
44
|
+
return { width: video.width, height: video.height, duration, frameRate, frameCount,
|
|
45
|
+
absoluteVideoIndex: video.index, audioStreams: probe.streams.length - 1 };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** FFprobe indices are absolute; the look compiler selects a video-relative stream. */
|
|
49
|
+
export function createFilmFinishPlan(style: unknown, probe: unknown) {
|
|
50
|
+
const facts = parseFilmProbe(probe);
|
|
51
|
+
const look = createVisualStyleVideoLook(style, { height: facts.height, seed: 19060414 });
|
|
52
|
+
return { facts, look, compiled: compileVideoLookToFfmpeg(look, { videoStreamIndex: 0 }) };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function sha256(path: string) {
|
|
56
|
+
const hash = createHash("sha256");
|
|
57
|
+
let bytes = 0;
|
|
58
|
+
for await (const chunk of createReadStream(path)) {
|
|
59
|
+
bytes += chunk.length;
|
|
60
|
+
if (bytes > maxInputBytes) throw new Error("Media exceeds the 512 MiB byte limit.");
|
|
61
|
+
hash.update(chunk);
|
|
62
|
+
}
|
|
63
|
+
return hash.digest("hex");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function command(argv: string[], timeoutMs = 600_000) {
|
|
67
|
+
const child = Bun.spawn(argv, { cwd: root, stdout: "pipe", stderr: "pipe" });
|
|
68
|
+
let timedOut = false;
|
|
69
|
+
const timer = setTimeout(() => { timedOut = true; child.kill("SIGKILL"); }, timeoutMs);
|
|
70
|
+
async function collect(stream: ReadableStream<Uint8Array>): Promise<string> {
|
|
71
|
+
const reader = stream.getReader();
|
|
72
|
+
const chunks: Uint8Array[] = [];
|
|
73
|
+
let bytes = 0;
|
|
74
|
+
try {
|
|
75
|
+
for (;;) {
|
|
76
|
+
const chunk = await reader.read();
|
|
77
|
+
if (chunk.done) break;
|
|
78
|
+
bytes += chunk.value.byteLength;
|
|
79
|
+
if (bytes > 128 * 1024) {
|
|
80
|
+
child.kill("SIGKILL");
|
|
81
|
+
await reader.cancel();
|
|
82
|
+
throw new Error(`${argv[0]} exceeded its 128 KiB output bound.`);
|
|
83
|
+
}
|
|
84
|
+
chunks.push(chunk.value);
|
|
85
|
+
}
|
|
86
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
87
|
+
} finally { reader.releaseLock(); }
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
const [code, stdout, stderr] = await Promise.all([child.exited, collect(child.stdout), collect(child.stderr)]);
|
|
91
|
+
if (timedOut) throw new Error(`${argv[0]} exceeded its ${timeoutMs} ms deadline.`);
|
|
92
|
+
if (code !== 0) throw new Error(`${argv[0]} failed (${code}): ${stderr.slice(-6000)}`);
|
|
93
|
+
return stdout;
|
|
94
|
+
} catch (error) {
|
|
95
|
+
child.kill("SIGKILL");
|
|
96
|
+
await child.exited;
|
|
97
|
+
throw error;
|
|
98
|
+
} finally { clearTimeout(timer); }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function probe(path: string, countFrames = false): Promise<unknown> {
|
|
102
|
+
return JSON.parse(await command(["ffprobe", "-v", "error", "-protocol_whitelist", "file",
|
|
103
|
+
...(countFrames ? ["-count_frames"] : []), "-show_entries",
|
|
104
|
+
"stream=index,codec_type,width,height,avg_frame_rate,nb_read_frames:format=duration", "-of", "json", path], 30_000)) as unknown;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function finishFilm(args: readonly string[], workspaceRoot = root, log: (message: string) => void = console.log): Promise<void> {
|
|
108
|
+
const [inputArg, style, outputArg, ...extra] = args;
|
|
109
|
+
if (!inputArg || !style || !outputArg || extra.length) throw new Error("Usage: bun examples/style-portfolio/finish-film.ts <input-video> <style-id> <new-output.mp4>");
|
|
110
|
+
const canonicalRoot = await realpath(workspaceRoot);
|
|
111
|
+
const input = await realpath(resolve(canonicalRoot, inputArg));
|
|
112
|
+
const output = resolve(canonicalRoot, outputArg);
|
|
113
|
+
const outputRoot = resolve(canonicalRoot, "artifacts/style-portfolio");
|
|
114
|
+
const outputRelative = relative(outputRoot, output);
|
|
115
|
+
if (isAbsolute(outputRelative) || outputRelative.startsWith("..") || !output.endsWith(".mp4")) throw new Error("Output must be a new .mp4 within artifacts/style-portfolio.");
|
|
116
|
+
// FFmpeg's existence check can follow a dangling symlink to a new target.
|
|
117
|
+
// Reject the directory entry itself before any media work; retain -n as well.
|
|
118
|
+
try {
|
|
119
|
+
await lstat(output);
|
|
120
|
+
throw new Error("Output already exists, including a symlink. Choose a new output path.");
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
123
|
+
}
|
|
124
|
+
const inputStat = await stat(input);
|
|
125
|
+
if (input === output || !inputStat.isFile() || inputStat.size < 1 || inputStat.size > maxInputBytes) throw new Error("Input must be a distinct regular file no larger than 512 MiB.");
|
|
126
|
+
const { facts, look, compiled } = createFilmFinishPlan(style, await probe(input));
|
|
127
|
+
await mkdir(dirname(output), { recursive: true });
|
|
128
|
+
if (await realpath(dirname(output)) !== dirname(output)) throw new Error("Output parents must not be symlinks.");
|
|
129
|
+
const inputSha256 = await sha256(input);
|
|
130
|
+
const intent = { kind: "slopcamera.style-finish-intent", input, inputSha256, output, style, look,
|
|
131
|
+
source: facts, compiler: compiled.compiler, compilerVersion: compiled.compilerVersion, lookHash: compiled.lookHash };
|
|
132
|
+
await writeFile(`${output}.intent.json`, JSON.stringify(intent, null, 2) + "\n", { flag: "wx" });
|
|
133
|
+
const argv = ["ffmpeg", "-hide_banner", "-loglevel", "error", "-n", "-protocol_whitelist", "file", "-i", input,
|
|
134
|
+
"-filter_complex", compiled.filterGraph, "-map", `[${compiled.outputLabel}]`, "-map", "0:a:0?",
|
|
135
|
+
"-c:v", "libx264", "-preset", "slow", "-crf", "15", "-pix_fmt", "yuv420p",
|
|
136
|
+
"-c:a", "aac", "-b:a", "320k", "-movflags", "+faststart", "-color_primaries", "bt709",
|
|
137
|
+
"-color_trc", "bt709", "-colorspace", "bt709", "-threads", "4", output];
|
|
138
|
+
await command(argv);
|
|
139
|
+
if (await sha256(input) !== inputSha256) throw new Error("Source changed during finishing; retain this output as rejected and inspect the intent.");
|
|
140
|
+
const delivered = parseFilmProbe(await probe(output, true), true);
|
|
141
|
+
if (delivered.width !== facts.width || delivered.height !== facts.height || delivered.audioStreams !== facts.audioStreams
|
|
142
|
+
|| Math.abs(delivered.duration - facts.duration) > 0.15 || Math.abs(delivered.frameRate - facts.frameRate) > .05) {
|
|
143
|
+
throw new Error("Finished dimensions, audio streams, duration, or frame rate do not match source.");
|
|
144
|
+
}
|
|
145
|
+
const receipt = { ...intent, kind: "slopcamera.style-finish-receipt", outputSha256: await sha256(output), bytes: (await stat(output)).size,
|
|
146
|
+
dimensions: { width: delivered.width, height: delivered.height }, frameCount: delivered.frameCount, duration: delivered.duration,
|
|
147
|
+
limitations: ["Display-referred artistic finish, not a spectral film-stock simulation.", "Diffusion approximates highlight glow; this adapter does not apply gate weave or alter capture cadence."] };
|
|
148
|
+
await writeFile(`${output}.receipt.json`, JSON.stringify(receipt, null, 2) + "\n", { flag: "wx" });
|
|
149
|
+
log(JSON.stringify({ output, receipt: `${output}.receipt.json`, dimensions: receipt.dimensions, frameCount: receipt.frameCount }));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (import.meta.main) await finishFilm(process.argv.slice(2));
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Market Street film study
|
|
2
|
+
|
|
3
|
+
This editable Blender scene depicts a short cable-car journey toward a Ferry Building interpretation. It combines procedural masonry, shopfronts, open-spoked vehicles, articulated pedestrians and horses, damp paving, and atmospheric depth in a native 4:3 composition. Slopcamera Studio retains the source bundle, execution receipt, frame sequence, and Blender scene.
|
|
4
|
+
|
|
5
|
+
The scene is an authored historical interpretation. Its compressed streetscape, building dimensions, shop occupants, people, traffic, and lighting are invented. It is not original footage, a restoration, or a surveyed reconstruction. Its geometry needs visual review at the intended delivery resolution before a render can support a quality claim.
|
|
6
|
+
|
|
7
|
+
## Source contract
|
|
8
|
+
|
|
9
|
+
Import `source.json` as a Blender source bundle. Its entrypoint is `scene.py`, which exports `build(context)`. The only bundled Python dependencies are `mesh.py` and `street_data.py`. The source uses Blender's bundled font, creates its materials locally, and does not fetch assets or read environment variables.
|
|
10
|
+
|
|
11
|
+
| Control | Default | Meaning |
|
|
12
|
+
| --- | --- | --- |
|
|
13
|
+
| `parameters.frames` | `6 × job frame rate` | Animation frames to author, bounded to 1–480 |
|
|
14
|
+
| `parameters.seed` | `19060414` | Seed for all procedural placement and surface variation |
|
|
15
|
+
| `render.frameRate` | `16/1` | Simulation and capture rate, bounded to 1–60 fps |
|
|
16
|
+
| `render.width`, `render.height` | `1920`, `1440` | Native output size |
|
|
17
|
+
| `render.startFrame` | `1` | Initial frame after scene construction |
|
|
18
|
+
|
|
19
|
+
Use an Eevee job with 64 samples, opaque RGB PNG frames, and `AgX` view transform. The scene requests AgX Medium High Contrast, exposure `-0.10`, and gamma `1`. Rendering remains the host's responsibility. The source does not change native job admission, output paths, resource limits, or execution authority.
|
|
20
|
+
|
|
21
|
+
At 16 fps, `parameters.frames: 96` authors six seconds. For a single-frame proof of the full animation, keep `frames: 96` and select one frame in the job's render interval. A full job uses `startFrame: 1` and `endFrameExclusive: 97`. A 24 fps job can use `frames: 144`; the physical speeds remain the same. Sixteen fps is an authored cadence choice. It is not a claim about the original camera's precise crank rate.
|
|
22
|
+
|
|
23
|
+
The camera starts at `(-2.1, 185, 2.72)` metres, travels toward negative Y at `4.4 m/s`, and looks toward `(0, -12, 12.5)`. A 32 mm lens on a 36 mm sensor keeps the tower and street in view. This is a level traveling view with slight mechanical vibration. The one-sided blocks are compressed composition coordinates, not geographic coordinates.
|
|
24
|
+
|
|
25
|
+
The scene records its evidence, interpretation limits, seed, capture rate, camera, and object counts in the Blender scene's `historical_film_study` property. When Studio supplies `workingRoot`, it also writes `creative-metadata.json` there once.
|
|
26
|
+
|
|
27
|
+
## Historical evidence
|
|
28
|
+
|
|
29
|
+
- The [Library of Congress film record](https://www.loc.gov/item/00694408/) describes a cable-car viewpoint moving toward the Ferry Building, identifies the source as silent black-and-white 35 mm film, and documents the mixed street traffic and puddles. The scene follows those broad visual observations. The Library's catalog associates the film with April 14, 1906; the scene uses the broader description “before the April 1906 earthquake.”
|
|
30
|
+
- The [San Francisco Municipal Transportation Agency's account of the 1906 conversion](https://www.sfmta.com/blog/how-1906-earthquake-transformed-our-public-transit-system) places the installation of Market Street's electric traction infrastructure after the earthquake. This scene omits longitudinal overhead traction wires. Electric cars did cross Market at intersections in the original film; this omission does not imply that no electric transit existed in the city.
|
|
31
|
+
- The [San Francisco Planning Department's Path of Gold landmark report](https://commissions.sfplanning.org/hpcpackets/2014.794A.pdf) dates the bases to 1908 and the globe tops to 1916. Those standards are omitted. The smaller street posts here are period-inspired inventions rather than identified historical fixtures.
|
|
32
|
+
|
|
33
|
+
The source does not claim that a Sanborn map, OpenSFHistory photograph, or other archival object was measured unless its specific record and use are documented. No archival photograph is bundled or projected onto the scene. Named collection references in the earlier prototype were insufficient evidence for its claimed dimensions and confidence levels.
|
|
34
|
+
|
|
35
|
+
## Visual foundations
|
|
36
|
+
|
|
37
|
+
The street is designed to read in monochrome before film finishing. Materials use world-scale masonry joints, roughness, surface variation, and bump; glazing sits visibly inside projecting jambs and faceted projecting bays. The tower, cornices, canvas awnings, silhouettes, and traffic provide different spatial scales. Pedestrians walk and cross the road, horse legs articulate, and vehicles move independently of the camera. Static architectural detail is batched by material to keep the scene manageable.
|
|
38
|
+
|
|
39
|
+
The first native proof exposed a flat tonal range, fog washing out the foreground, party-wall brick mapping collapsing into stripes, and polygon puddles reading as opaque cutouts. The revision separates visible sky brightness from ambient fill, uses more oblique sunlight, confines haze to the distant block, fixes masonry mapping on both wall orientations, and models dampness through surface roughness. Near traffic is kept far enough away for its procedural silhouettes to support the composition.
|
|
40
|
+
|
|
41
|
+
Keep transfer effects restrained: subtle grain at delivery resolution, small gate weave, mild luminance variation, and occasional dirt. Preserve masonry detail and tonal separation. Scratches, a sepia wash, or blur cannot substitute for believable geometry, occlusion, light, and movement. A clean high-resolution master can retain an early-film cadence.
|
|
42
|
+
|
|
43
|
+
Review the first, middle, and final frames and a moving preview for geometry intersections, frozen wheels, foot sliding, shadow artifacts, and legibility of the destination. Vehicles use eased interpolation between endpoint poses; the camera is sampled along its travel at each captured frame. Wheel spokes do not rotate independently in this example, and the figures and vehicles remain procedural approximations. Film finishing should not be used to describe those limitations as archival authenticity.
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Small deterministic mesh batches for an authored Blender scene.
|
|
2
|
+
|
|
3
|
+
Static street details share a mesh per material. Moving figures and vehicles
|
|
4
|
+
keep their own batches so animation never needs a scene-wide object search.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import math
|
|
8
|
+
|
|
9
|
+
import bpy
|
|
10
|
+
from mathutils import Euler, Vector
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Batch:
|
|
14
|
+
def __init__(self, name):
|
|
15
|
+
self.name = name
|
|
16
|
+
self.groups = {}
|
|
17
|
+
|
|
18
|
+
def mesh(self, material, vertices, faces, smooth=False):
|
|
19
|
+
group = self.groups.setdefault(material.name, [material, [], [], []])
|
|
20
|
+
offset = len(group[1])
|
|
21
|
+
group[1].extend(vertices)
|
|
22
|
+
face_list = list(faces)
|
|
23
|
+
group[2].extend(tuple(offset + i for i in face) for face in face_list)
|
|
24
|
+
group[3].extend([smooth] * len(face_list))
|
|
25
|
+
|
|
26
|
+
def box(self, material, center, size, rotation=None):
|
|
27
|
+
x, y, z = center
|
|
28
|
+
dx, dy, dz = (s / 2 for s in size)
|
|
29
|
+
points = [Vector((sx * dx, sy * dy, sz * dz))
|
|
30
|
+
for sx in (-1, 1) for sy in (-1, 1) for sz in (-1, 1)]
|
|
31
|
+
if rotation:
|
|
32
|
+
transform = Euler(rotation).to_matrix()
|
|
33
|
+
points = [transform @ p for p in points]
|
|
34
|
+
vertices = [(x + p.x, y + p.y, z + p.z) for p in points]
|
|
35
|
+
self.mesh(material, vertices, [(0, 1, 3, 2), (4, 6, 7, 5),
|
|
36
|
+
(0, 4, 5, 1), (2, 3, 7, 6),
|
|
37
|
+
(0, 2, 6, 4), (1, 5, 7, 3)])
|
|
38
|
+
|
|
39
|
+
def cone(self, material, center, radius, height, top=None, segments=12,
|
|
40
|
+
rotation=None, smooth=False):
|
|
41
|
+
if top is None:
|
|
42
|
+
top = radius
|
|
43
|
+
matrix = Euler(rotation).to_matrix() if rotation else None
|
|
44
|
+
origin = Vector(center)
|
|
45
|
+
vertices = []
|
|
46
|
+
for z, r in ((-height / 2, radius), (height / 2, top)):
|
|
47
|
+
for index in range(segments):
|
|
48
|
+
angle = math.tau * index / segments
|
|
49
|
+
point = Vector((r * math.cos(angle), r * math.sin(angle), z))
|
|
50
|
+
vertices.append(tuple(origin + (matrix @ point if matrix else point)))
|
|
51
|
+
faces = [tuple(reversed(range(segments))), tuple(range(segments, segments * 2))]
|
|
52
|
+
faces += [(i, (i + 1) % segments, (i + 1) % segments + segments, i + segments)
|
|
53
|
+
for i in range(segments)]
|
|
54
|
+
self.mesh(material, vertices, faces, smooth=smooth)
|
|
55
|
+
|
|
56
|
+
def ellipsoid(self, material, center, radii, segments=14, rings=8):
|
|
57
|
+
x, y, z = center
|
|
58
|
+
a, b, c = radii
|
|
59
|
+
vertices = [(x, y, z + c)]
|
|
60
|
+
for ring in range(1, rings):
|
|
61
|
+
phi = math.pi * ring / rings
|
|
62
|
+
for index in range(segments):
|
|
63
|
+
theta = math.tau * index / segments
|
|
64
|
+
vertices.append((x + a * math.sin(phi) * math.cos(theta),
|
|
65
|
+
y + b * math.sin(phi) * math.sin(theta),
|
|
66
|
+
z + c * math.cos(phi)))
|
|
67
|
+
bottom = len(vertices)
|
|
68
|
+
vertices.append((x, y, z - c))
|
|
69
|
+
faces = [(0, 1 + i, 1 + (i + 1) % segments) for i in range(segments)]
|
|
70
|
+
for ring in range(rings - 2):
|
|
71
|
+
first = 1 + ring * segments
|
|
72
|
+
next_ring = first + segments
|
|
73
|
+
faces.extend((first + i, next_ring + i, next_ring + (i + 1) % segments,
|
|
74
|
+
first + (i + 1) % segments) for i in range(segments))
|
|
75
|
+
first = 1 + (rings - 2) * segments
|
|
76
|
+
faces.extend((first + i, bottom, first + (i + 1) % segments)
|
|
77
|
+
for i in range(segments))
|
|
78
|
+
self.mesh(material, vertices, faces, smooth=True)
|
|
79
|
+
|
|
80
|
+
def beam(self, material, start, end, radius, segments=8):
|
|
81
|
+
delta = Vector(end) - Vector(start)
|
|
82
|
+
self.cone(material, tuple((Vector(start) + Vector(end)) / 2), radius,
|
|
83
|
+
delta.length, segments=segments,
|
|
84
|
+
rotation=delta.to_track_quat('Z', 'Y').to_euler(), smooth=True)
|
|
85
|
+
|
|
86
|
+
def wheel(self, material, center, radius, width=0.075, spokes=12):
|
|
87
|
+
"""An open spoked wheel with its axle along X."""
|
|
88
|
+
x, y, z = center
|
|
89
|
+
vertices, faces = [], []
|
|
90
|
+
segments = 36
|
|
91
|
+
for i in range(segments):
|
|
92
|
+
theta = math.tau * i / segments
|
|
93
|
+
for dx, r in ((-width / 2, radius), (width / 2, radius),
|
|
94
|
+
(-width / 2, radius - 0.055), (width / 2, radius - 0.055)):
|
|
95
|
+
vertices.append((x + dx, y + r * math.sin(theta), z + r * math.cos(theta)))
|
|
96
|
+
for i in range(segments):
|
|
97
|
+
a, b = i * 4, ((i + 1) % segments) * 4
|
|
98
|
+
faces.extend(((a, b, b + 1, a + 1), (a + 2, a + 3, b + 3, b + 2),
|
|
99
|
+
(a, a + 2, b + 2, b), (a + 1, b + 1, b + 3, a + 3)))
|
|
100
|
+
self.mesh(material, vertices, faces)
|
|
101
|
+
for i in range(spokes):
|
|
102
|
+
angle = math.tau * i / spokes
|
|
103
|
+
self.beam(material, center, (x, y + (radius - .045) * math.sin(angle),
|
|
104
|
+
z + (radius - .045) * math.cos(angle)), .025, 6)
|
|
105
|
+
self.cone(material, center, .095, width * 2.5, segments=12,
|
|
106
|
+
rotation=(0, math.pi / 2, 0))
|
|
107
|
+
|
|
108
|
+
def finish(self, parent=None):
|
|
109
|
+
objects = []
|
|
110
|
+
for material, vertices, faces, smooth in self.groups.values():
|
|
111
|
+
mesh = bpy.data.meshes.new(self.name + ' / ' + material.name)
|
|
112
|
+
mesh.from_pydata(vertices, [], faces)
|
|
113
|
+
mesh.update()
|
|
114
|
+
mesh.materials.append(material)
|
|
115
|
+
obj = bpy.data.objects.new(mesh.name, mesh)
|
|
116
|
+
bpy.context.collection.objects.link(obj)
|
|
117
|
+
for poly, enabled in zip(mesh.polygons, smooth):
|
|
118
|
+
poly.use_smooth = enabled
|
|
119
|
+
if parent:
|
|
120
|
+
obj.parent = parent
|
|
121
|
+
objects.append(obj)
|
|
122
|
+
return objects
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def empty(name, parent=None):
|
|
126
|
+
obj = bpy.data.objects.new(name, None)
|
|
127
|
+
bpy.context.collection.objects.link(obj)
|
|
128
|
+
obj.parent = parent
|
|
129
|
+
return obj
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def text_label(name, text, location, normal, size, material, parent=None):
|
|
133
|
+
curve = bpy.data.curves.new(name, 'FONT')
|
|
134
|
+
curve.body = text
|
|
135
|
+
curve.align_x = 'CENTER'
|
|
136
|
+
curve.size = size
|
|
137
|
+
curve.extrude = 0
|
|
138
|
+
curve.space_character = 1.06
|
|
139
|
+
obj = bpy.data.objects.new(name, curve)
|
|
140
|
+
bpy.context.collection.objects.link(obj)
|
|
141
|
+
obj.location = location
|
|
142
|
+
obj.rotation_euler = Vector(normal).to_track_quat('Z', 'Y').to_euler()
|
|
143
|
+
obj.data.materials.append(material)
|
|
144
|
+
obj.parent = parent
|
|
145
|
+
return obj
|