diff --git a/public/og/tr/linear-model-nedir.png b/public/og/tr/linear-model-nedir.png
new file mode 100644
index 0000000..f5b9a5e
Binary files /dev/null and b/public/og/tr/linear-model-nedir.png differ
diff --git a/src/components/content/GradientVectorScene.astro b/src/components/content/GradientVectorScene.astro
new file mode 100644
index 0000000..3681f3a
--- /dev/null
+++ b/src/components/content/GradientVectorScene.astro
@@ -0,0 +1,325 @@
+---
+interface Vector3 {
+ x: number
+ y: number
+ z: number
+}
+interface PointInput {
+ position: Vector3
+ color?: string
+ label?: string
+ size?: number
+}
+interface VectorInput {
+ origin?: Vector3
+ direction: Vector3
+ color?: string
+ length?: number
+ label?: string
+}
+interface LineInput {
+ from: Vector3
+ to: Vector3
+ color?: string
+ dashed?: boolean
+}
+
+interface Props {
+ id?: string
+ height?: number
+ gradientDirection: Vector3
+ gradientOrigin?: Vector3
+ gradientColor?: string
+ gradientLength?: number
+ gradientLabel?: string
+ showPlane?: boolean
+ planeCenter?: Vector3
+ planeNormal?: Vector3
+ planeColor?: string
+ planeOpacity?: number
+ planeSize?: number
+ points?: PointInput[]
+ vectors?: VectorInput[]
+ lines?: LineInput[]
+ showGrid?: boolean
+ showAxes?: boolean
+ axisRange?: number
+ title?: string
+ xAxisLabel?: string
+ yAxisLabel?: string
+ zAxisLabel?: string
+}
+
+const {
+ id = '',
+ height = 400,
+ gradientDirection,
+ gradientOrigin = { x: 0, y: 0, z: 0 },
+ gradientColor = '#22c55e',
+ gradientLength,
+ gradientLabel = '∇f',
+ showPlane = false,
+ planeCenter = { x: 0, y: 0, z: 0 },
+ planeNormal = { x: 0, y: 1, z: 0 },
+ planeColor = '#71717a',
+ planeOpacity = 0.2,
+ planeSize,
+ points = [],
+ vectors = [],
+ lines = [],
+ showGrid = true,
+ showAxes = true,
+ axisRange = 5,
+ title = '',
+ xAxisLabel = 'X₁',
+ yAxisLabel = 'Y',
+ zAxisLabel = 'X₂',
+} = Astro.props
+
+const chartId = `gradient-${id || Math.random().toString(36).slice(2, 9)}`
+---
+
+
+
+
diff --git a/src/components/content/HyperplaneScene.astro b/src/components/content/HyperplaneScene.astro
new file mode 100644
index 0000000..c5e639e
--- /dev/null
+++ b/src/components/content/HyperplaneScene.astro
@@ -0,0 +1,318 @@
+---
+interface Vector3 {
+ x: number
+ y: number
+ z: number
+}
+interface AxisRange {
+ min: number
+ max: number
+}
+interface PointInput {
+ position: Vector3
+ color?: string
+ label?: string
+}
+interface LineInput {
+ from: Vector3
+ to: Vector3
+ color?: string
+ dashed?: boolean
+}
+
+interface Props {
+ id?: string
+ height?: number
+ points?: PointInput[]
+ planeNormal: Vector3
+ planePoint: Vector3
+ planeColor?: string
+ planeOpacity?: number
+ showResiduals?: boolean
+ residualColor?: string
+ residualDashed?: boolean
+ lines?: LineInput[]
+ showGrid?: boolean
+ xRange?: AxisRange
+ yRange?: AxisRange
+ zRange?: AxisRange
+ title?: string
+ xAxisLabel?: string
+ yAxisLabel?: string
+ zAxisLabel?: string
+}
+
+const {
+ id = '',
+ height = 400,
+ points = [],
+ planeNormal,
+ planePoint,
+ planeColor = '#3b82f6',
+ planeOpacity = 0.3,
+ showResiduals = true,
+ residualColor = '#ef4444',
+ residualDashed = true,
+ lines = [],
+ showGrid = true,
+ xRange,
+ yRange,
+ zRange,
+ title = '',
+ xAxisLabel = 'X₁',
+ yAxisLabel = 'Y',
+ zAxisLabel = 'X₂',
+} = Astro.props
+
+const chartId = `hyperplane-${id || Math.random().toString(36).slice(2, 9)}`
+---
+
+
+
+
diff --git a/src/components/content/Scene3D.astro b/src/components/content/Scene3D.astro
new file mode 100644
index 0000000..5f028aa
--- /dev/null
+++ b/src/components/content/Scene3D.astro
@@ -0,0 +1,285 @@
+---
+interface Vector3 {
+ x: number
+ y: number
+ z: number
+}
+interface PointConfig {
+ position: Vector3
+ color?: string
+ size?: number
+ label?: string
+ opacity?: number
+}
+interface VectorConfig {
+ origin?: Vector3
+ direction: Vector3
+ color?: string
+ length?: number
+ label?: string
+}
+interface LineConfig {
+ from: Vector3
+ to: Vector3
+ color?: string
+ dashed?: boolean
+}
+interface PlaneConfig {
+ center: Vector3
+ normal: Vector3
+ size?: number
+ color?: string
+ opacity?: number
+ label?: string
+}
+
+interface Props {
+ id?: string
+ height?: number
+ points?: PointConfig[]
+ vectors?: VectorConfig[]
+ lines?: LineConfig[]
+ planes?: PlaneConfig[]
+ showGrid?: boolean
+ showAxes?: boolean
+ axisRange?: number
+ title?: string
+ xAxisLabel?: string
+ yAxisLabel?: string
+ zAxisLabel?: string
+}
+
+const {
+ id = '',
+ height = 400,
+ points = [],
+ vectors = [],
+ lines = [],
+ planes = [],
+ showGrid = true,
+ showAxes = true,
+ axisRange = 5,
+ title = '',
+ xAxisLabel = 'X',
+ yAxisLabel = 'Y',
+ zAxisLabel = 'Z',
+} = Astro.props
+
+const chartId = `scene3d-${id || Math.random().toString(36).slice(2, 9)}`
+---
+
+
+
+
diff --git a/src/content/articles/tr/linear-model-nedir.mdx b/src/content/articles/tr/linear-model-nedir.mdx
new file mode 100644
index 0000000..9b61ef1
--- /dev/null
+++ b/src/content/articles/tr/linear-model-nedir.mdx
@@ -0,0 +1,273 @@
+---
+lang: 'tr'
+slug: 'linear-model-nedir'
+title: 'Linear Model Nedir?'
+excerpt: 'Lineer model, etiketlenmiş veri setlerindeki girdi ve çıktı değişkenleri arasındaki doğrusal ilişkiyi matematiksel olarak modellemek için kullanılan temel bir supervised (gözetimli) makine öğrenmesi algoritmasıdır.'
+category: 'machine-learning'
+tags: ['linear-regression', 'supervised-learning', 'teori']
+author: '@omerfdmrl'
+date: '2026-03-29'
+views: 0
+status: 'Published'
+---
+
+import HyperplaneScene from '../../../components/content/HyperplaneScene.astro'
+import GradientVectorScene from '../../../components/content/GradientVectorScene.astro'
+
+Makine öğrenmesi ve istatistik dünyasına atılan ilk adım genellikle **Lineer Modeller** ile başlar.
+Onlarca yıldır veri biliminin en temel yapı taşı olan bu yaklaşım, veriler arasındaki karmaşık gibi görünen ilişkileri basit ve doğrusal bir denklemle ifade etmemizi sağlar.
+Özünde yaptığı iş çok nettir: Verilen input vektörü ile $X^T = (X_1, X_2, X_3 ...)$ output değeri $Y$'yi tahmin etmeye çalışır:
+
+$$
+\hat{Y} = \hat{\beta}_0 + \sum_{j=1}^p X_j \hat{\beta}_j
+$$
+
+$\hat{\beta}_0$ **bias (intercept)** değeridir. Bu formülü $X$'i vektör olarak yazarsak:
+
+$$
+\hat{Y} = X^{\top} \hat{\beta}
+$$
+
+olarak kullanabiliriz.
+
+## Dot Product Anlamak
+
+Bunu daha iyi anlamak için formülü tek tek açalım:
+
+$$
+\hat{Y}=\hat{\beta}_0 + X_1\hat{\beta}_1 + X_2\hat{\beta}_2 + \cdots + X_p\hat{\beta}_p
+$$
+
+Burada her $X_j$ tek bir feature değeridir ve tek sayıdır:
+
+$$
+X_j \in \mathbb{R}
+$$
+
+Aynı şekilde her katsayı da tek sayıdır:
+
+$$
+\hat{\beta}_j \in \mathbb{R}
+$$
+
+Dolayısıyla iki reel sayının çarpımı yine tek bir reel sayı verir:
+
+$$
+X_j \hat{\beta}_j \in \mathbb{R}
+$$
+
+Yani örneğin:
+
+$$
+X_1=3,\quad \hat{\beta}_1=4
+$$
+
+ise:
+
+$$
+X_1\hat{\beta}_1 = 12
+$$
+
+elde edilir.
+
+Bu 12 artık bir **skaler**dir; yani vektör veya matris değil, yalnızca tek bir sayıdır.
+
+Aynı durum tüm terimler için geçerlidir:
+
+$$
+X_2\hat{\beta}_2,\; X_3\hat{\beta}_3,\; \ldots,\; X_p\hat{\beta}_p
+$$
+
+hepsi tek tek skaler değerler üretir.
+
+Sonuçta bunların toplamı da yine tek bir sayı olur:
+
+$$
+\hat{Y}\in \mathbb{R}
+$$
+
+Bu nedenle lineer modelin çıktısı tek bir skaler tahmindir.
+Dolayısıyla her çarpım da skaler olur, ve buna **dot product (inner product)** nedir.
+
+### Bias'ı Dahil Etmek
+
+Bias' ayrı yazmak yerine feature vektörünü genişletebiliriz:
+
+$$
+X =
+\begin{bmatrix}
+1 \\ X_1 \\ X_2 \\ \vdots \\ X_p
+\end{bmatrix},
+\quad
+\hat{\beta} =
+\begin{bmatrix}
+\hat{\beta}_0 \\ \hat{\beta}_1 \\ \hat{\beta}_2 \\ \vdots \\ \hat{\beta}_p
+\end{bmatrix}
+$$
+
+Burada 1 ile başlatma sebebimiz, formüldeki $\hat{\beta}_0$ değerini de ekleyebilmek için.
+
+### Neden Transpose Gerekiyor?
+
+İki sütun vektörünü doğrudan çarpamayız.
+
+$$
+X =
+\begin{bmatrix}
+1 \\ X_1 \\ X_2 \\ \vdots \\ X_p
+\end{bmatrix}
+=
+(p + 1)\times1,
+\quad
+\hat{\beta} =
+\begin{bmatrix}
+\hat{\beta}_0 \\ \hat{\beta}_1 \\ \hat{\beta}_2 \\ \vdots \\ \hat{\beta}_p
+\end{bmatrix} =
+(p + 1)\times1
+$$
+
+Dot product çarpımında içerikde boyutların aynı olması gerekir. Bu yüzden de birini satır olarak yazmamız gerekir.
+
+$$
+((p + 1)\times1) \times ((p + 1)\times1) \rightarrow (1\times(p + 1)) \times ((p + 1)\times1)
+$$
+
+$$
+= 1\times1
+$$
+
+Yani artık:
+
+$$
+X =
+\begin{bmatrix}
+1 & X_1 & X_2 & \ldots & X_p
+\end{bmatrix}
+=
+1\times(p + 1),
+\quad
+\hat{\beta} =
+\begin{bmatrix}
+\hat{\beta}_0 \\ \hat{\beta}_1 \\ \hat{\beta}_2 \\ \vdots \\ \hat{\beta}_p
+\end{bmatrix} =
+(p + 1)\times1
+$$
+
+### Örnek Çözüm
+
+İki feature olsun:
+
+$$
+X=
+\begin{bmatrix}
+1\\
+3\\
+5
+\end{bmatrix}
+,\quad
+\hat{\beta}=
+\begin{bmatrix}
+2\\
+4\\
+6
+\end{bmatrix}
+$$
+
+O zaman:
+
+$$
+X^\top \hat{\beta}
+=
+\begin{bmatrix}
+1 & 3 & 5
+\end{bmatrix}
+\begin{bmatrix}
+2\\
+4\\
+6
+\end{bmatrix}
+$$
+
+Sonuç:
+
+$$
+=1\cdot2 + 3\cdot4 + 5\cdot6
+$$
+
+$$
+=44
+$$
+
+## Hiperdüzlem (Hyperplane)
+
+Lineer modelin geometrik anlamı bir **hiperdüzlem**dir. İki feature ($X_1, X_2$) ve bir output ($Y$) ile düşünelim.
+Modelimizin ürettiği tahminler ($\hat{Y}$), $X_1$ ve $X_2$ eksenleri boyunca uzanan düz bir yüzey, yani bir düzlem (veya daha yüksek boyutlarda hiperdüzlem) oluşturur.
+
+Aşağıdaki görselleştirmede; mavi düzlem lineer modelimizin uzayda oluşturduğu hiperdüzlemi, noktalar gerçek veri noktalarını ve kırmızı kesikli çizgiler ise gerçek verilerin bizim modelimize (düzleme) olan uzaklıklarını gösterir:
+
+
+
+Her nokta, hiperdüzleme dikey olarak izdüşürülür. Gerçek değer ($Y$) ile modelin düzlem üzerindeki tahmini ($\hat{Y}$) arasındaki bu farklara **residual (artık)** denir:
+$e_i = Y_i - \hat{Y}_i$.
+
+> **Not:** Bu düzlemin uzaya nasıl yerleştirileceği ve aradaki bu residual hatalarının nasıl en aza indirileceği, optimizasyon yöntemlerinin konusudur. Lineer model sadece bu düzlemin matematiksel tanımıdır.
+
+### Katsayılar ve Gradient Vektörü
+
+Lineer modelin denklemini bir fonksiyon olarak, $f(X) = X^\top \beta$ şeklinde düşündüğümüzde, modelin uzaydaki eğimini belirleyen şey $\beta$ katsayılarıdır. Matematiksel olarak bir fonksiyonun **gradyanı (gradient)**, o noktada fonksiyonun en dik artış yönünü gösterir.
+
+Lineer bir model için bu gradyan doğrudan katsayılar vektörüne eşittir:
+
+$$
+f'(X) = \nabla f(X) = \beta
+$$
+
+Yani $\beta$ vektörü, input uzayında modelimizin çıktısının ($\hat{Y}$) en hızlı arttığı yönü gösterir.
+
+
+
+Yukarıdaki görselleştirmede yeşil ok $\nabla f$ (yani $\beta$) vektörünü, turuncu nokta ise mevcut konum $X$'i temsil eder.
+Lineer modelde düzlemin eğimi sabit olduğundan, bu yön her yerde aynıdır. Modelleri eğitirken kullandığımız algoritmalar (örneğin Gradient Descent), bu vektörel özellikleri kullanarak modeli en doğru konuma getirmeye çalışır.
diff --git a/src/data/tr/articles.json b/src/data/tr/articles.json
index 8743e99..0badc07 100644
--- a/src/data/tr/articles.json
+++ b/src/data/tr/articles.json
@@ -1,4 +1,9 @@
[
+ {
+ "title": "Linear Model Nedir?",
+ "slug": "linear-model-nedir",
+ "category": "machine-learning"
+ },
{
"title": "Linear Regression Nedir?",
"slug": "linear-regression-nedir",
diff --git a/src/utils/scene3d.ts b/src/utils/scene3d.ts
new file mode 100644
index 0000000..3b931a6
--- /dev/null
+++ b/src/utils/scene3d.ts
@@ -0,0 +1,418 @@
+import * as THREE from 'three'
+import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
+
+export interface Vec3 {
+ x: number
+ y: number
+ z: number
+}
+
+export interface ThemeColors {
+ textColor: number
+ gridColor: number
+ axisColor: number
+ bgCss: string
+ darkText: string
+ lightText: string
+}
+
+export function getThemeColors3D(): ThemeColors {
+ const isDark = document.documentElement.classList.contains('dark')
+ return {
+ textColor: isDark ? 0xd4d4d8 : 0x52525b,
+ gridColor: isDark ? 0x3f3f46 : 0xdde1e7,
+ axisColor: isDark ? 0x71717a : 0x94a3b8,
+ bgCss: isDark ? '#0c0c0e' : '#f8fafc',
+ darkText: isDark ? '#e4e4e7' : '#27272a',
+ lightText: isDark ? '#a1a1aa' : '#71717a',
+ }
+}
+
+export function makeLabel(
+ text: string,
+ position: THREE.Vector3,
+ colorHex: number,
+ scaleW = 0.28,
+ scaleH = 0.1,
+ fontPx = 28,
+): THREE.Sprite {
+ const canvas = document.createElement('canvas')
+ canvas.width = 256
+ canvas.height = 64
+ const ctx = canvas.getContext('2d')!
+ ctx.clearRect(0, 0, 256, 64)
+ ctx.fillStyle = `#${colorHex.toString(16).padStart(6, '0')}`
+ ctx.font = `${fontPx}px monospace`
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+ ctx.fillText(text, 128, 32)
+ const texture = new THREE.CanvasTexture(canvas)
+ texture.needsUpdate = true
+ const mat = new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false })
+ const sprite = new THREE.Sprite(mat)
+ sprite.position.copy(position)
+ sprite.scale.set(scaleW, scaleH, 1)
+ return sprite
+}
+
+export function createBaseScene(): THREE.Scene {
+ const scene = new THREE.Scene()
+ scene.add(new THREE.AmbientLight(0xffffff, 0.7))
+ const dLight = new THREE.DirectionalLight(0xffffff, 0.9)
+ dLight.position.set(3, 5, 3)
+ scene.add(dLight)
+ const dLight2 = new THREE.DirectionalLight(0xffffff, 0.25)
+ dLight2.position.set(-2, -1, -2)
+ scene.add(dLight2)
+ return scene
+}
+
+export function createCamera(
+ width: number,
+ height: number,
+ position: THREE.Vector3,
+ target: THREE.Vector3,
+): THREE.PerspectiveCamera {
+ const camera = new THREE.PerspectiveCamera(40, width / height, 0.01, 200)
+ camera.position.copy(position)
+ camera.lookAt(target)
+ return camera
+}
+
+export function createRenderer(container: HTMLElement, width: number, height: number): THREE.WebGLRenderer {
+ const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
+ renderer.setSize(width, height)
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
+ renderer.setClearColor(0x000000, 0)
+ renderer.domElement.style.cssText = 'position:absolute;top:0;left:0;z-index:1;border-radius:8px;'
+ container.style.position = 'relative'
+ container.appendChild(renderer.domElement)
+ return renderer
+}
+
+export function createControls(
+ camera: THREE.PerspectiveCamera,
+ element: HTMLElement,
+ target: THREE.Vector3,
+ cubeSize: number,
+): OrbitControls {
+ const controls = new OrbitControls(camera, element)
+ controls.enableDamping = true
+ controls.dampingFactor = 0.06
+ controls.rotateSpeed = 0.7
+ controls.zoomSpeed = 1.2
+ controls.minDistance = cubeSize * 0.5
+ controls.maxDistance = cubeSize * 8
+ controls.target.copy(target)
+ controls.update()
+ return controls
+}
+
+export function createAxisArrows(
+ cube: number,
+ theme: ThemeColors,
+ scene: THREE.Scene,
+ labels: { x: string; y: string; z: string },
+ tickLabels: { x: string[]; y: string[]; z: string[] },
+): void {
+ const arrowLen = cube + 0.15
+ const headLen = 0.08
+ const headWid = 0.04
+
+ const xDir = new THREE.Vector3(1, 0, 0)
+ const yDir = new THREE.Vector3(0, 1, 0)
+ const zDir = new THREE.Vector3(0, 0, 1)
+
+ scene.add(new THREE.ArrowHelper(xDir, new THREE.Vector3(0, 0, 0), arrowLen, theme.axisColor, headLen, headWid))
+ scene.add(new THREE.ArrowHelper(yDir, new THREE.Vector3(0, 0, 0), arrowLen, theme.axisColor, headLen, headWid))
+ scene.add(new THREE.ArrowHelper(zDir, new THREE.Vector3(0, 0, 0), arrowLen, theme.axisColor, headLen, headWid))
+
+ const tickOffset = cube * 0.02
+
+ scene.add(
+ makeLabel(
+ labels.x,
+ new THREE.Vector3(cube / 2, -tickOffset * 2.5, cube + tickOffset * 2),
+ theme.textColor,
+ 0.3,
+ 0.1,
+ 30,
+ ),
+ )
+ scene.add(
+ makeLabel(
+ labels.y,
+ new THREE.Vector3(-tickOffset * 3, cube / 2, cube + tickOffset * 2),
+ theme.textColor,
+ 0.3,
+ 0.1,
+ 30,
+ ),
+ )
+ scene.add(
+ makeLabel(
+ labels.z,
+ new THREE.Vector3(-tickOffset * 3, -tickOffset * 2.5, cube / 2),
+ theme.textColor,
+ 0.3,
+ 0.1,
+ 30,
+ ),
+ )
+
+ for (let i = 0; i < tickLabels.x.length; i++) {
+ const t = i / (tickLabels.x.length - 1)
+ const pos = t * cube
+ scene.add(
+ makeLabel(
+ tickLabels.x[i],
+ new THREE.Vector3(pos, -tickOffset, cube + tickOffset),
+ theme.textColor,
+ 0.2,
+ 0.08,
+ 24,
+ ),
+ )
+ }
+ for (let i = 0; i < tickLabels.y.length; i++) {
+ const t = i / (tickLabels.y.length - 1)
+ const pos = t * cube
+ scene.add(
+ makeLabel(
+ tickLabels.y[i],
+ new THREE.Vector3(-tickOffset * 1.5, pos, cube + tickOffset),
+ theme.textColor,
+ 0.2,
+ 0.08,
+ 24,
+ ),
+ )
+ }
+ for (let i = 0; i < tickLabels.z.length; i++) {
+ const t = i / (tickLabels.z.length - 1)
+ const pos = t * cube
+ scene.add(
+ makeLabel(
+ tickLabels.z[i],
+ new THREE.Vector3(-tickOffset * 1.5, -tickOffset, pos),
+ theme.textColor,
+ 0.2,
+ 0.08,
+ 24,
+ ),
+ )
+ }
+}
+
+export function createGridLines(cube: number, theme: ThemeColors, scene: THREE.Scene, divisions = 6): void {
+ function makeFaceGrid(origin: THREE.Vector3, uDir: THREE.Vector3, vDir: THREE.Vector3): void {
+ const points: THREE.Vector3[] = []
+ for (let i = 0; i <= divisions; i++) {
+ const t = (i / divisions) * cube
+ points.push(origin.clone().addScaledVector(vDir, t))
+ points.push(origin.clone().addScaledVector(uDir, cube).addScaledVector(vDir, t))
+ points.push(origin.clone().addScaledVector(uDir, t))
+ points.push(origin.clone().addScaledVector(uDir, t).addScaledVector(vDir, cube))
+ }
+ const geo = new THREE.BufferGeometry().setFromPoints(points)
+ const mat = new THREE.LineBasicMaterial({ color: theme.gridColor, transparent: true, opacity: 0.35 })
+ scene.add(new THREE.LineSegments(geo, mat))
+ }
+
+ makeFaceGrid(new THREE.Vector3(0, 0, 0), new THREE.Vector3(1, 0, 0), new THREE.Vector3(0, 0, 1))
+ makeFaceGrid(new THREE.Vector3(0, 0, 0), new THREE.Vector3(1, 0, 0), new THREE.Vector3(0, 1, 0))
+ makeFaceGrid(new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 0, 1), new THREE.Vector3(0, 1, 0))
+}
+
+export function renderPoint(
+ scene: THREE.Scene,
+ position: THREE.Vector3,
+ color: number,
+ size = 8,
+ opacity = 1,
+): THREE.Mesh {
+ const geo = new THREE.SphereGeometry(size * 0.005, 20, 20)
+ const mat = new THREE.MeshStandardMaterial({
+ color,
+ transparent: opacity < 1,
+ opacity,
+ roughness: 0.35,
+ metalness: 0.15,
+ })
+ const mesh = new THREE.Mesh(geo, mat)
+ mesh.position.copy(position)
+ scene.add(mesh)
+ return mesh
+}
+
+export function renderVector(
+ scene: THREE.Scene,
+ origin: THREE.Vector3,
+ direction: THREE.Vector3,
+ color: number,
+ length?: number,
+): THREE.ArrowHelper {
+ const len = length ?? direction.length()
+ const arrow = new THREE.ArrowHelper(direction.clone().normalize(), origin, len, color, len * 0.15, len * 0.06)
+ scene.add(arrow)
+ return arrow
+}
+
+export function renderLine(
+ scene: THREE.Scene,
+ from: THREE.Vector3,
+ to: THREE.Vector3,
+ color: number,
+ dashed = false,
+): THREE.Line | THREE.LineSegments {
+ if (dashed) {
+ const geo = new THREE.BufferGeometry().setFromPoints([from, to])
+ const mat = new THREE.LineDashedMaterial({
+ color,
+ dashSize: 0.05,
+ gapSize: 0.03,
+ transparent: true,
+ opacity: 0.8,
+ })
+ const line = new THREE.Line(geo, mat)
+ line.computeLineDistances()
+ scene.add(line)
+ return line
+ }
+ const geo = new THREE.BufferGeometry().setFromPoints([from, to])
+ const mat = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.8 })
+ const line = new THREE.Line(geo, mat)
+ scene.add(line)
+ return line
+}
+
+export function renderPlane(
+ scene: THREE.Scene,
+ center: THREE.Vector3,
+ normal: THREE.Vector3,
+ size: number,
+ color: number,
+ opacity = 0.35,
+): THREE.Mesh {
+ const geo = new THREE.PlaneGeometry(size, size)
+ const mat = new THREE.MeshStandardMaterial({
+ color,
+ transparent: true,
+ opacity,
+ side: THREE.DoubleSide,
+ roughness: 0.6,
+ metalness: 0.1,
+ })
+ const mesh = new THREE.Mesh(geo, mat)
+ mesh.position.copy(center)
+
+ const up = new THREE.Vector3(0, 1, 0)
+ const norm = normal.clone().normalize()
+ if (Math.abs(norm.dot(up)) > 0.999) {
+ mesh.rotation.x = norm.y > 0 ? -Math.PI / 2 : Math.PI / 2
+ } else {
+ mesh.lookAt(center.clone().add(norm))
+ }
+ scene.add(mesh)
+ return mesh
+}
+
+export function projectPointOnPlane(
+ point: THREE.Vector3,
+ planeNormal: THREE.Vector3,
+ planePoint: THREE.Vector3,
+): THREE.Vector3 {
+ const n = planeNormal.clone().normalize()
+ const diff = point.clone().sub(planePoint)
+ const dist = diff.dot(n)
+ return point.clone().sub(n.clone().multiplyScalar(dist))
+}
+
+export function computeResidual(
+ point: THREE.Vector3,
+ planeNormal: THREE.Vector3,
+ planePoint: THREE.Vector3,
+): { from: THREE.Vector3; to: THREE.Vector3 } {
+ return {
+ from: point.clone(),
+ to: projectPointOnPlane(point, planeNormal, planePoint),
+ }
+}
+
+export function setupZoomControls(
+ container: HTMLElement,
+ camera: THREE.PerspectiveCamera,
+ controls: OrbitControls,
+ defaultEye: THREE.Vector3,
+ center: THREE.Vector3,
+): void {
+ const duration = 300
+ const zoomFactor = 0.75
+
+ container.querySelectorAll('.chart-btn').forEach((btn) => {
+ btn.addEventListener('click', () => {
+ if ((btn as HTMLElement).classList.contains('zoom-in')) {
+ const targetPos = camera.position
+ .clone()
+ .sub(controls.target)
+ .normalize()
+ .multiplyScalar(camera.position.distanceTo(controls.target) * zoomFactor)
+ .add(controls.target)
+ const startPos = camera.position.clone()
+ const zoomStart = performance.now()
+ function zoomInAnim() {
+ const p = Math.min((performance.now() - zoomStart) / duration, 1)
+ const e = 1 - Math.pow(1 - p, 3)
+ camera.position.lerpVectors(startPos, targetPos, e)
+ controls.update()
+ if (p < 1) requestAnimationFrame(zoomInAnim)
+ }
+ zoomInAnim()
+ } else if ((btn as HTMLElement).classList.contains('zoom-out')) {
+ const targetPos = camera.position
+ .clone()
+ .sub(controls.target)
+ .normalize()
+ .multiplyScalar(camera.position.distanceTo(controls.target) / zoomFactor)
+ .add(controls.target)
+ const startPos = camera.position.clone()
+ const zoomStart = performance.now()
+ function zoomOutAnim() {
+ const p = Math.min((performance.now() - zoomStart) / duration, 1)
+ const e = 1 - Math.pow(1 - p, 3)
+ camera.position.lerpVectors(startPos, targetPos, e)
+ controls.update()
+ if (p < 1) requestAnimationFrame(zoomOutAnim)
+ }
+ zoomOutAnim()
+ } else {
+ const resetStart = performance.now()
+ const fromPos = camera.position.clone()
+ const toPos = defaultEye.clone()
+ function resetAnim() {
+ const p = Math.min((performance.now() - resetStart) / 400, 1)
+ const e = 1 - Math.pow(1 - p, 3)
+ camera.position.lerpVectors(fromPos, toPos, e)
+ controls.target.copy(center)
+ controls.update()
+ if (p < 1) requestAnimationFrame(resetAnim)
+ }
+ resetAnim()
+ }
+ })
+ })
+}
+
+export function createControlsHTML(): string {
+ return ``
+}