yaplotlib 0.1.2__tar.gz → 1.0.0__tar.gz

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.
@@ -0,0 +1,129 @@
1
+ Metadata-Version: 2.4
2
+ Name: yaplotlib
3
+ Version: 1.0.0
4
+ Summary: A tiny library to generate Yaplot file.
5
+ License: MIT
6
+ Author: vitroid
7
+ Author-email: vitroid@gmail.com
8
+ Requires-Python: >=3.11,<4.0
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Requires-Dist: numpy (>=1.24)
16
+ Description-Content-Type: text/markdown
17
+
18
+ ## yaplotlib
19
+
20
+ Yaplot 形式の描画データを Python から作るライブラリです。
21
+
22
+ 描画コマンドは種類(`line` や `circle` など)と座標の列で表します。種類ごとの違いはライブラリ内部の表にまとめてあり、呼び出し側は同じ `add` を使います。複数フレームと、ファイル先頭の `global` ブロック、画面に固定する SVG は `YaplotDocument` が持ちます。
23
+
24
+ ## インストール
25
+
26
+ ```bash
27
+ pip install yaplotlib
28
+ ```
29
+
30
+ numpy が必要です。
31
+
32
+ ## クイックスタート
33
+
34
+ ```python
35
+ import numpy as np
36
+ from yaplotlib import YaplotDocument
37
+
38
+ doc = YaplotDocument()
39
+ doc.globals.fov = 30
40
+ doc.globals.radius = 0.3
41
+
42
+ xyz = np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]])
43
+ path = np.array([[0.0, 0.0, 0.0], [0.5, 0.2, 0.0], [1.0, 0.0, 0.0]])
44
+
45
+ for t in range(len(path)):
46
+ frame = doc.new_frame()
47
+ frame.add_many("circle", xyz, color=3)
48
+ frame.add("stick", (0, 0, 0), (1, 0, 0), arrow=1, color=4)
49
+ if t >= 1:
50
+ # polyline は、Yaplot が受け付けるようになるまで線分に分解して出力する
51
+ frame.add("polyline", *path[: t + 1], color=5)
52
+ frame.add("text", (0, 0, 0), text=f"t = {t}")
53
+
54
+ doc.save("movie.yap")
55
+ ```
56
+
57
+ `color`、`layer`、`radius`、`arrow` を省略すると、その項目は出力されません。直前の値を引き継ぐので、色を指定した要素の後ろに置いた色なしの要素は、その色で描かれます。
58
+
59
+ ## global ブロック
60
+
61
+ `doc.globals` に置いた値は、すべてのフレームの初期状態になります。何も設定しなければ `global` ブロックは出力しません。
62
+
63
+ - 見た目: `layer`, `color`, `radius`, `arrow`, `palettes`
64
+ - 表示の初期状態: `reality`(0..4), `splat`(0..3), `fov`, `verbose`(0..3), `thickness`, `rotation`(1〜3 個の数値), `fps`, `hide`, `show`, `messages`
65
+ - 全フレーム共通の SVG: `add_svg`
66
+
67
+ ```python
68
+ doc.globals.reality = 3
69
+ doc.globals.palettes[3] = (255, 100, 100)
70
+ doc.globals.messages.append("loaded")
71
+ ```
72
+
73
+ ## SVG
74
+
75
+ 画面に固定する 2D の情報(見出し、凡例、グラフ)は SVG のまま渡します。`global` に置くと全フレーム、フレームに置くとそのフレームだけに出ます。matplotlib の Figure も渡せます(背景は透明になります)。
76
+
77
+ ```python
78
+ frame.add_svg('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">'
79
+ '<text x="16" y="32" fill="white">t = 0</text></svg>')
80
+ frame.add_svg(figure) # matplotlib の Figure
81
+ doc.globals.add_svg("hud.svg") # ファイル
82
+ ```
83
+
84
+ ## まとめて追加する
85
+
86
+ `add_many` は座標を ndarray のまま保持します。形は「個数 N × 1 個あたりの座標数 × 3」です。
87
+
88
+ | 種類 | 形 |
89
+ |---|---|
90
+ | `circle`, `ring`, `triangle`, `square`, `pentagon`, `hexagon`, `text` | `(N, 3)` |
91
+ | `line`, `stick` | `(N, 2, 3)` |
92
+ | `polygon`, `polyline`(頂点数がそろうとき) | `(N, m, 3)` |
93
+
94
+ `line` の始点と終点が別の配列なら `np.stack([starts, ends], axis=1)` で重ねます。`text` には長さ N の `texts` も渡します。
95
+
96
+ ```python
97
+ starts = np.zeros((10, 3))
98
+ ends = np.ones((10, 3))
99
+ frame.add_many("line", np.stack([starts, ends], axis=1), color=3)
100
+ ```
101
+
102
+ ## パレット
103
+
104
+ ```python
105
+ from yaplotlib.palettes import rainbow_colors
106
+
107
+ doc.globals.palettes.update(enumerate(rainbow_colors(8), start=10))
108
+ frame.palette(3, (255, 96, 96))
109
+ frame.palettes(rainbow_colors(8), offset=10)
110
+ ```
111
+
112
+ `random_colors` と `gradation_colors` も同じように RGB のリストを返します。
113
+
114
+ ## 以前の API
115
+
116
+ モジュールの関数(`Line`, `Circle`, `Color`, `SetPalette` など)と、`Line` や `Color` のような大文字のメソッドは、`DeprecationWarning` を出して残してあります。`Yaplot` は 1 フレームだけの `YaplotFrame` の別名です。
117
+
118
+ ```python
119
+ from yaplotlib import Yaplot
120
+
121
+ drawing = Yaplot()
122
+ drawing.Points([(0, 0, 0), (1, 0, 0)], color=2, size=0.3)
123
+ drawing.save("example.yaplot")
124
+ ```
125
+
126
+ ## ライセンス
127
+
128
+ 本リポジトリの `LICENSE` ファイルを参照してください。
129
+
@@ -0,0 +1,111 @@
1
+ ## yaplotlib
2
+
3
+ Yaplot 形式の描画データを Python から作るライブラリです。
4
+
5
+ 描画コマンドは種類(`line` や `circle` など)と座標の列で表します。種類ごとの違いはライブラリ内部の表にまとめてあり、呼び出し側は同じ `add` を使います。複数フレームと、ファイル先頭の `global` ブロック、画面に固定する SVG は `YaplotDocument` が持ちます。
6
+
7
+ ## インストール
8
+
9
+ ```bash
10
+ pip install yaplotlib
11
+ ```
12
+
13
+ numpy が必要です。
14
+
15
+ ## クイックスタート
16
+
17
+ ```python
18
+ import numpy as np
19
+ from yaplotlib import YaplotDocument
20
+
21
+ doc = YaplotDocument()
22
+ doc.globals.fov = 30
23
+ doc.globals.radius = 0.3
24
+
25
+ xyz = np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]])
26
+ path = np.array([[0.0, 0.0, 0.0], [0.5, 0.2, 0.0], [1.0, 0.0, 0.0]])
27
+
28
+ for t in range(len(path)):
29
+ frame = doc.new_frame()
30
+ frame.add_many("circle", xyz, color=3)
31
+ frame.add("stick", (0, 0, 0), (1, 0, 0), arrow=1, color=4)
32
+ if t >= 1:
33
+ # polyline は、Yaplot が受け付けるようになるまで線分に分解して出力する
34
+ frame.add("polyline", *path[: t + 1], color=5)
35
+ frame.add("text", (0, 0, 0), text=f"t = {t}")
36
+
37
+ doc.save("movie.yap")
38
+ ```
39
+
40
+ `color`、`layer`、`radius`、`arrow` を省略すると、その項目は出力されません。直前の値を引き継ぐので、色を指定した要素の後ろに置いた色なしの要素は、その色で描かれます。
41
+
42
+ ## global ブロック
43
+
44
+ `doc.globals` に置いた値は、すべてのフレームの初期状態になります。何も設定しなければ `global` ブロックは出力しません。
45
+
46
+ - 見た目: `layer`, `color`, `radius`, `arrow`, `palettes`
47
+ - 表示の初期状態: `reality`(0..4), `splat`(0..3), `fov`, `verbose`(0..3), `thickness`, `rotation`(1〜3 個の数値), `fps`, `hide`, `show`, `messages`
48
+ - 全フレーム共通の SVG: `add_svg`
49
+
50
+ ```python
51
+ doc.globals.reality = 3
52
+ doc.globals.palettes[3] = (255, 100, 100)
53
+ doc.globals.messages.append("loaded")
54
+ ```
55
+
56
+ ## SVG
57
+
58
+ 画面に固定する 2D の情報(見出し、凡例、グラフ)は SVG のまま渡します。`global` に置くと全フレーム、フレームに置くとそのフレームだけに出ます。matplotlib の Figure も渡せます(背景は透明になります)。
59
+
60
+ ```python
61
+ frame.add_svg('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">'
62
+ '<text x="16" y="32" fill="white">t = 0</text></svg>')
63
+ frame.add_svg(figure) # matplotlib の Figure
64
+ doc.globals.add_svg("hud.svg") # ファイル
65
+ ```
66
+
67
+ ## まとめて追加する
68
+
69
+ `add_many` は座標を ndarray のまま保持します。形は「個数 N × 1 個あたりの座標数 × 3」です。
70
+
71
+ | 種類 | 形 |
72
+ |---|---|
73
+ | `circle`, `ring`, `triangle`, `square`, `pentagon`, `hexagon`, `text` | `(N, 3)` |
74
+ | `line`, `stick` | `(N, 2, 3)` |
75
+ | `polygon`, `polyline`(頂点数がそろうとき) | `(N, m, 3)` |
76
+
77
+ `line` の始点と終点が別の配列なら `np.stack([starts, ends], axis=1)` で重ねます。`text` には長さ N の `texts` も渡します。
78
+
79
+ ```python
80
+ starts = np.zeros((10, 3))
81
+ ends = np.ones((10, 3))
82
+ frame.add_many("line", np.stack([starts, ends], axis=1), color=3)
83
+ ```
84
+
85
+ ## パレット
86
+
87
+ ```python
88
+ from yaplotlib.palettes import rainbow_colors
89
+
90
+ doc.globals.palettes.update(enumerate(rainbow_colors(8), start=10))
91
+ frame.palette(3, (255, 96, 96))
92
+ frame.palettes(rainbow_colors(8), offset=10)
93
+ ```
94
+
95
+ `random_colors` と `gradation_colors` も同じように RGB のリストを返します。
96
+
97
+ ## 以前の API
98
+
99
+ モジュールの関数(`Line`, `Circle`, `Color`, `SetPalette` など)と、`Line` や `Color` のような大文字のメソッドは、`DeprecationWarning` を出して残してあります。`Yaplot` は 1 フレームだけの `YaplotFrame` の別名です。
100
+
101
+ ```python
102
+ from yaplotlib import Yaplot
103
+
104
+ drawing = Yaplot()
105
+ drawing.Points([(0, 0, 0), (1, 0, 0)], color=2, size=0.3)
106
+ drawing.save("example.yaplot")
107
+ ```
108
+
109
+ ## ライセンス
110
+
111
+ 本リポジトリの `LICENSE` ファイルを参照してください。
@@ -0,0 +1,24 @@
1
+ [tool.poetry]
2
+ name = "yaplotlib"
3
+ version = "1.0.0"
4
+ description = "A tiny library to generate Yaplot file."
5
+ authors = ["vitroid <vitroid@gmail.com>"]
6
+ license = "MIT"
7
+ readme = "README.md"
8
+
9
+ [tool.poetry.dependencies]
10
+ python = "^3.11"
11
+ numpy = ">=1.24"
12
+
13
+ [tool.poetry.group.dev.dependencies]
14
+ pdoc = "^15.0"
15
+ pytest = ">=8"
16
+
17
+ [tool.pytest.ini_options]
18
+ pythonpath = ["."]
19
+ testpaths = ["tests"]
20
+ filterwarnings = ["always::DeprecationWarning"]
21
+
22
+ [build-system]
23
+ requires = ["poetry-core"]
24
+ build-backend = "poetry.core.masonry.api"
@@ -0,0 +1,61 @@
1
+ """Yaplot 形式の描画データを作る。"""
2
+
3
+ # even: stable; odd: develop
4
+ __version__ = "1.0.0"
5
+
6
+ from .core import (
7
+ Arrow,
8
+ ArrowType,
9
+ Circle,
10
+ Color,
11
+ Layer,
12
+ Line,
13
+ NewPage,
14
+ Polygon,
15
+ SetPalette,
16
+ Size,
17
+ Text,
18
+ )
19
+ from .document import YaplotDocument
20
+ from .elements import Batch, PaletteDef, Primitive, Svg
21
+ from .frame import Yaplot, YaplotFrame
22
+ from .globals import Globals
23
+ from .palettes import (
24
+ GradationPalettes,
25
+ RainbowPalettes,
26
+ RandomPalettes,
27
+ gradation_colors,
28
+ rainbow_colors,
29
+ random_colors,
30
+ )
31
+ from .spec import Kind
32
+
33
+ __all__ = [
34
+ "__version__",
35
+ "Kind",
36
+ "Primitive",
37
+ "Batch",
38
+ "PaletteDef",
39
+ "Svg",
40
+ "Globals",
41
+ "YaplotFrame",
42
+ "Yaplot",
43
+ "YaplotDocument",
44
+ "random_colors",
45
+ "rainbow_colors",
46
+ "gradation_colors",
47
+ "Line",
48
+ "Text",
49
+ "Circle",
50
+ "Arrow",
51
+ "Polygon",
52
+ "Color",
53
+ "SetPalette",
54
+ "Size",
55
+ "Layer",
56
+ "ArrowType",
57
+ "NewPage",
58
+ "RandomPalettes",
59
+ "RainbowPalettes",
60
+ "GradationPalettes",
61
+ ]
@@ -0,0 +1,356 @@
1
+ """要素の列を Yaplot のテキストに変換する。
2
+
3
+ 状態(レイヤ・色・半径・矢印)の差分はここでだけ計算する。
4
+ フレームは状態を持たない。
5
+ """
6
+
7
+ import operator
8
+ from typing import Optional, Sequence, Union
9
+
10
+ import numpy as np
11
+
12
+ from .elements import (
13
+ Batch,
14
+ FrameBreak,
15
+ PaletteDef,
16
+ Primitive,
17
+ Svg,
18
+ _as_int,
19
+ _as_radius,
20
+ )
21
+ from .globals import Globals
22
+ from .spec import KINDS
23
+
24
+ # Yaplot 本体の初期値。色だけは線が 0、塗りが 2 と分かれているため未確定にする。
25
+ _DEFAULT_LAYER = 1
26
+ _DEFAULT_RADIUS = 1.0
27
+ _DEFAULT_ARROW = 0
28
+
29
+
30
+ def quote_text(text: str) -> str:
31
+ """空白や引用符を含む文字列を Yaplot の ``"..."`` にする。"""
32
+ if "\n" in text or "\r" in text:
33
+ raise ValueError("改行を含む文字列は出力できません")
34
+ if text == "" or any(char.isspace() or char in '"\\' for char in text):
35
+ escaped = text.replace("\\", "\\\\").replace('"', '\\"')
36
+ return f'"{escaped}"'
37
+ return text
38
+
39
+
40
+ def format_command(
41
+ command: str,
42
+ points: Sequence[Sequence[float]],
43
+ *,
44
+ count: bool = False,
45
+ text: Optional[str] = None,
46
+ ) -> str:
47
+ """描画コマンド 1 行を作る。座標の末尾には従来どおり空白を付ける。"""
48
+ parts = [command]
49
+ if count:
50
+ parts.append(str(len(points)))
51
+ for point in points:
52
+ for coord in point:
53
+ parts.append(f"{float(coord):.4f}")
54
+ line = " ".join(parts) + " "
55
+ if text is not None:
56
+ line += " " + quote_text(text)
57
+ return line + "\n"
58
+
59
+
60
+ def format_palette(index: int, rgb: Sequence[int]) -> str:
61
+ """``@ n r g b`` の 1 行を作る。"""
62
+ red, green, blue = rgb
63
+ return f"@ {int(index)} {int(red)} {int(green)} {int(blue)}\n"
64
+
65
+
66
+ class _State:
67
+ """書き出し中の、Yaplot 側の現在の状態。"""
68
+
69
+ def __init__(self, defaults: Optional[Globals]) -> None:
70
+ self.layer = _DEFAULT_LAYER
71
+ self.radius = _DEFAULT_RADIUS
72
+ self.arrow = _DEFAULT_ARROW
73
+ self.color: Optional[int] = None
74
+ if defaults is not None:
75
+ if defaults.layer is not None:
76
+ self.layer = _as_int("layer", defaults.layer, 1)
77
+ if defaults.radius is not None:
78
+ self.radius = _as_radius(defaults.radius)
79
+ if defaults.arrow is not None:
80
+ self.arrow = _as_int("arrow", defaults.arrow, 0)
81
+ self.color = _opening_color(defaults)
82
+ self._initial = (self.layer, self.radius, self.arrow, self.color)
83
+
84
+ def reset(self) -> None:
85
+ self.layer, self.radius, self.arrow, self.color = self._initial
86
+
87
+
88
+ def _opening_color(defaults: Globals) -> Optional[int]:
89
+ """global ブロックを読み終えた時点で選ばれている色。"""
90
+ if defaults.color is not None:
91
+ return _as_int("color", defaults.color, 0)
92
+ if not defaults.palettes:
93
+ return None
94
+ last = sorted(defaults.palettes)[-1]
95
+ return _as_int("palette", last, 0)
96
+
97
+
98
+ def _lowering(kind: str, lower: bool) -> bool:
99
+ spec = KINDS[kind]
100
+ if lower and spec.lower_to:
101
+ return True
102
+ if spec.command is None:
103
+ raise ValueError(
104
+ f"{kind!r} にはまだ Yaplot のコマンドがありません。"
105
+ " dumps(lower=True) で線分に分解して出力できます。"
106
+ )
107
+ return False
108
+
109
+
110
+ def _emit_style(state: _State, item: Union[Primitive, Batch], out: list[str]) -> None:
111
+ if item.layer is not None and item.layer != state.layer:
112
+ state.layer = item.layer
113
+ out.append(f"y {state.layer}\n")
114
+ if item.color is not None and item.color != state.color:
115
+ state.color = item.color
116
+ out.append(f"@ {state.color}\n")
117
+ if item.radius is not None and item.radius != state.radius:
118
+ state.radius = item.radius
119
+ out.append(f"r {state.radius}\n")
120
+ if item.arrow is not None and item.arrow != state.arrow:
121
+ state.arrow = item.arrow
122
+ out.append(f"a {state.arrow}\n")
123
+
124
+
125
+ def _emit_primitive(state: _State, prim: Primitive, out: list[str], lower: bool) -> None:
126
+ spec = KINDS[prim.kind]
127
+ _emit_style(state, prim, out)
128
+ if _lowering(prim.kind, lower):
129
+ command = KINDS[spec.lower_to].command # type: ignore[index]
130
+ points = prim.points
131
+ for start, end in zip(points, points[1:]):
132
+ out.append(format_command(command or "", (start, end)))
133
+ return
134
+ out.append(
135
+ format_command(
136
+ spec.command or "",
137
+ prim.points,
138
+ count=spec.npoints is None,
139
+ text=prim.text,
140
+ )
141
+ )
142
+
143
+
144
+ def _emit_batch(state: _State, batch: Batch, out: list[str], lower: bool) -> None:
145
+ spec = KINDS[batch.kind]
146
+ lowering = _lowering(batch.kind, lower)
147
+ _emit_style(state, batch, out)
148
+ if lowering:
149
+ command = KINDS[spec.lower_to].command or "" # type: ignore[union-attr]
150
+ if isinstance(batch.coords, np.ndarray):
151
+ out.append(_format_block(command, _chains_to_lines(batch.coords), None, False))
152
+ else:
153
+ for array in batch.coords:
154
+ for index in range(array.shape[0] - 1):
155
+ out.append(format_command(command, (array[index], array[index + 1])))
156
+ return
157
+ if isinstance(batch.coords, np.ndarray):
158
+ out.append(
159
+ _format_block(spec.command or "", batch.coords, batch.texts, spec.npoints is None)
160
+ )
161
+ return
162
+ for index, array in enumerate(batch.coords):
163
+ text = None if batch.texts is None else batch.texts[index]
164
+ out.append(format_command(spec.command or "", array, count=True, text=text))
165
+
166
+
167
+ def _emit_palette(state: _State, item: PaletteDef, out: list[str]) -> None:
168
+ out.append(format_palette(item.index, item.rgb))
169
+ # @ n r g b はパレット n を現在の色としても選ぶ。
170
+ state.color = item.index
171
+
172
+
173
+ def _chains_to_lines(coords: np.ndarray) -> np.ndarray:
174
+ """(N, m, 3) の折れ線を、隣り合う頂点を結ぶ (N*(m-1), 2, 3) の線分にする。"""
175
+ if coords.shape[0] == 0 or coords.shape[1] < 2:
176
+ return np.zeros((0, 2, 3), dtype=float)
177
+ paired = np.stack((coords[:, :-1, :], coords[:, 1:, :]), axis=2)
178
+ return paired.reshape(-1, 2, 3)
179
+
180
+
181
+ def _format_block(
182
+ command: str,
183
+ coords: np.ndarray,
184
+ texts: Optional[tuple[str, ...]],
185
+ with_count: bool,
186
+ ) -> str:
187
+ """(N, k, 3) をコマンドの行にまとめて整形する。"""
188
+ count = int(coords.shape[0])
189
+ if count == 0:
190
+ return ""
191
+ width = int(coords.shape[1])
192
+ formatted = np.char.mod("%.4f", coords.reshape(count, width * 3))
193
+ prefix = f"{command} {width} " if with_count else f"{command} "
194
+ lines: list[str] = []
195
+ for index in range(count):
196
+ line = prefix + " ".join(formatted[index].tolist()) + " "
197
+ if texts is not None:
198
+ line += " " + quote_text(texts[index])
199
+ lines.append(line + "\n")
200
+ return "".join(lines)
201
+
202
+
203
+ def _svg_text(svg: Svg) -> str:
204
+ text = svg.text
205
+ if not text.endswith("\n"):
206
+ return text + "\n"
207
+ return text
208
+
209
+
210
+ def render_frame(
211
+ items: Sequence[object],
212
+ svgs: Sequence[Svg],
213
+ defaults: Optional[Globals],
214
+ lower: bool = True,
215
+ ) -> str:
216
+ """1 フレーム分をテキストにする。末尾にフレーム区切りの空行は付けない。"""
217
+ state = _State(defaults)
218
+ out: list[str] = []
219
+ for item in items:
220
+ if isinstance(item, FrameBreak):
221
+ out.append("\n")
222
+ state.reset()
223
+ continue
224
+ if isinstance(item, PaletteDef):
225
+ _emit_palette(state, item, out)
226
+ elif isinstance(item, Batch):
227
+ _emit_batch(state, item, out, lower)
228
+ elif isinstance(item, Primitive):
229
+ _emit_primitive(state, item, out, lower)
230
+ else:
231
+ raise TypeError(f"フレームに置けない要素です: {type(item).__name__}")
232
+ for svg in svgs:
233
+ out.append(_svg_text(svg))
234
+ return "".join(out)
235
+
236
+
237
+ def _join_frames(texts: Sequence[str]) -> str:
238
+ out: list[str] = []
239
+ last = len(texts) - 1
240
+ for index, text in enumerate(texts):
241
+ if text and not text.endswith("\n"):
242
+ text += "\n"
243
+ out.append(text)
244
+ if index != last:
245
+ out.append("\n")
246
+ return "".join(out)
247
+
248
+
249
+ def render_document(defaults: Globals, frames: Sequence[object], lower: bool = True) -> str:
250
+ """global ブロックと全フレームを 1 つのテキストにする。"""
251
+ parts: list[str] = []
252
+ if not defaults.is_empty():
253
+ parts.append(render_globals(defaults))
254
+ rendered = [
255
+ render_frame(frame.items, frame.svgs, defaults, lower) # type: ignore[attr-defined]
256
+ for frame in frames
257
+ ]
258
+ parts.append(_join_frames(rendered))
259
+ return "".join(parts)
260
+
261
+
262
+ def render_globals(defaults: Globals) -> str:
263
+ """global ブロックを、終端の空行まで含めて作る。"""
264
+ lines = ["global\n"]
265
+ if defaults.reality is not None:
266
+ lines.append(f"reality {_int_between('reality', defaults.reality, 0, 4)}\n")
267
+ if defaults.splat is not None:
268
+ lines.append(f"splat {_int_between('splat', defaults.splat, 0, 3)}\n")
269
+ if defaults.fov is not None:
270
+ lines.append(f"fov {_float_token(_as_float('fov', defaults.fov))}\n")
271
+ if defaults.verbose is not None:
272
+ lines.append(f"verbose {_int_between('verbose', defaults.verbose, 0, 3)}\n")
273
+ if defaults.thickness is not None:
274
+ lines.append(f"thickness {_plain_int('thickness', defaults.thickness)}\n")
275
+ if defaults.rotation is not None:
276
+ lines.append("rotation " + _rotation(defaults.rotation) + "\n")
277
+ if defaults.fps is not None:
278
+ lines.append(f"fps {_float_token(_as_float('fps', defaults.fps))}\n")
279
+ if defaults.hide:
280
+ lines.append("hide " + _layers("hide", defaults.hide) + "\n")
281
+ if defaults.show:
282
+ lines.append("show " + _layers("show", defaults.show) + "\n")
283
+ for message in defaults.messages:
284
+ if not isinstance(message, str):
285
+ raise TypeError("message は文字列にしてください")
286
+ lines.append("message " + quote_text(message) + "\n")
287
+ if defaults.radius is not None:
288
+ lines.append(f"r {_as_radius(defaults.radius)}\n")
289
+ if defaults.layer is not None:
290
+ lines.append(f"y {_as_int('layer', defaults.layer, 1)}\n")
291
+ if defaults.arrow is not None:
292
+ lines.append(f"a {_as_int('arrow', defaults.arrow, 0)}\n")
293
+ for index in sorted(defaults.palettes):
294
+ item = PaletteDef(index, defaults.palettes[index])
295
+ lines.append(format_palette(item.index, item.rgb))
296
+ if defaults.color is not None:
297
+ color = _as_int("color", defaults.color, 0)
298
+ last: Optional[int] = None
299
+ if defaults.palettes:
300
+ last = _as_int("palette", sorted(defaults.palettes)[-1], 0)
301
+ # 直前の @ n r g b がすでにこの色を選んでいれば、繰り返し出力しない。
302
+ if last != color:
303
+ lines.append(f"@ {color}\n")
304
+ for svg in defaults.svgs:
305
+ lines.append(_svg_text(svg))
306
+ lines.append("\n")
307
+ return "".join(lines)
308
+
309
+
310
+ def _plain_int(name: str, value: object) -> int:
311
+ if isinstance(value, bool):
312
+ raise TypeError(f"{name} は整数で指定してください")
313
+ try:
314
+ return operator.index(value) # type: ignore[arg-type]
315
+ except TypeError:
316
+ raise TypeError(f"{name} は整数で指定してください") from None
317
+
318
+
319
+ def _int_between(name: str, value: object, lo: int, hi: int) -> int:
320
+ number = _plain_int(name, value)
321
+ if not lo <= number <= hi:
322
+ raise ValueError(f"{name} は {lo}..{hi} の範囲です: {number}")
323
+ return number
324
+
325
+
326
+ def _as_float(name: str, value: object) -> float:
327
+ if isinstance(value, bool):
328
+ raise TypeError(f"{name} は数値で指定してください")
329
+ try:
330
+ return float(value) # type: ignore[arg-type]
331
+ except (TypeError, ValueError):
332
+ raise TypeError(f"{name} は数値で指定してください") from None
333
+
334
+
335
+ def _float_token(value: float) -> str:
336
+ if value == int(value) and abs(value) < 1e15:
337
+ return str(int(value))
338
+ return format(value, ".6g")
339
+
340
+
341
+ def _rotation(value: object) -> str:
342
+ try:
343
+ numbers = tuple(_as_float("rotation", item) for item in value) # type: ignore[union-attr]
344
+ except TypeError:
345
+ raise TypeError("rotation は 1〜3 個の数値にしてください") from None
346
+ if not 1 <= len(numbers) <= 3:
347
+ raise ValueError(f"rotation は 1〜3 個の数値にしてください({len(numbers)} 個渡されました)")
348
+ return " ".join(_float_token(number) for number in numbers)
349
+
350
+
351
+ def _layers(name: str, values: object) -> str:
352
+ try:
353
+ numbers = [_as_int(name, item, 1) for item in values] # type: ignore[union-attr]
354
+ except TypeError:
355
+ raise TypeError(f"{name} はレイヤ番号の列にしてください") from None
356
+ return " ".join(str(number) for number in numbers)