رفتن به محتوا

سه‌بعدی و رندر نسل سوم

فاز ۵ پلن. صحنه در apps/web/src/components/Scene3D.tsx و components/scene/ (مسیرهای scene/… ِ پایین همین پوشه‌اند)؛ رندر در lib/render/، components/RenderPanel.tsx و components/render/ (RenderBridge، RenderOptions، SavedRenders).

صحنه در scene/SceneCanvas.tsx رندر می‌شود، نه Canvas ِ R3F. رفتار همان است — اندازه از react-use-measure، رویدادِ اشاره‌گر روی قاب، معلق‌شدن و خطای صحنه به بیرونِ بوم، پلِ زمینهٔ React و StrictMode با its-fine — ولی Canvas ِ R3F کلِ فضای نامِ three را ثبت می‌کند (extend(THREE)) و هیچ کلاسی از بسته بیرون نمی‌ماند. SceneCanvas با createRoot ِ خودِ R3F فقط THREE_CATALOGUE (scene/three-catalogue.ts) را ثبت می‌کند: تکهٔ three از ۲۶۴٫۷ به ۲۲۳٫۵ کیلوبایت (gzip).

عنصرِ تازهٔ JSX (<torusKnotGeometry>) یا مؤلفهٔ تازهٔ drei که عنصری می‌سازد، باید کلاسش در فهرست باشد؛ وگرنه در مرورگر «R3F: X is not part of the THREE namespace» می‌آید. test/three-catalogue.test.ts برچسب‌ها را از درختِ نحوِ هر TSX و createElement ِ مؤلفه‌های drei ِ واردشده می‌خواند و هم کمبود و هم کلاسِ اضافه را می‌گیرد.

حالتِ باز (نسل ششم، فاز ۳)

Section titled “حالتِ باز (نسل ششم، فاز ۳)”
  • حرکت در هندسه است، نه در وب: هر PartInstance ِ متحرک frontId (گروهِ چیزهایی که با هم می‌جنبند) و motion دارد — swing، slide، follow، fold — و poseFor(part, leaders, t) جای آن را در لحظهٔ t (۰ بسته … ۱ باز) می‌دهد. قراردادِ علامت در geometry/src/motion.ts نوشته است: چرخشِ مثبت حولِ axis لبهٔ آزاد را به سمتِ اتاق می‌برد، و محور از ضربِ برداری ساخته می‌شود تا علامتِ دستی نتواند دو بار غلط باشد (دربِ نسل پنجم درونِ بدنه باز می‌شد). خطِ لولا روی صفحهٔ جلوی بدنه و یک ضخامت بیرونِ لبهٔ لولادار است — کارِ بازوی لولای اروپایی، که بی آن دربِ لولای ۱۱۰ درجه گوشهٔ خودش را ۶ میلی‌متر درونِ پهلو می‌برد. آزمونِ پذیرش: geometry/test/open.test.ts (هر آشپزخانهٔ نمونه، هر نما، هر زاویه، بی برخورد).
  • چقدر باز در وب می‌ماند: openness: Map<frontId, 0..1> در Scene3D. «درها باز» همه را باز می‌کند، کلیک روی یک نما فقط همان را (و بارِ دوم می‌بندد؛ انتخاب دست نمی‌خورد)، و لغزندهٔ «میزان باز شدن درها» مقدارِ همه است و کلیک‌های تکی را پاک می‌کند.
  • بازشو در صحنه (فاز ۳.۴ و ۳.۵): دستگیره از ماشین‌کاری می‌آید، نه از حدس — handleSpot دو سوراخِ همان قطعه‌ای را می‌خواند که مته می‌خورد (handleHoles، و در دربِ قاب‌دار قیدِ آزاد یا صفحهٔ وسط) و میله را میانِ آن دو می‌گذارد؛ پس نقشه و قطعهٔ سوراخ‌شده نمی‌توانند اختلاف داشته باشند. دستگیرهٔ ریلی سوراخ ندارد و به پهنای نما روی لبه می‌نشیند. یونیتی که بازشوش دستگیره نیست هیچ میله‌ای ندارد؛ به‌جایش: پروفیل C/L آلومینیومی درونِ همان شکافی که نماها برایش کوتاه شده‌اند (channel-instances.ts، metal)، و شیارِ فرزخورده به‌صورتِ یک مستطیلِ تیره آنجا که دیده می‌شود — شیارِ انگشتی روی رویه، J روی خودِ لبه (gripMark). نمای دستگاه با دستگاهش باز می‌شود: ظرف‌شویی و فر به پایین، یخچال به پهلو، یک ربعِ دور.
  • اتاق و متعلقات (۳.۶ تا ۳.۹): یک ماده به ازای هر ظاهر و نه هر قطعه (src/scene/material-cache.ts: آشپزخانهٔ نمونه ۹۲ ← ۵)، سایه فقط از پوستهٔ بیرونی، پیش‌فرضِ دیوار گچِ گرمِ روشن (DEFAULT_FINISHES)، مانع با شکلِ نوعِ خودش و برچسبِ شناور در انتخاب (Obstacles3D)، متعلقاتِ دیوار — پهلو، تاج، قرنیز، پرکن — از همان قطعه‌ای که بریده می‌شود (accessory-instances.ts؛ برابری در سطحِ پروژه)، و «قاب» با درهای باز (openBoundsOf).
  • میان‌پرده در src/scene/openness.ts، خالص و بی three: ۳۲۰ میلی‌ثانیه، درب با easeOutCubic (لولای آرام‌بندِ کاتالوگ) و کشو خطی (ریل چیزی برای میرا کردن ندارد)، و وقتی همه با هم باز می‌شوند هر کدام ۳۰ میلی‌ثانیه پس از قبلی راه می‌افتد. prefers-reduced-motion همان فریم سرِ جا. یک useFrame در RoomParts همهٔ گروه‌ها را با applyPose می‌نشاند — بی رندرِ دوبارهٔ React.

حلقهٔ رسم، آزادسازی، از دست رفتنِ زمینه (فاز ۷)

Section titled “حلقهٔ رسم، آزادسازی، از دست رفتنِ زمینه (فاز ۷)”
  • frameloop="demand" (۷.۸، V18): صحنه فقط وقتی چیزی عوض شد رسم می‌شود. r3f با تغییرِ props خودش invalidate می‌کند و OrbitControls ِ drei با رویدادِ change (میرایی هم)؛ هر تغییرِ بیرون از props دستی: Clipping (برشِ رندرکننده)، RenderBridge (پس از رندرِ آفلاین که اندازهٔ بوم را عوض و برگرداند)، و باز شدنِ نماها (یک useFrame در RoomParts برای کلِ صحنه؛ تا نماها نرسیده‌اند فریم می‌خواهد؛ dt سقفِ ۱/۳۰ دارد چون فریمِ اولِ پس از سکون فاصله تا فریمِ قبلی را می‌گیرد). صحنهٔ ساکن صفر فریم: e2e/scene-idle.spec.ts فراخوانِ رسمِ WebGL را می‌شمارد.
  • آزادسازی (V19): geometry={…} را r3f آزاد نمی‌کند. هندسهٔ مالِ جزء (صفحهٔ سوراخ‌دار، WallWash) در پاک‌سازیِ اثر dispose می‌شود؛ انبارهٔ panelGeometryCached شمارِ کاربر دارد (retainGeometry): آخرین کاربر که رفت، بافرِ کارت گرافیک پس از ریزکار همان‌جا آزاد می‌شود (شیء در انباره می‌ماند و three دوباره بارش می‌کند)، و سرریز (۸۰۰) فقط قدیمی‌ترین‌های بی‌کاربر را از انباره برمی‌دارد. e2e/scene-idle.spec.ts بافرهای زندهٔ WebGL را پیش و پس از ۵۰ ویرایشِ عرض می‌شمارد (سنجیده: ۳۰۲ ← ۲۶۴).
  • بودجهٔ کارایی (۷.۹، e2e/perf.spec.ts، تنها با --project perf --no-deps): ۴۰ یونیت، p50 و p95 ِ فاصلهٔ فریم در کشیدنِ مرزِ دوبعدی، کشیدن در «هر دو» و چرخاندنِ صحنه؛ آستانه ≈ ۱٫۵ برابرِ سنجیده روی SwiftShader. نشانگرِ روی یونیت در چرخاندن (دکمهٔ فشرده) عوض نمی‌شود — آشتیِ همهٔ قطعه‌ها در هر گذر چرخش را کند می‌کرد (p50 ۱۴۲۳ ← ۱۱۴۴، p95 ۳۴۵۴ ← ۲۲۵۷ میلی‌ثانیه).
  • انبارهٔ سطح با سقف (V20، lib/textures/index.ts): ۱۶ سطح (≈ ۶۷ مگابایت)، کم‌استفاده‌ترین آزاد می‌شود؛ سطحِ هنوز سوار را three از همان بومِ پخته دوباره بار می‌کند.
  • «هر دو» ارزان (۷.۸، V17): Scene3D پروژه را با useFrameBatched حداکثر یک بار در هر فریم می‌خواند (یک درخواستِ درراه، نه لغو با هر تغییر — وگرنه کشیدنِ پیوسته هرگز به فریم نمی‌رسید) و projectScene(project, cat, cache) را یک بار صدا می‌زند (Room صحنه را پارامتر می‌گیرد). انبارهٔ createSceneCache() ِ همان نما قطعه‌های هر یونیت را با هویتِ شیءِ یونیت نگه می‌دارد: immer یونیتِ دست‌نخورده را همان شیء می‌گذارد، پس فقط یونیتِ تغییرکرده PartInstance ِ تازه و هندسهٔ تازه می‌گیرد.
  • نورِ نوارِ زیرِ کابینت (V21): lightStrips(project) روی دیوارِ خودش (wallTransform) و stripLightPoints(strips, 4) — همیشه چهار چراغ؛ بی کابینتِ دیواری خاموش (شدتِ صفر)، نه برداشته.
  • انتخاب در سه‌بعدی (۷.۱۱، V24): همهٔ selection (نه فقط انتخابِ اصلی) با تابشِ MARK؛ یونیتِ زیرِ نشانگر تابشِ کم‌تر (Mark = 'pointed'، وضعیتِ همان Room)؛ کلیک روی جای خالی (onPointerMissed ِ r3f، تا ۲ پیکسل جابه‌جایی) انتخاب را برمی‌دارد، جز در اندازه‌گیری. F (scene.focus در رجیستری، لایهٔ scene) با تمرکز درونِ صحنه — قابِ بوم tabIndex={-1} است تا کلیک تمرکز را بیاورد — focusPose محدودهٔ انتخاب را از همان سوی دوربین در قاب می‌گذارد.
  • پایداری (۷.۱۰، V22): SceneCanvas در سوار شدن webglSupported() را می‌پرسد (زمینهٔ آزمایشی با WEBGL_lose_context رها می‌شود) و روی webglcontextlost خودش جای بوم SceneStopped می‌گذارد: «نمای سه‌بعدی متوقف شد» با «دوباره» (بومِ تازه با key) و «برو به دوبعدی»؛ بی WebGL «نمای سه‌بعدی در دسترس نیست» بی «دوباره»؛ تکهٔ تنبلِ صحنه که نرسد (SceneChunk) «نمای سه‌بعدی بار نشد» با «دوباره» (lazy ِ تازه).

scene/presets.ts: شش نمای آماده از محدودهٔ صحنه و نسبت پنل — سه‌رخ (همان قاب‌گیری پیش‌فرض)، روبه‌رو، بالا، گوشهٔ چپ/راست، «نمای کار» (چشم ۱۶۰ سانت، ۲۲۰ سانت جلوی جلوترین لبهٔ صحنه). اعمال روی همان OrbitControls (applyPose) تا میرایی و حد فاصله بماند. «ذخیرهٔ نمای فعلی» → project.views[] = { id, name, pos, target } (متر، مختصات صحنه) و در همان منو فهرست می‌شود.

  • دستگاهِ یونیت: z از دیوار به بیرون. یونیت با پشت‌بندش به دیوار تکیه می‌دهد: پشت‌بندِ روکار در [0, ضخامت] و بدنه از پشتِ آن (depthFromWall = totalDepth)؛ پیش‌تر پشت‌بند درونِ دیوار (z منفی) بود. نمای دستگاهِ توکار هم از همان جلوی بدنه. صفحهٔ کابینت از خودِ دیوار اندازه می‌خورد (ورقِ بازار)، پس پیش‌آمدگی‌اش جلوی یونیتِ پشت‌بنددار به اندازهٔ پشت‌بند کمتر است — همان که نصب می‌شود.
  • چرخش: size ِ هر جعبه در دستگاهِ خودش است (x در امتدادِ دیوار) و rotY آن را می‌چرخاند؛ boundsOf و isoBox هر دو چرخش را می‌بینند. مرزِ صحنهٔ ال‌شکل همان مرزِ گوشه‌های چرخیده است (آزمونِ walls.test.ts).
  • کلاف‌ها به ترتیبِ هسته‌اند: rail:0 جلو، rail:1 عقب.
  • نمای روبه‌رو از دیوارِ جاری است (RenderBridge ِ wallId): دوربینِ متعامد سرِ گوشهٔ پایین-چپِ دیوار و ۱۲ متر جلوتر در امتدادِ نرمالش (lib/render/elevation.ts)، نورِ اصلی هم از جلوی همان دیوار. ارتفاعِ خروجی از پهنای مهارشده است (outputSize).
  • لغزندهٔ «برش»: صفحهٔ برش سراسری رندرکننده روی محور z (scene/Clipping.tsx، gl.clippingPlanes) — درصدی از عمق صحنه از دیوار به بیرون؛ داخل کشو و پشت‌بند دیده می‌شود.
  • «اندازه»: با روشن بودنش، کلیک روی هر قطعه نقطه می‌گیرد؛ دو نقطه → خط و برچسب فاصله به میلی‌متر (scene/Measure.tsx با Html از drei). نقطهٔ سوم از نو.

scene/FinishesPanel.tsx روی room.finishes (رنگ دیوار، کف: نوع و رنگ)؛ صحنه (scene/Room.tsx) گچ دیوار را با همان رنگ و کف را با سطح نوع (چوب → بافت چوب، سرامیک/سنگ → کوارتز، بتن → خام) می‌سازد. ویرایش‌های پیاپی یک قدم واگرد.

حالت «مونتاژ» در نوار: assemblySteps(unit, cat) برای یونیت انتخاب‌شده (از راهِ stepsOf ِ scene/assembly.ts؛ یونیتِ ناساختنی بی گام)؛ rolesUpTo(steps, i) نقش‌های سوارشده تا گام جاری را می‌دهد و بقیهٔ قطعات همان یونیت شفاف می‌شوند (PartBox.dimmed). قبلی/بعدی در SceneTools.

دستگاه‌های توکار پارامتری (V4)

Section titled “دستگاه‌های توکار پارامتری (V4)”

یونیتی که appliance دارد (فر، یخچال، مایکروویو، ماشین ظرف‌شویی) در صحنه یک «نمای دستگاه» می‌گیرد: جعبه‌ای به اندازهٔ دهانهٔ یونیت منهای ده میلی‌متر لقی از هر طرف، هجده میلی‌متر جلوی بدنه. هندسه در packages/geometry/src/fixtures.ts (applianceFixtures) ساخته می‌شود و ظاهرش در apps/web/src/components/FixturesAppliances.tsx از یک جدول کوچک می‌آید: پنجرهٔ شیشه‌ای، دستگیرهٔ افقی یا عمودی، نوار کنترل. مدل دانلودی و بافت نداریم — آفلاین می‌مانیم و اندازه از خودِ یونیت می‌آید.

رندر: لغو، نگه‌داشتن در پروژه، جلد گزارش (V6)

Section titled “رندر: لغو، نگه‌داشتن در پروژه، جلد گزارش (V6)”
  • لغو: RenderRequest.signal بین دو نمونهٔ انباشت بررسی می‌شود و با AbortError بیرون می‌آید؛ پنل رندر دکمهٔ «لغو» را هنگام کار نشان می‌دهد.
  • نگه‌داشتن در پروژه: project.renders (نوع SavedRender) تا سقف شش رندر؛ هر کدام فقط assetId دارد — بایت در AssetRepo ِ مرورگر و در assets/ ِ پروندهٔ .darz، که همچنان خودکفاست (data-model.md، «تصویر بیرون از سند»). فهرست با بندانگشتی، «جلد گزارش» و «حذف» زیر تنظیمات رندر می‌آید.
  • جلد گزارش: coverOf(project, url) در بستهٔ گزارش — url شناسهٔ تصویر را به data URL می‌رساند (وب از AssetRepo، خط فرمان از assets/ ِ .darz) — رندرِ علامت‌خورده (یا تازه‌ترین) را به ReportInput.cover می‌دهد و صفحهٔ اول گزارش آن را نشان می‌دهد. وب و CLI هر دو همین را می‌فرستند.

رندرِ آفلاین و بافرِ نمای تعاملی (۷.۱۳، V26)

Section titled “رندرِ آفلاین و بافرِ نمای تعاملی (۷.۱۳، V26)”
  • نفس: حلقهٔ انباشت میان دو نمونه با breathe() ِ lib/render/breathe.ts (یک پیامِ MessageChannel) نفس می‌کشد، نه setTimeout(0) — زمان‌سنجِ زبانهٔ پنهان به ≥ ۱ ثانیه کُند می‌شود.
  • شکوفهٔ نمرهٔ رنگی (lib/grade.ts) محوش را با blurRgb ِ lib/render/blur.ts (سه گذرِ جعبه‌ای، σ=۳) روی بومِ یک‌چهارم می‌زند؛ ctx.filter نیست، چون سافاری تا ۱۸ آن را بی‌صدا نادیده می‌گرفت.
  • نمای تعاملی بی preserveDrawingBuffer: رندرِ آفلاین بافر را همان‌جا پس از gl.render می‌خواند؛ آزمونی که پیکسلِ صحنه را می‌خواهد عکسِ ترکیب‌شدهٔ بوم را می‌گیرد (e2e/theme.spec.ts).
  • خطاهای بوم و تصویر DarzError با پیام‌اند (lib/render/errors.ts)، نه متنِ فارسی.

عکسِ دیوار: Worker، جهتِ EXIF، مهار (۷.۱۲، V25)

Section titled “عکسِ دیوار: Worker، جهتِ EXIF، مهار (۷.۱۲، V25)”
  • راست‌سازی در Worker: lib/rectify.ts فقط ریاضی و پیکسل است (rectifyPixelslib/rectify.worker.ts با OffscreenCanvas می‌خواندش و پیکسل‌های منبع را برای کشیدنِ بعدیِ گوشه نگه می‌دارد. Rectifier ِ lib/rectify-client.ts صفِ «آخری می‌برد» با درنگِ ۱۲۰ میلی‌ثانیه است و درخواست‌های هم‌کلید (ویرایشگر و بوم) یک کار می‌شوند؛ بی OffscreenCanvas همان کار روی نخ اصلی.
  • پرونده: checkPhotoFile (components/photo/photo-file.ts) نوع (عکسِ شطرنجی؛ SVG نه) و سقفِ ۳۰ مگابایت را پیش از خواندن می‌سنجد؛ downscale با createImageBitmap(file, { imageOrientation: 'from-image' }) جهتِ EXIF را اعمال می‌کند (e2e/photo.spec.ts).
  • گوشه: قابِ عکس اشاره‌گر را می‌گیرد (setPointerCapture)؛ پایانِ کشیدن رها کردن یا lostpointercapture است، نه خروج از قاب.
  • مانع از کلیک روی عکس با clampObstacleCenter (components/photo/place.ts) درونِ دیوار می‌ماند.
  • برچسبِ نمونه‌های packages/geometry پیام است (src/labels.ts)، لبه رندرش می‌کند.

تطبیق دوربین با عکس (V7، پژوهشی)

Section titled “تطبیق دوربین با عکس (V7، پژوهشی)”

apps/web/src/lib/pose.ts از چهار گوشهٔ کالیبراسیون (photo.corners) و مستطیل مرجع (photo.reference) هموگرافی دیوار→عکس را با همان solveHomography ِ هسته می‌سازد (حل‌کنندهٔ دوم رفت) و با فرض فاصلهٔ کانونی، به وضعیت دوربین تجزیه می‌کند (H = K [r1 r2 t]، در قرارداد بینایی ماشین و بعد برگردان به دستگاه دیوار). photoCamera(wall, focal) در RenderBridge همان را به PerspectiveCamera سه‌بعدی در مختصات اتاق تبدیل می‌کند؛ رندر «سه‌رخ» با تیک «روی عکس اصلی، با دوربین عکس» با این دوربین و نسبت تصویرِ عکس گرفته و روی عکس اصلی (نه صاف‌شده) می‌نشیند.

⚠️ فاصلهٔ کانونی از عکس درنمی‌آید (EXIF نگه نمی‌داریم). حدس پیش‌فرض دوربین اصلی گوشی است (۲۶ میلی‌متر معادل: ضلع بلند × ۲۶ / ۳۶) و لغزندهٔ «فاصلهٔ کانونی» آن را از ۰٫۵ تا ۱٫۶ برابر می‌لغزاند. آزمون test/pose.test.ts با دوربین مصنوعی نشان می‌دهد بازیابی جا، جهت و زاویهٔ دید دقیق است (خطای بازتصویر زیر ۱۰⁻¹⁰ پیکسل، سقف پذیرفته ۳ پیکسل).

apps/web/e2e/visual.spec.ts از بوم سه‌بعدی آشپزخانهٔ نمونه در پوستهٔ تیره عکس می‌گیرد و با عکس مرجع می‌سنجد (maxDiffPixelRatio: 0.03). عکسِ مرجع فقط در تصویر رسمیِ Playwright در CI معتبر است و همان‌جا مقایسه می‌شود؛ نبودنش شکست است، و عکسِ تازه از کارِ دستیِ snapshots می‌آید (testing.md). تا نسل چهارم فقط عکسِ مک کامیت بود و آزمون بی عکسِ سکوی خودش می‌نوشتش و پاس می‌شد: هرگز مقایسه نکرده بود.