@ai-gui/plugin-physics 0.10.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/CHANGELOG.md ADDED
@@ -0,0 +1,24 @@
1
+ # @ai-gui/plugin-physics
2
+
3
+ ## 0.10.0
4
+
5
+ ### Minor Changes
6
+
7
+ - New: force and vector diagrams for teaching mechanics.
8
+
9
+ A ```physics block declares bodies, surfaces, vectors and angles, and the plugin draws them as SVG —
10
+ the picture a textbook draws for a block on an incline, with the forces labelled and one of them
11
+ resolved into components.
12
+
13
+ It is a drawing, not a simulation. A rigid-body engine produces a collision that looks right and
14
+ gives a teacher no way to label the intermediate quantities, stop at three seconds, or show a force
15
+ in equilibrium that never moves anything. Coordinates in, faithful picture out; the numbers are the
16
+ model's to get right.
17
+
18
+ y increases upwards and angles run counter-clockwise from the positive x axis, because that is how a
19
+ mechanics problem states them — "60° above the horizontal", "gravity at -90". Colours come from CSS
20
+ custom properties over currentColor, so a diagram follows the page it is on.
21
+
22
+ ### Patch Changes
23
+
24
+ - @ai-gui/core@0.10.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Liang Li
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/dist/index.cjs ADDED
@@ -0,0 +1,370 @@
1
+ "use strict";
2
+
3
+ //#region src/index.ts
4
+ const DEFAULTS = {
5
+ width: 460,
6
+ height: 340,
7
+ maxSourceBytes: 8 * 1024,
8
+ maxElements: 40
9
+ };
10
+ /** Colours are named through currentColor and CSS custom properties so a host's theme decides. */
11
+ const STYLE_VAR = {
12
+ force: "--aigui-physics-force",
13
+ velocity: "--aigui-physics-velocity",
14
+ acceleration: "--aigui-physics-acceleration",
15
+ component: "--aigui-physics-component",
16
+ dimension: "--aigui-physics-dimension"
17
+ };
18
+ function escapeHtml(value) {
19
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
20
+ }
21
+ function isFinitePoint(value) {
22
+ return Array.isArray(value) && value.length === 2 && value.every((part) => typeof part === "number" && Number.isFinite(part));
23
+ }
24
+ function num(value, fallback) {
25
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
26
+ }
27
+ /**
28
+ * Read a diagram out of a block, rejecting anything that is not one.
29
+ *
30
+ * Strict, like the other plugins that accept model output: a diagram that half-parses would draw a
31
+ * confident picture of something the model did not mean.
32
+ */
33
+ function parsePhysicsDiagram(source, options = {}) {
34
+ const limits = {
35
+ ...DEFAULTS,
36
+ ...options
37
+ };
38
+ if (new TextEncoder().encode(source).byteLength > limits.maxSourceBytes) return {
39
+ valid: false,
40
+ issues: ["Diagram is too large."]
41
+ };
42
+ let parsed;
43
+ try {
44
+ parsed = JSON.parse(source);
45
+ } catch {
46
+ return {
47
+ valid: false,
48
+ issues: ["Diagram must be valid JSON."]
49
+ };
50
+ }
51
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {
52
+ valid: false,
53
+ issues: ["Diagram must be a JSON object."]
54
+ };
55
+ const value = parsed;
56
+ if (value.version !== 1) return {
57
+ valid: false,
58
+ issues: ["Diagram must declare \"version\": 1."]
59
+ };
60
+ const issues = [];
61
+ const list = (key, read) => {
62
+ const raw = value[key];
63
+ if (raw === void 0) return [];
64
+ if (!Array.isArray(raw)) {
65
+ issues.push(`$.${key} must be an array.`);
66
+ return [];
67
+ }
68
+ if (raw.length > limits.maxElements) {
69
+ issues.push(`$.${key} has more than ${limits.maxElements} entries.`);
70
+ return [];
71
+ }
72
+ const out = [];
73
+ raw.forEach((item, index) => {
74
+ if (typeof item !== "object" || item === null || Array.isArray(item)) {
75
+ issues.push(`$.${key}[${index}] must be an object.`);
76
+ return;
77
+ }
78
+ const read_ = read(item, `$.${key}[${index}]`);
79
+ if (read_ !== void 0) out.push(read_);
80
+ });
81
+ return out;
82
+ };
83
+ const bodies = list("bodies", (item, path) => {
84
+ if (!isFinitePoint(item.at)) {
85
+ issues.push(`${path}.at must be two finite numbers.`);
86
+ return void 0;
87
+ }
88
+ const shape = item.shape === void 0 ? "box" : item.shape;
89
+ if (shape !== "box" && shape !== "circle" && shape !== "point") {
90
+ issues.push(`${path}.shape must be box, circle, or point.`);
91
+ return void 0;
92
+ }
93
+ return {
94
+ at: item.at,
95
+ shape,
96
+ width: num(item.width, 60),
97
+ height: num(item.height, 40),
98
+ radius: num(item.radius, 22),
99
+ rotation: num(item.rotation, 0),
100
+ ...typeof item.label === "string" ? { label: item.label } : {}
101
+ };
102
+ });
103
+ const vectors = list("vectors", (item, path) => {
104
+ const hasTo = isFinitePoint(item.to);
105
+ const hasPolar = typeof item.magnitude === "number" && Number.isFinite(item.magnitude);
106
+ if (!hasTo && !hasPolar) {
107
+ issues.push(`${path} needs either "to" or "magnitude" with "angle".`);
108
+ return void 0;
109
+ }
110
+ const style = item.style === void 0 ? "force" : item.style;
111
+ if (typeof style !== "string" || !Object.hasOwn(STYLE_VAR, style)) {
112
+ issues.push(`${path}.style is not one of ${Object.keys(STYLE_VAR).join(", ")}.`);
113
+ return void 0;
114
+ }
115
+ return {
116
+ ...isFinitePoint(item.from) ? { from: item.from } : {},
117
+ ...hasTo ? { to: item.to } : {},
118
+ ...hasPolar ? {
119
+ magnitude: item.magnitude,
120
+ angle: num(item.angle, 0)
121
+ } : {},
122
+ style,
123
+ dashed: item.dashed === true,
124
+ ...typeof item.label === "string" ? { label: item.label } : {}
125
+ };
126
+ });
127
+ const surfaces = list("surfaces", (item, path) => {
128
+ if (!isFinitePoint(item.from) || !isFinitePoint(item.to)) {
129
+ issues.push(`${path}.from and ${path}.to must be two finite numbers each.`);
130
+ return void 0;
131
+ }
132
+ return {
133
+ from: item.from,
134
+ to: item.to,
135
+ hatch: item.hatch !== false,
136
+ ...typeof item.label === "string" ? { label: item.label } : {}
137
+ };
138
+ });
139
+ const angles = list("angles", (item, path) => {
140
+ if (!isFinitePoint(item.at)) {
141
+ issues.push(`${path}.at must be two finite numbers.`);
142
+ return void 0;
143
+ }
144
+ if (typeof item.from !== "number" || typeof item.to !== "number") {
145
+ issues.push(`${path}.from and ${path}.to must be angles in degrees.`);
146
+ return void 0;
147
+ }
148
+ return {
149
+ at: item.at,
150
+ from: item.from,
151
+ to: item.to,
152
+ radius: num(item.radius, 28),
153
+ ...typeof item.label === "string" ? { label: item.label } : {}
154
+ };
155
+ });
156
+ if (bodies.length + vectors.length + surfaces.length === 0) issues.push("Diagram must contain at least one body, vector, or surface.");
157
+ if (issues.length > 0) return {
158
+ valid: false,
159
+ issues
160
+ };
161
+ return {
162
+ valid: true,
163
+ data: {
164
+ version: 1,
165
+ ...Array.isArray(value.view) && value.view.length === 4 && value.view.every((v) => typeof v === "number" && Number.isFinite(v)) ? { view: value.view } : {},
166
+ ...typeof value.title === "string" ? { title: value.title } : {},
167
+ bodies,
168
+ vectors,
169
+ surfaces,
170
+ angles
171
+ }
172
+ };
173
+ }
174
+ /** The box the diagram occupies, widened to hold everything it draws. */
175
+ function viewBoxOf(diagram) {
176
+ if (diagram.view) return diagram.view;
177
+ const xs = [];
178
+ const ys = [];
179
+ const note = (point) => {
180
+ xs.push(point[0]);
181
+ ys.push(point[1]);
182
+ };
183
+ for (const body of diagram.bodies ?? []) {
184
+ const halfWidth = (body.shape === "circle" ? body.radius ?? 22 : body.width ?? 60) / 2;
185
+ const halfHeight = (body.shape === "circle" ? body.radius ?? 22 : body.height ?? 40) / 2;
186
+ note([body.at[0] - halfWidth, body.at[1] - halfHeight]);
187
+ note([body.at[0] + halfWidth, body.at[1] + halfHeight]);
188
+ }
189
+ for (const surface of diagram.surfaces ?? []) {
190
+ note(surface.from);
191
+ note(surface.to);
192
+ }
193
+ for (const vector of diagram.vectors ?? []) {
194
+ const start = vector.from ?? diagram.bodies?.[0]?.at ?? [0, 0];
195
+ note(start);
196
+ note(tipOf(vector, start));
197
+ }
198
+ if (xs.length === 0) return [
199
+ 0,
200
+ 0,
201
+ 100,
202
+ 100
203
+ ];
204
+ const pad = 40;
205
+ return [
206
+ Math.min(...xs) - pad,
207
+ Math.min(...ys) - pad,
208
+ Math.max(...xs) + pad,
209
+ Math.max(...ys) + pad
210
+ ];
211
+ }
212
+ /** Where a vector ends, whether it was given as a point or as a magnitude and angle. */
213
+ function tipOf(vector, start) {
214
+ if (vector.to) return vector.to;
215
+ const radians = (vector.angle ?? 0) * Math.PI / 180;
216
+ const length = vector.magnitude ?? 0;
217
+ return [start[0] + length * Math.cos(radians), start[1] + length * Math.sin(radians)];
218
+ }
219
+ function round(value) {
220
+ return (Math.round(value * 100) / 100).toString();
221
+ }
222
+ /**
223
+ * Draw the diagram.
224
+ *
225
+ * The y axis is flipped once, in the outer transform, so a declaration can say "gravity points to
226
+ * y = -1" and mean down — a mechanics problem is stated in maths coordinates, not screen ones.
227
+ */
228
+ function renderPhysicsSVG(diagram, options = {}) {
229
+ const limits = {
230
+ ...DEFAULTS,
231
+ ...options
232
+ };
233
+ const [minX, minY, maxX, maxY] = viewBoxOf(diagram);
234
+ const width = Math.max(1, maxX - minX);
235
+ const height = Math.max(1, maxY - minY);
236
+ const parts = [];
237
+ for (const surface of diagram.surfaces ?? []) {
238
+ parts.push(`<line x1="${round(surface.from[0])}" y1="${round(surface.from[1])}" x2="${round(surface.to[0])}" y2="${round(surface.to[1])}" class="aigui-physics-surface" />`);
239
+ if (surface.hatch) {
240
+ const dx = surface.to[0] - surface.from[0];
241
+ const dy = surface.to[1] - surface.from[1];
242
+ const span = Math.hypot(dx, dy) || 1;
243
+ const steps = Math.min(limits.maxElements, Math.max(2, Math.floor(span / 14)));
244
+ const nx = -dy / span;
245
+ const ny = dx / span;
246
+ for (let step = 0; step <= steps; step += 1) {
247
+ const t = step / steps;
248
+ const x = surface.from[0] + dx * t;
249
+ const y = surface.from[1] + dy * t;
250
+ parts.push(`<line x1="${round(x)}" y1="${round(y)}" x2="${round(x - nx * 9 - dx / span * 6)}" y2="${round(y - ny * 9 - dy / span * 6)}" class="aigui-physics-hatch" />`);
251
+ }
252
+ }
253
+ if (surface.label) parts.push(`<text x="${round((surface.from[0] + surface.to[0]) / 2)}" y="${round((surface.from[1] + surface.to[1]) / 2 - 12)}" class="aigui-physics-label" transform="scale(1,-1)" transform-origin="${round((surface.from[0] + surface.to[0]) / 2)} ${round((surface.from[1] + surface.to[1]) / 2 - 12)}">${escapeHtml(surface.label)}</text>`);
254
+ }
255
+ for (const angle of diagram.angles ?? []) {
256
+ const radius = angle.radius ?? 28;
257
+ const start = angle.from * Math.PI / 180;
258
+ const end = angle.to * Math.PI / 180;
259
+ const large = Math.abs(angle.to - angle.from) > 180 ? 1 : 0;
260
+ const sweep = angle.to > angle.from ? 1 : 0;
261
+ const x1 = angle.at[0] + radius * Math.cos(start);
262
+ const y1 = angle.at[1] + radius * Math.sin(start);
263
+ const x2 = angle.at[0] + radius * Math.cos(end);
264
+ const y2 = angle.at[1] + radius * Math.sin(end);
265
+ parts.push(`<path d="M ${round(x1)} ${round(y1)} A ${round(radius)} ${round(radius)} 0 ${large} ${sweep} ${round(x2)} ${round(y2)}" class="aigui-physics-angle" />`);
266
+ if (angle.label) {
267
+ const mid = (start + end) / 2;
268
+ const lx = angle.at[0] + (radius + 14) * Math.cos(mid);
269
+ const ly = angle.at[1] + (radius + 14) * Math.sin(mid);
270
+ parts.push(`<text x="${round(lx)}" y="${round(ly)}" class="aigui-physics-label" transform="scale(1,-1)" transform-origin="${round(lx)} ${round(ly)}">${escapeHtml(angle.label)}</text>`);
271
+ }
272
+ }
273
+ for (const body of diagram.bodies ?? []) {
274
+ const rotate = body.rotation ? ` transform="rotate(${round(body.rotation)} ${round(body.at[0])} ${round(body.at[1])})"` : "";
275
+ if (body.shape === "circle") parts.push(`<circle cx="${round(body.at[0])}" cy="${round(body.at[1])}" r="${round(body.radius ?? 22)}" class="aigui-physics-body"${rotate} />`);
276
+ else if (body.shape === "point") parts.push(`<circle cx="${round(body.at[0])}" cy="${round(body.at[1])}" r="4" class="aigui-physics-point"${rotate} />`);
277
+ else {
278
+ const w = body.width ?? 60;
279
+ const h = body.height ?? 40;
280
+ parts.push(`<rect x="${round(body.at[0] - w / 2)}" y="${round(body.at[1] - h / 2)}" width="${round(w)}" height="${round(h)}" class="aigui-physics-body"${rotate} />`);
281
+ }
282
+ if (body.label) parts.push(`<text x="${round(body.at[0])}" y="${round(body.at[1])}" class="aigui-physics-body-label" transform="scale(1,-1)" transform-origin="${round(body.at[0])} ${round(body.at[1])}">${escapeHtml(body.label)}</text>`);
283
+ }
284
+ for (const vector of diagram.vectors ?? []) {
285
+ const start = vector.from ?? diagram.bodies?.[0]?.at ?? [0, 0];
286
+ const tip = tipOf(vector, start);
287
+ const style = vector.style ?? "force";
288
+ const dash = vector.dashed ? " stroke-dasharray=\"6 4\"" : "";
289
+ parts.push(`<line x1="${round(start[0])}" y1="${round(start[1])}" x2="${round(tip[0])}" y2="${round(tip[1])}" class="aigui-physics-vector" style="stroke:var(${STYLE_VAR[style]}, currentColor)" marker-end="url(#aigui-physics-arrow-${style})"${dash} />`);
290
+ if (vector.label) {
291
+ const lx = tip[0] + (tip[0] - start[0]) * .08 + 8;
292
+ const ly = tip[1] + (tip[1] - start[1]) * .08;
293
+ parts.push(`<text x="${round(lx)}" y="${round(ly)}" class="aigui-physics-label" style="fill:var(${STYLE_VAR[style]}, currentColor)" transform="scale(1,-1)" transform-origin="${round(lx)} ${round(ly)}">${escapeHtml(vector.label)}</text>`);
294
+ }
295
+ }
296
+ const markers = Object.entries(STYLE_VAR).map(([style, variable]) => `<marker id="aigui-physics-arrow-${style}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="var(${variable}, currentColor)" /></marker>`).join("");
297
+ const title = diagram.title ? `<title>${escapeHtml(diagram.title)}</title>` : "";
298
+ return [
299
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${round(minX)} ${round(minY)} ${round(width)} ${round(height)}"`,
300
+ ` width="100%" style="max-width:${limits.width}px;max-height:${limits.height}px;display:block;margin:auto"`,
301
+ ` role="img" aria-label="${escapeHtml(diagram.title ?? "Physics diagram")}" data-aigui-physics="diagram">`,
302
+ title,
303
+ `<defs>${markers}</defs>`,
304
+ `<g transform="translate(0, ${round(minY + maxY)}) scale(1, -1)">`,
305
+ parts.join(""),
306
+ "</g></svg>"
307
+ ].join("");
308
+ }
309
+ function physicsPromptSpec(options = {}) {
310
+ const limits = {
311
+ ...DEFAULTS,
312
+ ...options
313
+ };
314
+ return [
315
+ "Force and vector diagrams (one fenced block): ```physics <strict JSON>```.",
316
+ "Root: {\"version\":1,\"title\":\"...\"?,\"view\":[minX,minY,maxX,maxY]?,\"bodies\":[...]?,\"surfaces\":[...]?,\"vectors\":[...]?,\"angles\":[...]?}. No unknown fields.",
317
+ "Body: {\"at\":[x,y],\"shape\":\"box|circle|point\"?,\"width\":n?,\"height\":n?,\"radius\":n?,\"rotation\":deg?,\"label\":\"...\"?}.",
318
+ "Surface: {\"from\":[x,y],\"to\":[x,y],\"hatch\":true?,\"label\":\"...\"?} — hatching marks the solid side.",
319
+ "Vector: {\"from\":[x,y]?,\"to\":[x,y]?,\"magnitude\":n?,\"angle\":deg?,\"style\":\"force|velocity|acceleration|component|dimension\"?,\"dashed\":true?,\"label\":\"...\"?}. Give either \"to\" or \"magnitude\" with \"angle\". Omit \"from\" to start at the first body.",
320
+ "Angle: {\"at\":[x,y],\"from\":deg,\"to\":deg,\"radius\":n?,\"label\":\"...\"?}.",
321
+ "y increases upwards and angles are counter-clockwise from the positive x axis, as a mechanics problem states them: gravity points at angle -90.",
322
+ `Diagram source is local JSON only, at most ${limits.maxSourceBytes} UTF-8 bytes. Never emit URLs, scripts, remote resources, HTML, or executable content.`,
323
+ "Use this for free-body diagrams, resolved components, inclines and projectile setups — the picture a textbook draws. It is a drawing, not a simulation: state the coordinates you mean."
324
+ ].join("\n");
325
+ }
326
+ const physicsCss = `
327
+ [data-aigui-physics] { max-width: 100%; height: auto; overflow: visible; color: currentColor; }
328
+ [data-aigui-physics] .aigui-physics-body { fill: var(--aigui-physics-body, color-mix(in srgb, currentColor 12%, transparent)); stroke: currentColor; stroke-width: 1.5; }
329
+ [data-aigui-physics] .aigui-physics-point { fill: currentColor; stroke: none; }
330
+ [data-aigui-physics] .aigui-physics-surface { stroke: currentColor; stroke-width: 2; }
331
+ [data-aigui-physics] .aigui-physics-hatch { stroke: currentColor; stroke-width: 1; opacity: .55; }
332
+ [data-aigui-physics] .aigui-physics-angle { fill: none; stroke: var(--aigui-physics-dimension, currentColor); stroke-width: 1.2; }
333
+ [data-aigui-physics] .aigui-physics-vector { stroke-width: 2; stroke-linecap: round; }
334
+ [data-aigui-physics] .aigui-physics-label { font-size: 13px; font-family: inherit; dominant-baseline: middle; }
335
+ [data-aigui-physics] .aigui-physics-body-label { font-size: 13px; font-family: inherit; text-anchor: middle; dominant-baseline: middle; fill: currentColor; }
336
+ `;
337
+ function errorHtml(issue) {
338
+ return {
339
+ kind: "html",
340
+ html: `<pre data-aigui-physics-error>${escapeHtml(issue)}</pre>`
341
+ };
342
+ }
343
+ function physics(options = {}) {
344
+ const render = (node) => {
345
+ const parsed = parsePhysicsDiagram(node.content ?? "", options);
346
+ if (!parsed.valid) return errorHtml(parsed.issues[0] ?? "Invalid diagram.");
347
+ try {
348
+ return {
349
+ kind: "html",
350
+ html: renderPhysicsSVG(parsed.data, options),
351
+ trusted: true
352
+ };
353
+ } catch {
354
+ return errorHtml("Diagram could not be drawn.");
355
+ }
356
+ };
357
+ return {
358
+ name: "physics",
359
+ nodeRenderers: { physics: render },
360
+ css: physicsCss,
361
+ promptSpec: physicsPromptSpec(options)
362
+ };
363
+ }
364
+
365
+ //#endregion
366
+ exports.parsePhysicsDiagram = parsePhysicsDiagram
367
+ exports.physics = physics
368
+ exports.physicsCss = physicsCss
369
+ exports.physicsPromptSpec = physicsPromptSpec
370
+ exports.renderPhysicsSVG = renderPhysicsSVG
@@ -0,0 +1,110 @@
1
+ import { AIGuiPlugin } from "@ai-gui/core";
2
+
3
+ //#region src/index.d.ts
4
+ /**
5
+ * Force and vector diagrams, drawn from a declaration rather than simulated.
6
+ *
7
+ * A mechanics lesson needs the picture a textbook draws: a body, the forces on it as labelled
8
+ * arrows, their angles, and often one force resolved into components. What it does not need is a
9
+ * rigid-body engine — a simulation produces a collision that *looks* right and gives a teacher no
10
+ * way to label the intermediate quantities, stop at three seconds, or show a force that is in
11
+ * equilibrium and therefore never moves anything. So this takes coordinates and draws them.
12
+ *
13
+ * The numbers are the model's to get right; the drawing is faithful to what it declared.
14
+ */
15
+ /**
16
+ * Force and vector diagrams, drawn from a declaration rather than simulated.
17
+ *
18
+ * A mechanics lesson needs the picture a textbook draws: a body, the forces on it as labelled
19
+ * arrows, their angles, and often one force resolved into components. What it does not need is a
20
+ * rigid-body engine — a simulation produces a collision that *looks* right and gives a teacher no
21
+ * way to label the intermediate quantities, stop at three seconds, or show a force that is in
22
+ * equilibrium and therefore never moves anything. So this takes coordinates and draws them.
23
+ *
24
+ * The numbers are the model's to get right; the drawing is faithful to what it declared.
25
+ */
26
+ type VectorStyle = "force" | "velocity" | "acceleration" | "component" | "dimension";
27
+ interface PhysicsVector {
28
+ /** Where the arrow starts, in diagram units. Defaults to the body's centre. */
29
+ from?: [number, number];
30
+ /**
31
+ * Either the arrow's tip, or a magnitude and angle.
32
+ *
33
+ * Angles are in degrees, counter-clockwise from the positive x axis, because that is how a
34
+ * mechanics problem states them ("a 30° incline", "60° above the horizontal").
35
+ */
36
+ to?: [number, number];
37
+ magnitude?: number;
38
+ angle?: number;
39
+ label?: string;
40
+ style?: VectorStyle;
41
+ /** Drawn as a dashed line, for a component or a construction line. */
42
+ dashed?: boolean;
43
+ }
44
+ interface PhysicsBody {
45
+ at: [number, number];
46
+ /** A block, a ball, or a point mass. */
47
+ shape?: "box" | "circle" | "point";
48
+ width?: number;
49
+ height?: number;
50
+ radius?: number;
51
+ label?: string;
52
+ /** Degrees, counter-clockwise — an inclined block is drawn tilted. */
53
+ rotation?: number;
54
+ }
55
+ interface PhysicsSurface {
56
+ from: [number, number];
57
+ to: [number, number];
58
+ /** Hatching on the far side, the convention for ground or a wall. */
59
+ hatch?: boolean;
60
+ label?: string;
61
+ }
62
+ interface PhysicsAngle {
63
+ at: [number, number];
64
+ /** Degrees, counter-clockwise from the positive x axis. */
65
+ from: number;
66
+ to: number;
67
+ radius?: number;
68
+ label?: string;
69
+ }
70
+ interface PhysicsDiagram {
71
+ version: 1;
72
+ /** The coordinate box the diagram is drawn in: [minX, minY, maxX, maxY]. */
73
+ view?: [number, number, number, number];
74
+ title?: string;
75
+ bodies?: PhysicsBody[];
76
+ vectors?: PhysicsVector[];
77
+ surfaces?: PhysicsSurface[];
78
+ angles?: PhysicsAngle[];
79
+ }
80
+ interface PhysicsOptions {
81
+ width?: number;
82
+ height?: number;
83
+ maxSourceBytes?: number;
84
+ /** How many of each element to draw before refusing — a runaway block is not a diagram. */
85
+ maxElements?: number;
86
+ }
87
+ /**
88
+ * Read a diagram out of a block, rejecting anything that is not one.
89
+ *
90
+ * Strict, like the other plugins that accept model output: a diagram that half-parses would draw a
91
+ * confident picture of something the model did not mean.
92
+ */
93
+ declare function parsePhysicsDiagram(source: string, options?: PhysicsOptions): {
94
+ valid: true;
95
+ data: PhysicsDiagram;
96
+ } | {
97
+ valid: false;
98
+ issues: string[];
99
+ };
100
+ /**
101
+ * Draw the diagram.
102
+ *
103
+ * The y axis is flipped once, in the outer transform, so a declaration can say "gravity points to
104
+ * y = -1" and mean down — a mechanics problem is stated in maths coordinates, not screen ones.
105
+ */
106
+ declare function renderPhysicsSVG(diagram: PhysicsDiagram, options?: PhysicsOptions): string;
107
+ declare function physicsPromptSpec(options?: PhysicsOptions): string;
108
+ declare const physicsCss = "\n[data-aigui-physics] { max-width: 100%; height: auto; overflow: visible; color: currentColor; }\n[data-aigui-physics] .aigui-physics-body { fill: var(--aigui-physics-body, color-mix(in srgb, currentColor 12%, transparent)); stroke: currentColor; stroke-width: 1.5; }\n[data-aigui-physics] .aigui-physics-point { fill: currentColor; stroke: none; }\n[data-aigui-physics] .aigui-physics-surface { stroke: currentColor; stroke-width: 2; }\n[data-aigui-physics] .aigui-physics-hatch { stroke: currentColor; stroke-width: 1; opacity: .55; }\n[data-aigui-physics] .aigui-physics-angle { fill: none; stroke: var(--aigui-physics-dimension, currentColor); stroke-width: 1.2; }\n[data-aigui-physics] .aigui-physics-vector { stroke-width: 2; stroke-linecap: round; }\n[data-aigui-physics] .aigui-physics-label { font-size: 13px; font-family: inherit; dominant-baseline: middle; }\n[data-aigui-physics] .aigui-physics-body-label { font-size: 13px; font-family: inherit; text-anchor: middle; dominant-baseline: middle; fill: currentColor; }\n";
109
+ declare function physics(options?: PhysicsOptions): AIGuiPlugin; //#endregion
110
+ export { PhysicsAngle, PhysicsBody, PhysicsDiagram, PhysicsOptions, PhysicsSurface, PhysicsVector, VectorStyle, parsePhysicsDiagram, physics, physicsCss, physicsPromptSpec, renderPhysicsSVG };
@@ -0,0 +1,110 @@
1
+ import { AIGuiPlugin } from "@ai-gui/core";
2
+
3
+ //#region src/index.d.ts
4
+ /**
5
+ * Force and vector diagrams, drawn from a declaration rather than simulated.
6
+ *
7
+ * A mechanics lesson needs the picture a textbook draws: a body, the forces on it as labelled
8
+ * arrows, their angles, and often one force resolved into components. What it does not need is a
9
+ * rigid-body engine — a simulation produces a collision that *looks* right and gives a teacher no
10
+ * way to label the intermediate quantities, stop at three seconds, or show a force that is in
11
+ * equilibrium and therefore never moves anything. So this takes coordinates and draws them.
12
+ *
13
+ * The numbers are the model's to get right; the drawing is faithful to what it declared.
14
+ */
15
+ /**
16
+ * Force and vector diagrams, drawn from a declaration rather than simulated.
17
+ *
18
+ * A mechanics lesson needs the picture a textbook draws: a body, the forces on it as labelled
19
+ * arrows, their angles, and often one force resolved into components. What it does not need is a
20
+ * rigid-body engine — a simulation produces a collision that *looks* right and gives a teacher no
21
+ * way to label the intermediate quantities, stop at three seconds, or show a force that is in
22
+ * equilibrium and therefore never moves anything. So this takes coordinates and draws them.
23
+ *
24
+ * The numbers are the model's to get right; the drawing is faithful to what it declared.
25
+ */
26
+ type VectorStyle = "force" | "velocity" | "acceleration" | "component" | "dimension";
27
+ interface PhysicsVector {
28
+ /** Where the arrow starts, in diagram units. Defaults to the body's centre. */
29
+ from?: [number, number];
30
+ /**
31
+ * Either the arrow's tip, or a magnitude and angle.
32
+ *
33
+ * Angles are in degrees, counter-clockwise from the positive x axis, because that is how a
34
+ * mechanics problem states them ("a 30° incline", "60° above the horizontal").
35
+ */
36
+ to?: [number, number];
37
+ magnitude?: number;
38
+ angle?: number;
39
+ label?: string;
40
+ style?: VectorStyle;
41
+ /** Drawn as a dashed line, for a component or a construction line. */
42
+ dashed?: boolean;
43
+ }
44
+ interface PhysicsBody {
45
+ at: [number, number];
46
+ /** A block, a ball, or a point mass. */
47
+ shape?: "box" | "circle" | "point";
48
+ width?: number;
49
+ height?: number;
50
+ radius?: number;
51
+ label?: string;
52
+ /** Degrees, counter-clockwise — an inclined block is drawn tilted. */
53
+ rotation?: number;
54
+ }
55
+ interface PhysicsSurface {
56
+ from: [number, number];
57
+ to: [number, number];
58
+ /** Hatching on the far side, the convention for ground or a wall. */
59
+ hatch?: boolean;
60
+ label?: string;
61
+ }
62
+ interface PhysicsAngle {
63
+ at: [number, number];
64
+ /** Degrees, counter-clockwise from the positive x axis. */
65
+ from: number;
66
+ to: number;
67
+ radius?: number;
68
+ label?: string;
69
+ }
70
+ interface PhysicsDiagram {
71
+ version: 1;
72
+ /** The coordinate box the diagram is drawn in: [minX, minY, maxX, maxY]. */
73
+ view?: [number, number, number, number];
74
+ title?: string;
75
+ bodies?: PhysicsBody[];
76
+ vectors?: PhysicsVector[];
77
+ surfaces?: PhysicsSurface[];
78
+ angles?: PhysicsAngle[];
79
+ }
80
+ interface PhysicsOptions {
81
+ width?: number;
82
+ height?: number;
83
+ maxSourceBytes?: number;
84
+ /** How many of each element to draw before refusing — a runaway block is not a diagram. */
85
+ maxElements?: number;
86
+ }
87
+ /**
88
+ * Read a diagram out of a block, rejecting anything that is not one.
89
+ *
90
+ * Strict, like the other plugins that accept model output: a diagram that half-parses would draw a
91
+ * confident picture of something the model did not mean.
92
+ */
93
+ declare function parsePhysicsDiagram(source: string, options?: PhysicsOptions): {
94
+ valid: true;
95
+ data: PhysicsDiagram;
96
+ } | {
97
+ valid: false;
98
+ issues: string[];
99
+ };
100
+ /**
101
+ * Draw the diagram.
102
+ *
103
+ * The y axis is flipped once, in the outer transform, so a declaration can say "gravity points to
104
+ * y = -1" and mean down — a mechanics problem is stated in maths coordinates, not screen ones.
105
+ */
106
+ declare function renderPhysicsSVG(diagram: PhysicsDiagram, options?: PhysicsOptions): string;
107
+ declare function physicsPromptSpec(options?: PhysicsOptions): string;
108
+ declare const physicsCss = "\n[data-aigui-physics] { max-width: 100%; height: auto; overflow: visible; color: currentColor; }\n[data-aigui-physics] .aigui-physics-body { fill: var(--aigui-physics-body, color-mix(in srgb, currentColor 12%, transparent)); stroke: currentColor; stroke-width: 1.5; }\n[data-aigui-physics] .aigui-physics-point { fill: currentColor; stroke: none; }\n[data-aigui-physics] .aigui-physics-surface { stroke: currentColor; stroke-width: 2; }\n[data-aigui-physics] .aigui-physics-hatch { stroke: currentColor; stroke-width: 1; opacity: .55; }\n[data-aigui-physics] .aigui-physics-angle { fill: none; stroke: var(--aigui-physics-dimension, currentColor); stroke-width: 1.2; }\n[data-aigui-physics] .aigui-physics-vector { stroke-width: 2; stroke-linecap: round; }\n[data-aigui-physics] .aigui-physics-label { font-size: 13px; font-family: inherit; dominant-baseline: middle; }\n[data-aigui-physics] .aigui-physics-body-label { font-size: 13px; font-family: inherit; text-anchor: middle; dominant-baseline: middle; fill: currentColor; }\n";
109
+ declare function physics(options?: PhysicsOptions): AIGuiPlugin; //#endregion
110
+ export { PhysicsAngle, PhysicsBody, PhysicsDiagram, PhysicsOptions, PhysicsSurface, PhysicsVector, VectorStyle, parsePhysicsDiagram, physics, physicsCss, physicsPromptSpec, renderPhysicsSVG };
package/dist/index.js ADDED
@@ -0,0 +1,364 @@
1
+ //#region src/index.ts
2
+ const DEFAULTS = {
3
+ width: 460,
4
+ height: 340,
5
+ maxSourceBytes: 8 * 1024,
6
+ maxElements: 40
7
+ };
8
+ /** Colours are named through currentColor and CSS custom properties so a host's theme decides. */
9
+ const STYLE_VAR = {
10
+ force: "--aigui-physics-force",
11
+ velocity: "--aigui-physics-velocity",
12
+ acceleration: "--aigui-physics-acceleration",
13
+ component: "--aigui-physics-component",
14
+ dimension: "--aigui-physics-dimension"
15
+ };
16
+ function escapeHtml(value) {
17
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
18
+ }
19
+ function isFinitePoint(value) {
20
+ return Array.isArray(value) && value.length === 2 && value.every((part) => typeof part === "number" && Number.isFinite(part));
21
+ }
22
+ function num(value, fallback) {
23
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
24
+ }
25
+ /**
26
+ * Read a diagram out of a block, rejecting anything that is not one.
27
+ *
28
+ * Strict, like the other plugins that accept model output: a diagram that half-parses would draw a
29
+ * confident picture of something the model did not mean.
30
+ */
31
+ function parsePhysicsDiagram(source, options = {}) {
32
+ const limits = {
33
+ ...DEFAULTS,
34
+ ...options
35
+ };
36
+ if (new TextEncoder().encode(source).byteLength > limits.maxSourceBytes) return {
37
+ valid: false,
38
+ issues: ["Diagram is too large."]
39
+ };
40
+ let parsed;
41
+ try {
42
+ parsed = JSON.parse(source);
43
+ } catch {
44
+ return {
45
+ valid: false,
46
+ issues: ["Diagram must be valid JSON."]
47
+ };
48
+ }
49
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {
50
+ valid: false,
51
+ issues: ["Diagram must be a JSON object."]
52
+ };
53
+ const value = parsed;
54
+ if (value.version !== 1) return {
55
+ valid: false,
56
+ issues: ["Diagram must declare \"version\": 1."]
57
+ };
58
+ const issues = [];
59
+ const list = (key, read) => {
60
+ const raw = value[key];
61
+ if (raw === void 0) return [];
62
+ if (!Array.isArray(raw)) {
63
+ issues.push(`$.${key} must be an array.`);
64
+ return [];
65
+ }
66
+ if (raw.length > limits.maxElements) {
67
+ issues.push(`$.${key} has more than ${limits.maxElements} entries.`);
68
+ return [];
69
+ }
70
+ const out = [];
71
+ raw.forEach((item, index) => {
72
+ if (typeof item !== "object" || item === null || Array.isArray(item)) {
73
+ issues.push(`$.${key}[${index}] must be an object.`);
74
+ return;
75
+ }
76
+ const read_ = read(item, `$.${key}[${index}]`);
77
+ if (read_ !== void 0) out.push(read_);
78
+ });
79
+ return out;
80
+ };
81
+ const bodies = list("bodies", (item, path) => {
82
+ if (!isFinitePoint(item.at)) {
83
+ issues.push(`${path}.at must be two finite numbers.`);
84
+ return void 0;
85
+ }
86
+ const shape = item.shape === void 0 ? "box" : item.shape;
87
+ if (shape !== "box" && shape !== "circle" && shape !== "point") {
88
+ issues.push(`${path}.shape must be box, circle, or point.`);
89
+ return void 0;
90
+ }
91
+ return {
92
+ at: item.at,
93
+ shape,
94
+ width: num(item.width, 60),
95
+ height: num(item.height, 40),
96
+ radius: num(item.radius, 22),
97
+ rotation: num(item.rotation, 0),
98
+ ...typeof item.label === "string" ? { label: item.label } : {}
99
+ };
100
+ });
101
+ const vectors = list("vectors", (item, path) => {
102
+ const hasTo = isFinitePoint(item.to);
103
+ const hasPolar = typeof item.magnitude === "number" && Number.isFinite(item.magnitude);
104
+ if (!hasTo && !hasPolar) {
105
+ issues.push(`${path} needs either "to" or "magnitude" with "angle".`);
106
+ return void 0;
107
+ }
108
+ const style = item.style === void 0 ? "force" : item.style;
109
+ if (typeof style !== "string" || !Object.hasOwn(STYLE_VAR, style)) {
110
+ issues.push(`${path}.style is not one of ${Object.keys(STYLE_VAR).join(", ")}.`);
111
+ return void 0;
112
+ }
113
+ return {
114
+ ...isFinitePoint(item.from) ? { from: item.from } : {},
115
+ ...hasTo ? { to: item.to } : {},
116
+ ...hasPolar ? {
117
+ magnitude: item.magnitude,
118
+ angle: num(item.angle, 0)
119
+ } : {},
120
+ style,
121
+ dashed: item.dashed === true,
122
+ ...typeof item.label === "string" ? { label: item.label } : {}
123
+ };
124
+ });
125
+ const surfaces = list("surfaces", (item, path) => {
126
+ if (!isFinitePoint(item.from) || !isFinitePoint(item.to)) {
127
+ issues.push(`${path}.from and ${path}.to must be two finite numbers each.`);
128
+ return void 0;
129
+ }
130
+ return {
131
+ from: item.from,
132
+ to: item.to,
133
+ hatch: item.hatch !== false,
134
+ ...typeof item.label === "string" ? { label: item.label } : {}
135
+ };
136
+ });
137
+ const angles = list("angles", (item, path) => {
138
+ if (!isFinitePoint(item.at)) {
139
+ issues.push(`${path}.at must be two finite numbers.`);
140
+ return void 0;
141
+ }
142
+ if (typeof item.from !== "number" || typeof item.to !== "number") {
143
+ issues.push(`${path}.from and ${path}.to must be angles in degrees.`);
144
+ return void 0;
145
+ }
146
+ return {
147
+ at: item.at,
148
+ from: item.from,
149
+ to: item.to,
150
+ radius: num(item.radius, 28),
151
+ ...typeof item.label === "string" ? { label: item.label } : {}
152
+ };
153
+ });
154
+ if (bodies.length + vectors.length + surfaces.length === 0) issues.push("Diagram must contain at least one body, vector, or surface.");
155
+ if (issues.length > 0) return {
156
+ valid: false,
157
+ issues
158
+ };
159
+ return {
160
+ valid: true,
161
+ data: {
162
+ version: 1,
163
+ ...Array.isArray(value.view) && value.view.length === 4 && value.view.every((v) => typeof v === "number" && Number.isFinite(v)) ? { view: value.view } : {},
164
+ ...typeof value.title === "string" ? { title: value.title } : {},
165
+ bodies,
166
+ vectors,
167
+ surfaces,
168
+ angles
169
+ }
170
+ };
171
+ }
172
+ /** The box the diagram occupies, widened to hold everything it draws. */
173
+ function viewBoxOf(diagram) {
174
+ if (diagram.view) return diagram.view;
175
+ const xs = [];
176
+ const ys = [];
177
+ const note = (point) => {
178
+ xs.push(point[0]);
179
+ ys.push(point[1]);
180
+ };
181
+ for (const body of diagram.bodies ?? []) {
182
+ const halfWidth = (body.shape === "circle" ? body.radius ?? 22 : body.width ?? 60) / 2;
183
+ const halfHeight = (body.shape === "circle" ? body.radius ?? 22 : body.height ?? 40) / 2;
184
+ note([body.at[0] - halfWidth, body.at[1] - halfHeight]);
185
+ note([body.at[0] + halfWidth, body.at[1] + halfHeight]);
186
+ }
187
+ for (const surface of diagram.surfaces ?? []) {
188
+ note(surface.from);
189
+ note(surface.to);
190
+ }
191
+ for (const vector of diagram.vectors ?? []) {
192
+ const start = vector.from ?? diagram.bodies?.[0]?.at ?? [0, 0];
193
+ note(start);
194
+ note(tipOf(vector, start));
195
+ }
196
+ if (xs.length === 0) return [
197
+ 0,
198
+ 0,
199
+ 100,
200
+ 100
201
+ ];
202
+ const pad = 40;
203
+ return [
204
+ Math.min(...xs) - pad,
205
+ Math.min(...ys) - pad,
206
+ Math.max(...xs) + pad,
207
+ Math.max(...ys) + pad
208
+ ];
209
+ }
210
+ /** Where a vector ends, whether it was given as a point or as a magnitude and angle. */
211
+ function tipOf(vector, start) {
212
+ if (vector.to) return vector.to;
213
+ const radians = (vector.angle ?? 0) * Math.PI / 180;
214
+ const length = vector.magnitude ?? 0;
215
+ return [start[0] + length * Math.cos(radians), start[1] + length * Math.sin(radians)];
216
+ }
217
+ function round(value) {
218
+ return (Math.round(value * 100) / 100).toString();
219
+ }
220
+ /**
221
+ * Draw the diagram.
222
+ *
223
+ * The y axis is flipped once, in the outer transform, so a declaration can say "gravity points to
224
+ * y = -1" and mean down — a mechanics problem is stated in maths coordinates, not screen ones.
225
+ */
226
+ function renderPhysicsSVG(diagram, options = {}) {
227
+ const limits = {
228
+ ...DEFAULTS,
229
+ ...options
230
+ };
231
+ const [minX, minY, maxX, maxY] = viewBoxOf(diagram);
232
+ const width = Math.max(1, maxX - minX);
233
+ const height = Math.max(1, maxY - minY);
234
+ const parts = [];
235
+ for (const surface of diagram.surfaces ?? []) {
236
+ parts.push(`<line x1="${round(surface.from[0])}" y1="${round(surface.from[1])}" x2="${round(surface.to[0])}" y2="${round(surface.to[1])}" class="aigui-physics-surface" />`);
237
+ if (surface.hatch) {
238
+ const dx = surface.to[0] - surface.from[0];
239
+ const dy = surface.to[1] - surface.from[1];
240
+ const span = Math.hypot(dx, dy) || 1;
241
+ const steps = Math.min(limits.maxElements, Math.max(2, Math.floor(span / 14)));
242
+ const nx = -dy / span;
243
+ const ny = dx / span;
244
+ for (let step = 0; step <= steps; step += 1) {
245
+ const t = step / steps;
246
+ const x = surface.from[0] + dx * t;
247
+ const y = surface.from[1] + dy * t;
248
+ parts.push(`<line x1="${round(x)}" y1="${round(y)}" x2="${round(x - nx * 9 - dx / span * 6)}" y2="${round(y - ny * 9 - dy / span * 6)}" class="aigui-physics-hatch" />`);
249
+ }
250
+ }
251
+ if (surface.label) parts.push(`<text x="${round((surface.from[0] + surface.to[0]) / 2)}" y="${round((surface.from[1] + surface.to[1]) / 2 - 12)}" class="aigui-physics-label" transform="scale(1,-1)" transform-origin="${round((surface.from[0] + surface.to[0]) / 2)} ${round((surface.from[1] + surface.to[1]) / 2 - 12)}">${escapeHtml(surface.label)}</text>`);
252
+ }
253
+ for (const angle of diagram.angles ?? []) {
254
+ const radius = angle.radius ?? 28;
255
+ const start = angle.from * Math.PI / 180;
256
+ const end = angle.to * Math.PI / 180;
257
+ const large = Math.abs(angle.to - angle.from) > 180 ? 1 : 0;
258
+ const sweep = angle.to > angle.from ? 1 : 0;
259
+ const x1 = angle.at[0] + radius * Math.cos(start);
260
+ const y1 = angle.at[1] + radius * Math.sin(start);
261
+ const x2 = angle.at[0] + radius * Math.cos(end);
262
+ const y2 = angle.at[1] + radius * Math.sin(end);
263
+ parts.push(`<path d="M ${round(x1)} ${round(y1)} A ${round(radius)} ${round(radius)} 0 ${large} ${sweep} ${round(x2)} ${round(y2)}" class="aigui-physics-angle" />`);
264
+ if (angle.label) {
265
+ const mid = (start + end) / 2;
266
+ const lx = angle.at[0] + (radius + 14) * Math.cos(mid);
267
+ const ly = angle.at[1] + (radius + 14) * Math.sin(mid);
268
+ parts.push(`<text x="${round(lx)}" y="${round(ly)}" class="aigui-physics-label" transform="scale(1,-1)" transform-origin="${round(lx)} ${round(ly)}">${escapeHtml(angle.label)}</text>`);
269
+ }
270
+ }
271
+ for (const body of diagram.bodies ?? []) {
272
+ const rotate = body.rotation ? ` transform="rotate(${round(body.rotation)} ${round(body.at[0])} ${round(body.at[1])})"` : "";
273
+ if (body.shape === "circle") parts.push(`<circle cx="${round(body.at[0])}" cy="${round(body.at[1])}" r="${round(body.radius ?? 22)}" class="aigui-physics-body"${rotate} />`);
274
+ else if (body.shape === "point") parts.push(`<circle cx="${round(body.at[0])}" cy="${round(body.at[1])}" r="4" class="aigui-physics-point"${rotate} />`);
275
+ else {
276
+ const w = body.width ?? 60;
277
+ const h = body.height ?? 40;
278
+ parts.push(`<rect x="${round(body.at[0] - w / 2)}" y="${round(body.at[1] - h / 2)}" width="${round(w)}" height="${round(h)}" class="aigui-physics-body"${rotate} />`);
279
+ }
280
+ if (body.label) parts.push(`<text x="${round(body.at[0])}" y="${round(body.at[1])}" class="aigui-physics-body-label" transform="scale(1,-1)" transform-origin="${round(body.at[0])} ${round(body.at[1])}">${escapeHtml(body.label)}</text>`);
281
+ }
282
+ for (const vector of diagram.vectors ?? []) {
283
+ const start = vector.from ?? diagram.bodies?.[0]?.at ?? [0, 0];
284
+ const tip = tipOf(vector, start);
285
+ const style = vector.style ?? "force";
286
+ const dash = vector.dashed ? " stroke-dasharray=\"6 4\"" : "";
287
+ parts.push(`<line x1="${round(start[0])}" y1="${round(start[1])}" x2="${round(tip[0])}" y2="${round(tip[1])}" class="aigui-physics-vector" style="stroke:var(${STYLE_VAR[style]}, currentColor)" marker-end="url(#aigui-physics-arrow-${style})"${dash} />`);
288
+ if (vector.label) {
289
+ const lx = tip[0] + (tip[0] - start[0]) * .08 + 8;
290
+ const ly = tip[1] + (tip[1] - start[1]) * .08;
291
+ parts.push(`<text x="${round(lx)}" y="${round(ly)}" class="aigui-physics-label" style="fill:var(${STYLE_VAR[style]}, currentColor)" transform="scale(1,-1)" transform-origin="${round(lx)} ${round(ly)}">${escapeHtml(vector.label)}</text>`);
292
+ }
293
+ }
294
+ const markers = Object.entries(STYLE_VAR).map(([style, variable]) => `<marker id="aigui-physics-arrow-${style}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="var(${variable}, currentColor)" /></marker>`).join("");
295
+ const title = diagram.title ? `<title>${escapeHtml(diagram.title)}</title>` : "";
296
+ return [
297
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${round(minX)} ${round(minY)} ${round(width)} ${round(height)}"`,
298
+ ` width="100%" style="max-width:${limits.width}px;max-height:${limits.height}px;display:block;margin:auto"`,
299
+ ` role="img" aria-label="${escapeHtml(diagram.title ?? "Physics diagram")}" data-aigui-physics="diagram">`,
300
+ title,
301
+ `<defs>${markers}</defs>`,
302
+ `<g transform="translate(0, ${round(minY + maxY)}) scale(1, -1)">`,
303
+ parts.join(""),
304
+ "</g></svg>"
305
+ ].join("");
306
+ }
307
+ function physicsPromptSpec(options = {}) {
308
+ const limits = {
309
+ ...DEFAULTS,
310
+ ...options
311
+ };
312
+ return [
313
+ "Force and vector diagrams (one fenced block): ```physics <strict JSON>```.",
314
+ "Root: {\"version\":1,\"title\":\"...\"?,\"view\":[minX,minY,maxX,maxY]?,\"bodies\":[...]?,\"surfaces\":[...]?,\"vectors\":[...]?,\"angles\":[...]?}. No unknown fields.",
315
+ "Body: {\"at\":[x,y],\"shape\":\"box|circle|point\"?,\"width\":n?,\"height\":n?,\"radius\":n?,\"rotation\":deg?,\"label\":\"...\"?}.",
316
+ "Surface: {\"from\":[x,y],\"to\":[x,y],\"hatch\":true?,\"label\":\"...\"?} — hatching marks the solid side.",
317
+ "Vector: {\"from\":[x,y]?,\"to\":[x,y]?,\"magnitude\":n?,\"angle\":deg?,\"style\":\"force|velocity|acceleration|component|dimension\"?,\"dashed\":true?,\"label\":\"...\"?}. Give either \"to\" or \"magnitude\" with \"angle\". Omit \"from\" to start at the first body.",
318
+ "Angle: {\"at\":[x,y],\"from\":deg,\"to\":deg,\"radius\":n?,\"label\":\"...\"?}.",
319
+ "y increases upwards and angles are counter-clockwise from the positive x axis, as a mechanics problem states them: gravity points at angle -90.",
320
+ `Diagram source is local JSON only, at most ${limits.maxSourceBytes} UTF-8 bytes. Never emit URLs, scripts, remote resources, HTML, or executable content.`,
321
+ "Use this for free-body diagrams, resolved components, inclines and projectile setups — the picture a textbook draws. It is a drawing, not a simulation: state the coordinates you mean."
322
+ ].join("\n");
323
+ }
324
+ const physicsCss = `
325
+ [data-aigui-physics] { max-width: 100%; height: auto; overflow: visible; color: currentColor; }
326
+ [data-aigui-physics] .aigui-physics-body { fill: var(--aigui-physics-body, color-mix(in srgb, currentColor 12%, transparent)); stroke: currentColor; stroke-width: 1.5; }
327
+ [data-aigui-physics] .aigui-physics-point { fill: currentColor; stroke: none; }
328
+ [data-aigui-physics] .aigui-physics-surface { stroke: currentColor; stroke-width: 2; }
329
+ [data-aigui-physics] .aigui-physics-hatch { stroke: currentColor; stroke-width: 1; opacity: .55; }
330
+ [data-aigui-physics] .aigui-physics-angle { fill: none; stroke: var(--aigui-physics-dimension, currentColor); stroke-width: 1.2; }
331
+ [data-aigui-physics] .aigui-physics-vector { stroke-width: 2; stroke-linecap: round; }
332
+ [data-aigui-physics] .aigui-physics-label { font-size: 13px; font-family: inherit; dominant-baseline: middle; }
333
+ [data-aigui-physics] .aigui-physics-body-label { font-size: 13px; font-family: inherit; text-anchor: middle; dominant-baseline: middle; fill: currentColor; }
334
+ `;
335
+ function errorHtml(issue) {
336
+ return {
337
+ kind: "html",
338
+ html: `<pre data-aigui-physics-error>${escapeHtml(issue)}</pre>`
339
+ };
340
+ }
341
+ function physics(options = {}) {
342
+ const render = (node) => {
343
+ const parsed = parsePhysicsDiagram(node.content ?? "", options);
344
+ if (!parsed.valid) return errorHtml(parsed.issues[0] ?? "Invalid diagram.");
345
+ try {
346
+ return {
347
+ kind: "html",
348
+ html: renderPhysicsSVG(parsed.data, options),
349
+ trusted: true
350
+ };
351
+ } catch {
352
+ return errorHtml("Diagram could not be drawn.");
353
+ }
354
+ };
355
+ return {
356
+ name: "physics",
357
+ nodeRenderers: { physics: render },
358
+ css: physicsCss,
359
+ promptSpec: physicsPromptSpec(options)
360
+ };
361
+ }
362
+
363
+ //#endregion
364
+ export { parsePhysicsDiagram, physics, physicsCss, physicsPromptSpec, renderPhysicsSVG };
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@ai-gui/plugin-physics",
3
+ "version": "0.10.0",
4
+ "description": "Force and vector diagrams for teaching mechanics, rendered as SVG from a declarative block.",
5
+ "keywords": [
6
+ "llm",
7
+ "ai",
8
+ "chemistry",
9
+ "molecule",
10
+ "smiles",
11
+ "molfile",
12
+ "3d",
13
+ "plugin",
14
+ "aigui"
15
+ ],
16
+ "license": "MIT",
17
+ "author": "Liang Li <ll_faw@hotmail.com>",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/liliang-cn/aigui.git",
21
+ "directory": "packages/plugin-molecule"
22
+ },
23
+ "homepage": "https://github.com/liliang-cn/aigui#readme",
24
+ "bugs": "https://github.com/liliang-cn/aigui/issues",
25
+ "type": "module",
26
+ "sideEffects": false,
27
+ "engines": {
28
+ "node": ">=18"
29
+ },
30
+ "main": "./dist/index.cjs",
31
+ "module": "./dist/index.js",
32
+ "types": "./dist/index.d.ts",
33
+ "exports": {
34
+ ".": {
35
+ "import": {
36
+ "types": "./dist/index.d.ts",
37
+ "default": "./dist/index.js"
38
+ },
39
+ "require": {
40
+ "types": "./dist/index.d.cts",
41
+ "default": "./dist/index.cjs"
42
+ }
43
+ }
44
+ },
45
+ "files": [
46
+ "dist",
47
+ "README.md",
48
+ "LICENSE",
49
+ "CHANGELOG.md"
50
+ ],
51
+ "publishConfig": {
52
+ "access": "public"
53
+ },
54
+ "dependencies": {
55
+ "@ai-gui/core": "0.10.0"
56
+ },
57
+ "scripts": {
58
+ "build": "tsdown",
59
+ "test": "pnpm --dir ../.. exec vitest run --project plugin-physics",
60
+ "typecheck": "tsc --noEmit"
61
+ }
62
+ }