301 lines
17 KiB
TypeScript
301 lines
17 KiB
TypeScript
import type { ColorName, PlaneItem, PointDef, Ref } from './types';
|
||
|
||
/**
|
||
* مثلث و چهارضلعی از روی دادههای مسئله، نه از روی مختصاتِ حدسیِ مدل.
|
||
*
|
||
* مدل میگوید «مثلثی با AB = 6، AC = 8 و زاویهی A = 90°» یا «ذوزنقه با قاعدههای ۸ و ۵ و
|
||
* ارتفاعِ ۳»؛ اینجا با قانونِ کسینوسها و سینوسها (مثلث) یا فرمولِ هر چهارضلعی رأسها
|
||
* حساب میشوند و به نقطه + polygon + برچسب باز میشوند. سنجشِ ۲۰۲۶-۰۹-۲۸: مربعی که مدل
|
||
* مختصاتش را خودش نوشت کج درآمد؛ این ابزار آن خطا را از دستِ مدل بیرون میآورد.
|
||
* دادهی ناقص با پیشفرضِ ساده پر میشود (ضلعِ نامعلوم = میانگینِ ضلعهای معلوم)، پس
|
||
* شکل همیشه کشیده میشود؛ دادهی متناقض (۳، ۴، ۱۰) مثلثی نمیسازد و item حذف میشود.
|
||
*/
|
||
|
||
type XY = [number, number];
|
||
const RAD = Math.PI / 180;
|
||
|
||
export interface TriangleItem {
|
||
type: 'triangle';
|
||
names: [string, string, string];
|
||
/** کلید: دو نامِ رأس («AB» یا «BA») */
|
||
sides?: Record<string, number>;
|
||
/** کلید: نامِ رأس، درجه */
|
||
angles?: Record<string, number>;
|
||
/** برچسبِ دلخواه برای ضلع («BC»: «x»)، رأس («A»: «?») یا ارتفاع («h») */
|
||
labels?: Record<string, string>;
|
||
/** رأسی که ارتفاع از آن بر ضلعِ روبهرو کشیده شود */
|
||
altitude?: string;
|
||
/** جای رأسِ اول */
|
||
at?: XY;
|
||
color?: ColorName;
|
||
fill?: boolean;
|
||
}
|
||
|
||
export interface QuadItem {
|
||
type: 'quad';
|
||
shape: 'rectangle' | 'square' | 'parallelogram' | 'rhombus' | 'trapezoid';
|
||
names: [string, string, string, string];
|
||
w?: number; h?: number; side?: number; angle?: number; top?: number; d1?: number; d2?: number;
|
||
right?: boolean;
|
||
at?: XY;
|
||
labels?: Record<string, string>;
|
||
color?: ColorName;
|
||
fill?: boolean;
|
||
}
|
||
|
||
interface Solved { a: number; b: number; c: number; A: number; B: number; C: number }
|
||
|
||
/** a روبهروی A و ...؛ زاویهها به درجه. null یعنی دادهها مثلث نمیسازند. */
|
||
export function solveTriangle(sides: Partial<Record<'a' | 'b' | 'c', number>>, angles: Partial<Record<'A' | 'B' | 'C', number>>): Solved | null {
|
||
const s: Record<string, number | undefined> = { ...sides };
|
||
const g: Record<string, number | undefined> = { ...angles };
|
||
const opp = { a: 'A', b: 'B', c: 'C' } as const;
|
||
const known = (o: Record<string, number | undefined>, k: string) => o[k] !== undefined && Number.isFinite(o[k]!);
|
||
const step = () => {
|
||
const ks = ['A', 'B', 'C'].filter((k) => known(g, k));
|
||
if (ks.length === 2) {
|
||
const rest = 180 - ks.reduce((t, k) => t + g[k]!, 0);
|
||
if (rest <= 0) return false;
|
||
g[['A', 'B', 'C'].find((k) => !known(g, k))!] = rest;
|
||
}
|
||
const ss = (['a', 'b', 'c'] as const).filter((k) => known(s, k));
|
||
if (ss.length === 3 && ['A', 'B', 'C'].some((k) => !known(g, k))) {
|
||
const { a, b, c } = s as Record<string, number>;
|
||
if (a + b <= c || a + c <= b || b + c <= a) return false;
|
||
g.A = Math.acos((b * b + c * c - a * a) / (2 * b * c)) / RAD;
|
||
g.B = Math.acos((a * a + c * c - b * b) / (2 * a * c)) / RAD;
|
||
g.C = 180 - g.A - g.B;
|
||
}
|
||
// دو ضلع و زاویهی بینشان
|
||
for (const [x, y, z] of [['b', 'c', 'a'], ['a', 'c', 'b'], ['a', 'b', 'c']] as const) {
|
||
if (known(s, x) && known(s, y) && !known(s, z) && known(g, opp[z])) {
|
||
s[z] = Math.sqrt(s[x]! ** 2 + s[y]! ** 2 - 2 * s[x]! * s[y]! * Math.cos(g[opp[z]]! * RAD));
|
||
}
|
||
}
|
||
// قانونِ سینوسها
|
||
const pair = (['a', 'b', 'c'] as const).find((k) => known(s, k) && known(g, opp[k]));
|
||
if (pair) {
|
||
const k = s[pair]! / Math.sin(g[opp[pair]]! * RAD);
|
||
for (const side of ['a', 'b', 'c'] as const) {
|
||
const ang = opp[side];
|
||
if (!known(s, side) && known(g, ang)) s[side] = k * Math.sin(g[ang]! * RAD);
|
||
else if (known(s, side) && !known(g, ang)) {
|
||
const r = s[side]! / k;
|
||
if (r > 1 + 1e-9) return false;
|
||
g[ang] = Math.asin(Math.min(1, r)) / RAD;
|
||
}
|
||
}
|
||
}
|
||
return true;
|
||
};
|
||
const done = () => ['a', 'b', 'c'].every((k) => known(s, k)) && ['A', 'B', 'C'].every((k) => known(g, k));
|
||
const count = () => ['a', 'b', 'c'].filter((k) => known(s, k)).length + ['A', 'B', 'C'].filter((k) => known(g, k)).length;
|
||
for (let round = 0; round < 12 && !done(); round++) {
|
||
const before = count();
|
||
if (!step()) return null;
|
||
if (done()) break;
|
||
if (count() > before) continue;
|
||
// گیر کرد: دادهی ناقص. ضلعِ نامعلومِ اول = میانگینِ ضلعهای معلوم (متساویالساقین/متساویالاضلاع)
|
||
const ss = (['a', 'b', 'c'] as const).filter((k) => known(s, k));
|
||
if (!ss.length) { s.c = 4; continue; }
|
||
const missing = (['a', 'b', 'c'] as const).find((k) => !known(s, k));
|
||
if (missing) s[missing] = ss.reduce((t, k) => t + s[k]!, 0) / ss.length;
|
||
}
|
||
if (!done()) return null;
|
||
const out = { a: s.a!, b: s.b!, c: s.c!, A: g.A!, B: g.B!, C: g.C! };
|
||
if (Object.values(out).some((v) => !Number.isFinite(v) || v <= 0) || Math.abs(out.A + out.B + out.C - 180) > 0.5) return null;
|
||
return out;
|
||
}
|
||
|
||
const num = (v: number) => String(Math.round(v * 100) / 100);
|
||
|
||
/** رأسهای شکلهای مرکبی که تا اینجا جا گرفتهاند (برای چیدنِ چند شکل کنارِ هم). */
|
||
export interface Layout { placed: XY[] }
|
||
|
||
/**
|
||
* جای شکل: رأسها در دستگاهِ محلی (رأسِ اول در مبدأ، ضلعِ اول روی محورِ x) ساخته شدهاند.
|
||
* دو رأسِ اول از قبل مختصات دارند → شکل روی همان ضلع ساخته میشود (ذوزنقه روی ضلعِ مستطیل)
|
||
* فقط رأسِ اول → همانجا؛ "at" → همانجا
|
||
* هیچکدام، ولی شکلِ دیگری قبلاً کشیده شده → کنارِ آن، نه روی آن (دو مربعِ کنارِ هم)
|
||
*/
|
||
function placement(names: string[], local: XY[], points: Record<string, PointDef>, layout: Layout, at?: XY): (p: XY) => XY {
|
||
const plain = (n: string) => (Array.isArray(points[n]) ? (points[n] as XY) : null);
|
||
const p0 = plain(names[0]), p1 = plain(names[1]);
|
||
let T: (p: XY) => XY = (p) => p;
|
||
if (p0 && p1 && Math.hypot(p1[0] - p0[0], p1[1] - p0[1]) > 1e-9) {
|
||
const len = Math.hypot(p1[0] - p0[0], p1[1] - p0[1]);
|
||
const u: XY = [(p1[0] - p0[0]) / len, (p1[1] - p0[1]) / len];
|
||
T = (p) => [p0[0] + p[0] * u[0] - p[1] * u[1], p0[1] + p[0] * u[1] + p[1] * u[0]];
|
||
} else if (p0) T = (p) => [p0[0] + p[0], p0[1] + p[1]];
|
||
else if (at) T = (p) => [at[0] + p[0], at[1] + p[1]];
|
||
else if (layout.placed.length) {
|
||
const right = Math.max(...layout.placed.map((q) => q[0]));
|
||
const bottom = Math.min(...layout.placed.map((q) => q[1]));
|
||
const minX = Math.min(...local.map((q) => q[0])), minY = Math.min(...local.map((q) => q[1]));
|
||
const gap = Math.max(1, 0.2 * (Math.max(...local.map((q) => q[0])) - minX));
|
||
T = (p) => [p[0] - minX + right + gap, p[1] - minY + bottom];
|
||
}
|
||
layout.placed.push(...local.map(T));
|
||
return T;
|
||
}
|
||
|
||
function sideKey(key: string, names: string[]): [number, number] | null {
|
||
for (let i = 0; i < names.length; i++) for (let j = 0; j < names.length; j++) {
|
||
if (i !== j && key === names[i] + names[j]) return [i, j];
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** itemهای ساده (نقطه، polygon، پارهخطِ برچسبدار، زاویه) بهجای triangle. */
|
||
export function expandTriangle(item: TriangleItem, idx: number, points: Record<string, PointDef>, taken: (n: string) => boolean, layout: Layout): PlaneItem[] | null {
|
||
const [P, Q, R] = item.names;
|
||
const sides: Partial<Record<'a' | 'b' | 'c', number>> = {};
|
||
const oppOf = (i: number, j: number) => (['a', 'b', 'c'] as const)[3 - i - j];
|
||
Object.entries(item.sides ?? {}).forEach(([k, v]) => { const p = sideKey(k, item.names); if (p) sides[oppOf(p[0], p[1])] = v; });
|
||
const angles: Partial<Record<'A' | 'B' | 'C', number>> = {};
|
||
Object.entries(item.angles ?? {}).forEach(([k, v]) => { const i = item.names.indexOf(k); if (i >= 0) angles[(['A', 'B', 'C'] as const)[i]] = v; });
|
||
// مثلثی روی نقطههایی که مدل خودش تعریف کرده (مختصات، polar، تبدیل) و بیدادهی ضلع و زاویه:
|
||
// همان نقطهها، دستنخورده (سنجشِ ۲۰۲۶-۰۹-۲۹: جابهجا کردنشان تصویرِ تقارن را خراب کرد)
|
||
const hasData = Object.keys(sides).length + Object.keys(angles).length > 0;
|
||
if (!(hasData || !item.names.every((n) => n in points))) {
|
||
layout.placed.push(...item.names.map((n) => points[n]).filter((v): v is XY => Array.isArray(v)));
|
||
} else {
|
||
const t = solveTriangle(sides, angles);
|
||
if (!t) return null;
|
||
const local: XY[] = [[0, 0], [t.c, 0], [t.b * Math.cos(t.A * RAD), t.b * Math.sin(t.A * RAD)]];
|
||
const T = placement(item.names, local, points, layout, item.at);
|
||
item.names.forEach((n, i) => { points[n] = T(local[i]); });
|
||
}
|
||
const style = { color: item.color };
|
||
const out: PlaneItem[] = [{ type: 'polygon', points: [P, Q, R], fill: item.fill ?? true, ...style }];
|
||
const labels = item.labels ?? {};
|
||
const sideLabel = new Map<string, string>();
|
||
Object.entries(item.sides ?? {}).forEach(([k, v]) => { const p = sideKey(k, item.names); if (p) sideLabel.set([p[0], p[1]].sort().join(), num(v)); });
|
||
Object.entries(labels).forEach(([k, v]) => { const p = sideKey(k, item.names); if (p) sideLabel.set([p[0], p[1]].sort().join(), v); });
|
||
// همهی ضلعها پارهخطاند (نه فقط برچسبدارها) تا ضخامتِ ضلعها یکی باشد
|
||
[[0, 1], [1, 2], [0, 2]].forEach(([i, j]) => {
|
||
out.push({ type: 'segment', from: item.names[i], to: item.names[j], label: sideLabel.get(`${i},${j}`), ...style });
|
||
});
|
||
item.names.forEach((n, i) => {
|
||
const given = item.angles?.[n];
|
||
const label = labels[n] ?? (given !== undefined ? `${num(given)}°` : undefined);
|
||
if (label === undefined) return;
|
||
const [from, to] = [item.names[(i + 1) % 3], item.names[(i + 2) % 3]];
|
||
if (given !== undefined && Math.abs(given - 90) < 1e-6 && labels[n] === undefined) out.push({ type: 'rightAngle', vertex: n, from, to });
|
||
else out.push({ type: 'angle', vertex: n, from, to, label, color: 'red' });
|
||
});
|
||
const alt = item.altitude;
|
||
if (alt && item.names.includes(alt)) {
|
||
const [u, v] = item.names.filter((n) => n !== alt);
|
||
const foot = taken('H') ? `_h${idx}` : 'H';
|
||
points[foot] = { foot: [alt, u, v] };
|
||
// امتدادِ ضلع فقط وقتی پای ارتفاع بیرونِ مثلث میافتد (مثلثِ منفرجه)
|
||
const [pu, pv, pa] = [u, v, alt].map((n) => (Array.isArray(points[n]) ? (points[n] as XY) : ([0, 0] as XY)));
|
||
const d: XY = [pv[0] - pu[0], pv[1] - pu[1]];
|
||
const tt = ((pa[0] - pu[0]) * d[0] + (pa[1] - pu[1]) * d[1]) / (d[0] * d[0] + d[1] * d[1] || 1);
|
||
if (tt < 0) out.push({ type: 'segment', from: foot, to: u, dashed: true, color: 'gray' });
|
||
if (tt > 1) out.push({ type: 'segment', from: v, to: foot, dashed: true, color: 'gray' });
|
||
out.push({ type: 'segment', from: alt, to: foot, dashed: true, color: 'green', label: labels.h });
|
||
out.push({ type: 'rightAngle', vertex: foot, from: alt, to: v });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
export function expandQuad(item: QuadItem, idx: number, points: Record<string, PointDef>, layout: Layout): PlaneItem[] {
|
||
const [A, B, C, D] = item.names;
|
||
const labels = item.labels ?? {};
|
||
const style = { color: item.color };
|
||
const lab = (key: string, value?: number) => labels[key] ?? (value !== undefined ? num(value) : undefined);
|
||
const out: PlaneItem[] = [];
|
||
const seg = (from: Ref, to: Ref, label?: string, extra: Partial<Extract<PlaneItem, { type: 'segment' }>> = {}) => {
|
||
out.push({ type: 'segment', from, to, label, ...style, ...extra });
|
||
};
|
||
// ضلعهایی که برچسب یا علامت نگرفتند، بعداً با همان ضخامت کشیده میشوند
|
||
const edges = () => {
|
||
const drawn = new Set(out.filter((i) => i.type === 'segment' && !i.dashed).map((i) => { const s = i as { from: Ref; to: Ref }; return [s.from, s.to].sort().join(); }));
|
||
[[A, B], [B, C], [C, D], [D, A]].forEach(([p, q]) => { if (!drawn.has([p, q].sort().join())) seg(p, q); });
|
||
};
|
||
// بیهیچ اندازه و روی چهار نقطهای که مدل خودش تعریف کرده: همان نقطهها
|
||
const keep = ['w', 'h', 'side', 'top', 'd1', 'd2', 'angle'].every((k) => item[k as keyof QuadItem] === undefined)
|
||
&& item.names.every((n) => n in points);
|
||
let T: (p: XY) => XY = (p) => p;
|
||
const put = (xy: XY[]) => {
|
||
if (keep) { layout.placed.push(...item.names.map((n) => points[n]).filter((v): v is XY => Array.isArray(v))); return; }
|
||
T = placement(item.names, xy, points, layout, item.at);
|
||
[A, B, C, D].forEach((n, i) => { points[n] = T(xy[i]); });
|
||
};
|
||
const height = (label?: string) => {
|
||
const foot = `_q${idx}`;
|
||
points[foot] = { foot: [D, A, B] };
|
||
out.push({ type: 'segment', from: D, to: foot, dashed: true, color: 'green', label });
|
||
out.push({ type: 'rightAngle', vertex: foot, from: D, to: B });
|
||
};
|
||
const θ = (item.angle ?? 60) * RAD;
|
||
switch (item.shape) {
|
||
case 'rectangle': {
|
||
const w = item.w ?? 6, h = item.h ?? 4;
|
||
put([[0, 0], [w, 0], [w, h], [0, h]]);
|
||
out.push({ type: 'polygon', points: [A, B, C, D], fill: item.fill ?? true, ...style }, { type: 'rightAngle', vertex: A, from: B, to: D });
|
||
seg(A, B, lab(A + B, item.w)); seg(B, C, lab(B + C, item.h));
|
||
break;
|
||
}
|
||
case 'square': {
|
||
const s = item.side ?? item.w ?? 4;
|
||
put([[0, 0], [s, 0], [s, s], [0, s]]);
|
||
out.push({ type: 'polygon', points: [A, B, C, D], fill: item.fill ?? true, ...style }, { type: 'rightAngle', vertex: A, from: B, to: D });
|
||
seg(A, B, lab(A + B, item.side ?? item.w), { ticks: 1 }); seg(B, C, lab(B + C), { ticks: 1 });
|
||
seg(C, D, lab(C + D), { ticks: 1 }); seg(D, A, lab(D + A), { ticks: 1 });
|
||
break;
|
||
}
|
||
case 'parallelogram': {
|
||
const w = item.w ?? 6;
|
||
const s = item.side ?? (item.h ? item.h / Math.sin(θ) : 3.5);
|
||
const d: XY = [s * Math.cos(θ), s * Math.sin(θ)];
|
||
put([[0, 0], [w, 0], [w + d[0], d[1]], d]);
|
||
out.push({ type: 'polygon', points: [A, B, C, D], fill: item.fill ?? true, ...style });
|
||
seg(A, B, lab(A + B, item.w)); seg(A, D, lab(A + D, item.side));
|
||
if (item.h !== undefined || labels.h) height(lab('h', item.h));
|
||
if (item.angle !== undefined) out.push({ type: 'angle', vertex: A, from: B, to: D, label: `${num(item.angle)}°`, color: 'red' });
|
||
break;
|
||
}
|
||
case 'rhombus': {
|
||
if (item.d1 !== undefined || item.d2 !== undefined) {
|
||
const d1 = item.d1 ?? 6, d2 = item.d2 ?? 4, o = `_o${idx}`;
|
||
put([[-d1 / 2, 0], [0, -d2 / 2], [d1 / 2, 0], [0, d2 / 2]]);
|
||
points[o] = T([0, 0]);
|
||
out.push({ type: 'polygon', points: [A, B, C, D], fill: item.fill ?? true, ...style });
|
||
// برچسبِ قطرها کنارِ هم نمینشیند (هر دو وسطشان در O است): روی یکچهارمِ هر قطر
|
||
seg(A, C, undefined, { dashed: true }); seg(B, D, undefined, { dashed: true });
|
||
const l1 = lab('d1', item.d1), l2 = lab('d2', item.d2);
|
||
if (l1) out.push({ type: 'text', at: T([d1 / 4, d2 * 0.07]), text: l1, color: 'blue' });
|
||
if (l2) out.push({ type: 'text', at: T([d1 * 0.07, d2 / 4]), text: l2, color: 'blue' });
|
||
out.push({ type: 'rightAngle', vertex: o, from: C, to: D });
|
||
[[A, B], [B, C], [C, D], [D, A]].forEach(([p, q]) => seg(p, q, lab(p + q), { ticks: 1 }));
|
||
} else {
|
||
const s = item.side ?? item.w ?? 4;
|
||
put([[0, 0], [s, 0], [s + s * Math.cos(θ), s * Math.sin(θ)], [s * Math.cos(θ), s * Math.sin(θ)]]);
|
||
out.push({ type: 'polygon', points: [A, B, C, D], fill: item.fill ?? true, ...style });
|
||
seg(A, B, lab(A + B, item.side ?? item.w), { ticks: 1 });
|
||
[[B, C], [C, D], [D, A]].forEach(([p, q]) => seg(p, q, lab(p + q), { ticks: 1 }));
|
||
if (item.h !== undefined || labels.h) height(lab('h', item.h));
|
||
if (item.angle !== undefined) out.push({ type: 'angle', vertex: A, from: B, to: D, label: `${num(item.angle)}°`, color: 'red' });
|
||
}
|
||
break;
|
||
}
|
||
case 'trapezoid': {
|
||
const w = item.w ?? 6, t = item.top ?? w * 0.6, h = item.h ?? 3;
|
||
const x0 = item.right ? 0 : (w - t) / 2;
|
||
put([[0, 0], [w, 0], [x0 + t, h], [x0, h]]);
|
||
out.push({ type: 'polygon', points: [A, B, C, D], fill: item.fill ?? true, ...style });
|
||
seg(A, B, lab(A + B, item.w)); seg(D, C, lab(D + C, item.top));
|
||
if (item.right) {
|
||
out.push({ type: 'rightAngle', vertex: A, from: B, to: D });
|
||
seg(A, D, lab(A + D, item.h) ?? lab('h'));
|
||
} else if (item.h !== undefined || labels.h) height(lab('h', item.h));
|
||
break;
|
||
}
|
||
}
|
||
edges();
|
||
return out;
|
||
}
|