diagram kit: lines from their written equation, regular polygons, exterior angles; hide unused points

Found in a real chat with gpt-4.1-mini: the model writes a line's coefficients
as ax + by + c = 0, so eq now means that and its own earlier outputs render
right; it can also just copy the equation text (3x + 5y = 4, y = 12x - 16).
regularPolygon builds the vertices (the model's own pentagon was not regular),
exteriorAngle draws the extension of the side (the model always drew the
interior angle). Named points no item refers to are no longer drawn, and a
computed root or vertex skips its label when the model already put a point there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-29 02:37:50 +03:30
parent ec4c844531
commit dffa940f8a
2 changed files with 93 additions and 7 deletions

View File

@@ -166,7 +166,51 @@ function RichLabel({ x, y, text, fill, size = 11, anchor = 'middle' }: { x: numb
/* ─── خودِ شکل ───────────────────────────────────────────────────────────── */ /* ─── خودِ شکل ───────────────────────────────────────────────────────────── */
export function Plane({ spec }: { spec: PlaneSpec }) { /**
* قطعه‌هایی که به قطعه‌های ساده‌تر باز می‌شوند، پیش از هر حسابی:
* regularPolygon → نقطه‌های polar با زاویه‌های مساوی + polygon (مدل مختصاتِ رأسِ پنج‌ضلعیِ
* منتظم را خودش حدس زده بود و شکل منتظم نشد)
* exteriorAngle → نقطه‌ی پنهان روی امتدادِ ضلع + پاره‌خطِ خط‌چین + angle (مدل با angle همیشه
* زاویه‌ی داخلی را می‌کشید)
*/
function expand(spec: PlaneSpec): PlaneSpec {
if (!spec.items.some((i) => i.type === 'regularPolygon' || i.type === 'exteriorAngle')) return spec;
const points: Record<string, PointDef> = { ...spec.points };
const items: PlaneItem[] = [];
spec.items.forEach((item, idx) => {
if (item.type === 'regularPolygon') {
const center = item.center ?? '_rc' + idx;
if (!item.center) points['_rc' + idx] = [0, 0];
// پیش‌فرض: ضلعِ پایین افقی، مثلِ کتاب
const start = item.start ?? -90 - 180 / item.n;
const names = Array.from({ length: item.n }, (_, i) => item.names?.[i] ?? `_r${idx}_${i}`);
names.forEach((name, i) => { points[name] = { polar: [item.r, start + (360 * i) / item.n], from: center }; });
items.push({ type: 'polygon', points: names, fill: item.fill, color: item.color, dashed: item.dashed, label: item.label });
} else if (item.type === 'exteriorAngle') {
const ext = `_ext${idx}`;
points[ext] = { along: [item.from, item.vertex, 1.7] };
items.push({ type: 'segment', from: item.vertex, to: ext, dashed: true, color: 'gray' });
items.push({ type: 'angle', vertex: item.vertex, from: ext, to: item.to, label: item.label, color: item.color });
} else items.push(item);
});
return { ...spec, points, items };
}
/** نام‌هایی که یک item یا تعریفِ نقطه‌ی دیگری به آن‌ها ارجاع داده. */
function referencedNames(spec: PlaneSpec): Set<string> {
const out = new Set<string>();
const walk = (v: unknown) => {
if (typeof v === 'string') { if (v in spec.points) out.add(v); }
else if (Array.isArray(v)) v.forEach(walk);
else if (v && typeof v === 'object') Object.values(v).forEach(walk);
};
spec.items.forEach(walk);
Object.values(spec.points).forEach(walk);
return out;
}
export function Plane({ spec: raw }: { spec: PlaneSpec }) {
const spec = expand(raw);
// شناسه‌ی یکتا: چند شکل در یک گفتگو نباید clipPathِ همدیگر را بگیرند. // شناسه‌ی یکتا: چند شکل در یک گفتگو نباید clipPathِ همدیگر را بگیرند.
const clipId = `dg-plot-${useId().replace(/:/g, '')}`; const clipId = `dg-plot-${useId().replace(/:/g, '')}`;
const { named, ref } = resolvePoints(spec); const { named, ref } = resolvePoints(spec);
@@ -248,7 +292,7 @@ export function Plane({ spec }: { spec: PlaneSpec }) {
} }
case 'segment': case 'arrow': case 'vector': add(ref(item.from)); add(ref(item.to)); break; case 'segment': case 'arrow': case 'vector': add(ref(item.from)); add(ref(item.to)); break;
case 'ray': add(ref(item.from)); add(ref(item.through)); break; case 'ray': add(ref(item.from)); add(ref(item.through)); break;
case 'line': { const [a, b] = lineEnds(item, ref); if (!item.eq) { add(a); add(b); } else if (a) add(a); break; } case 'line': { const [a, b] = lineEnds(item, ref); if (!item.eq && !item.equation) { add(a); add(b); } else if (a) add(a); break; }
case 'polygon': item.points.forEach((p) => add(ref(p))); break; case 'polygon': item.points.forEach((p) => add(ref(p))); break;
case 'point': add(ref(item.point)); break; case 'point': add(ref(item.point)); break;
case 'text': add(item.at); break; case 'text': add(item.at); break;
@@ -578,7 +622,10 @@ export function Plane({ spec }: { spec: PlaneSpec }) {
// بیش از چهار نقطه از یک نوع (ریشه‌های سینوس) فقط نقطه می‌گیرند، نه برچسب؛ وگرنه روی هم می‌افتند. // بیش از چهار نقطه از یک نوع (ریشه‌های سینوس) فقط نقطه می‌گیرند، نه برچسب؛ وگرنه روی هم می‌افتند.
const kindCount = new Map<string, number>(); const kindCount = new Map<string, number>();
markPts.forEach((m) => kindCount.set(m.kind === 'max' || m.kind === 'min' ? 'ext' : m.kind, (kindCount.get(m.kind === 'max' || m.kind === 'min' ? 'ext' : m.kind) ?? 0) + 1)); markPts.forEach((m) => kindCount.set(m.kind === 'max' || m.kind === 'min' ? 'ext' : m.kind, (kindCount.get(m.kind === 'max' || m.kind === 'min' ? 'ext' : m.kind) ?? 0) + 1));
// اگر مدل خودش روی همان ریشه یا رأس نقطه‌ی نام‌دار گذاشته، برچسبِ ما تکرار نشود
const namedXY = [...named.values()];
for (const m of markPts) { for (const m of markPts) {
if (namedXY.some((q) => Math.abs(q[0] - m.xy[0]) < 1e-3 && Math.abs(q[1] - m.xy[1]) < 1e-3)) continue;
const [px, py] = P(m.xy); const [px, py] = P(m.xy);
if (px < 0 || px > W || py < 0 || py > H) continue; if (px < 0 || px > W || py < 0 || py > H) continue;
layers.points.push(<circle key={k()} cx={px} cy={py} r={4.3} fill={m.color} stroke="#fff" strokeWidth={1.6} />); layers.points.push(<circle key={k()} cx={px} cy={py} r={4.3} fill={m.color} stroke="#fff" strokeWidth={1.6} />);
@@ -609,8 +656,13 @@ export function Plane({ spec }: { spec: PlaneSpec }) {
if (label) { const [lx, ly] = away(xy, 14); layers.labels.push(<RichLabel key={k()} x={lx} y={ly} text={label} fill={col} />); } if (label) { const [lx, ly] = away(xy, 14); layers.labels.push(<RichLabel key={k()} x={lx} y={ly} text={label} fill={col} />); }
}; };
const imageNames = new Set(images.flatMap((im) => im.names)); const imageNames = new Set(images.flatMap((im) => im.names));
// نقطه‌ای که هیچ تکه‌ای به آن ارجاع نداده رسم نمی‌شود (مدل هفت نقطه‌ی امتداد تعریف کرده
// بود که هیچ‌جا به کار نرفتند و شکل را شلوغ کردند) — مگر شکل فقط از نقطه ساخته شده باشد.
const used = referencedNames(spec);
const onlyPoints = spec.items.length === 0;
named.forEach((xy, name) => { named.forEach((xy, name) => {
if (name.startsWith('_') || imageNames.has(name)) return; if (name.startsWith('_') || imageNames.has(name)) return;
if (!onlyPoints && !used.has(name)) return;
const it = explicit.get(name); const it = explicit.get(name);
// مبدأ روی محور خودش «۰» دارد؛ فقط اگر مدل صریحاً نقطه‌اش را خواسته باشد برچسب می‌گیرد. // مبدأ روی محور خودش «۰» دارد؛ فقط اگر مدل صریحاً نقطه‌اش را خواسته باشد برچسب می‌گیرد.
if (spec.axes && !it && Math.abs(xy[0]) < 1e-9 && Math.abs(xy[1]) < 1e-9) return; if (spec.axes && !it && Math.abs(xy[0]) < 1e-9 && Math.abs(xy[1]) < 1e-9) return;
@@ -634,15 +686,47 @@ export function Plane({ spec }: { spec: PlaneSpec }) {
* شیب را کد حساب می‌کند، چون مدل در بازنویسیِ 3x + 5y = 4 به y = … علامت را گم کرده بود). * شیب را کد حساب می‌کند، چون مدل در بازنویسیِ 3x + 5y = 4 به y = … علامت را گم کرده بود).
*/ */
function lineEnds(item: Extract<PlaneItem, { type: 'line' }>, ref: (r: Ref) => XY | null): [XY | null, XY | null] { function lineEnds(item: Extract<PlaneItem, { type: 'line' }>, ref: (r: Ref) => XY | null): [XY | null, XY | null] {
if (item.eq) { const coef = item.equation ? parseLinear(item.equation) : item.eq ?? null;
const [a, b, c] = item.eq; if (coef) {
if (Math.abs(b) > 1e-12) return [[0, c / b], [1, (c - a) / b]]; // ax + by + c = 0
if (Math.abs(a) > 1e-12) return [[c / a, 0], [c / a, 1]]; const [a, b, c] = coef;
if (Math.abs(b) > 1e-12) return [[0, -c / b], [1, (-c - a) / b]];
if (Math.abs(a) > 1e-12) return [[-c / a, 0], [-c / a, 1]];
return [null, null]; return [null, null];
} }
return item.through ? [ref(item.through[0]), ref(item.through[1])] : [null, null]; return item.through ? [ref(item.through[0]), ref(item.through[1])] : [null, null];
} }
/**
* «3x + 5y = 4»، «y = 12x − 16»، «2y = x + 1»، «x = 2.5» → [a, b, c] با ax + by + c = 0.
* هر چیزِ غیرخطی (x²، sin) null است و خط کشیده نمی‌شود.
*/
export function parseLinear(src: string): [number, number, number] | null {
const text = toLatin(src).replace(/\s+/g, '').replace(/[−–]/g, '-').replace(/\*/g, '');
const sides = text.split('=');
if (sides.length !== 2) return null;
const side = (t: string): [number, number, number] | null => {
if (!t) return null;
const terms = t.replace(/-/g, '+-').split('+').filter(Boolean);
const acc: [number, number, number] = [0, 0, 0];
for (const term of terms) {
const m = /^(-?)(\d*\.?\d*)(?:\/(\d+\.?\d*))?([xy]?)$/.exec(term);
if (!m || (!m[2] && !m[4])) return null;
let v = m[2] ? Number(m[2]) : 1;
if (m[3]) v /= Number(m[3]);
if (m[1]) v = -v;
acc[m[4] === 'x' ? 0 : m[4] === 'y' ? 1 : 2] += v;
}
return acc;
};
const l = side(sides[0]), r = side(sides[1]);
if (!l || !r) return null;
const out: [number, number, number] = [l[0] - r[0], l[1] - r[1], l[2] - r[2]];
return Math.abs(out[0]) + Math.abs(out[1]) > 1e-12 ? out : null;
}
const toLatin = (s: string) => s.replace(/[۰-۹]/g, (d) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(d))).replace(/[٠-٩]/g, (d) => String('٠١٢٣٤٥٦٧٨٩'.indexOf(d)));
/** علامتِ تساویِ ضلع: یک، دو یا سه خطِ کوچک عمود بر وسطِ پاره‌خط. */ /** علامتِ تساویِ ضلع: یک، دو یا سه خطِ کوچک عمود بر وسطِ پاره‌خط. */
function Ticks({ a, b, n, col }: { a: XY; b: XY; n: number; col: string }) { function Ticks({ a, b, n, col }: { a: XY; b: XY; n: number; col: string }) {
const [mx, my] = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]; const [mx, my] = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];

View File

@@ -38,7 +38,7 @@ export type PlaneItem =
| ({ type: 'point'; point: Ref; open?: boolean } & Style) | ({ type: 'point'; point: Ref; open?: boolean } & Style)
| ({ type: 'segment'; from: Ref; to: Ref; ticks?: number } & Style) | ({ type: 'segment'; from: Ref; to: Ref; ticks?: number } & Style)
| ({ type: 'arrow' | 'vector'; from: Ref; to: Ref } & Style) | ({ type: 'arrow' | 'vector'; from: Ref; to: Ref } & Style)
| ({ type: 'line'; through?: [Ref, Ref]; /** ax + by = c */ eq?: [number, number, number] } & Style) | ({ type: 'line'; through?: [Ref, Ref]; /** «3x + 5y = 4»، همان‌طور که در مسئله نوشته شده */ equation?: string; /** ax + by + c = 0 */ eq?: [number, number, number] } & Style)
| ({ type: 'ray'; from: Ref; through: Ref } & Style) | ({ type: 'ray'; from: Ref; through: Ref } & Style)
| ({ type: 'vline'; x: number } & Style) | ({ type: 'vline'; x: number } & Style)
| ({ type: 'hline'; y: number } & Style) | ({ type: 'hline'; y: number } & Style)
@@ -47,6 +47,8 @@ export type PlaneItem =
| ({ type: 'angle'; vertex: Ref; from: Ref; to: Ref; arcs?: number } & Style) | ({ type: 'angle'; vertex: Ref; from: Ref; to: Ref; arcs?: number } & Style)
| ({ type: 'rightAngle'; vertex: Ref; from: Ref; to: Ref } & Style) | ({ type: 'rightAngle'; vertex: Ref; from: Ref; to: Ref } & Style)
| ({ type: 'polygon'; points: Ref[]; fill?: boolean } & Style) | ({ type: 'polygon'; points: Ref[]; fill?: boolean } & Style)
| ({ type: 'regularPolygon'; n: number; r: number; center?: Ref; start?: number; names?: string[]; fill?: boolean } & Style)
| ({ type: 'exteriorAngle'; vertex: Ref; from: Ref; to: Ref } & Style)
| ({ type: 'function'; expr: string; from: number; to: number; marks?: FunctionMark[] } & Style) | ({ type: 'function'; expr: string; from: number; to: number; marks?: FunctionMark[] } & Style)
| ({ type: 'piecewise'; pieces: { expr: string; from: number; to: number; closedFrom?: boolean; closedTo?: boolean }[] } & Style) | ({ type: 'piecewise'; pieces: { expr: string; from: number; to: number; closedFrom?: boolean; closedTo?: boolean }[] } & Style)
| ({ type: 'tangent'; expr: string; at: number } & Style) | ({ type: 'tangent'; expr: string; at: number } & Style)