Compare commits

...

4 Commits

Author SHA1 Message Date
selune 492e2d1c17 docs(render): portable shutdown handoff and exact validation status 2026-10-31 13:09:55 +01:00
selune a09ecc2ec6 wip(render): preserve exact progressive-render source before handoff
Emergency draft source checkpoint from A077; existing authored history is retained through the real parent. No source repair or claimed authorship reassignment. Exact C28 source binding d38a: 28 PASS / 1 font-fixture FAIL / 954 UNRUN; three postchecks UNRUN. Native acceptance/full CI/readiness incomplete. Separate unadopted fixture/compatibility proposals remain separate; see preservation notes.
2026-10-31 13:04:20 +01:00
lillith a077906234 feat(render): add bounded CPU Interactive request lifecycle 2026-10-29 20:18:50 +01:00
lillith b2733d607a claim: #8936 2026-10-29 18:16:13 +01:00
139 changed files with 203686 additions and 147772 deletions
+4624 -4624
View File
File diff suppressed because it is too large Load Diff
+4310 -4310
View File
File diff suppressed because it is too large Load Diff
+4287 -4287
View File
File diff suppressed because it is too large Load Diff
+3910 -3909
View File
File diff suppressed because it is too large Load Diff
+4624 -4624
View File
File diff suppressed because it is too large Load Diff
+3658 -3658
View File
File diff suppressed because it is too large Load Diff
+4314 -4314
View File
File diff suppressed because it is too large Load Diff
+4287 -4287
View File
File diff suppressed because it is too large Load Diff
+4624 -4624
View File
File diff suppressed because it is too large Load Diff
+4310 -4310
View File
File diff suppressed because it is too large Load Diff
+4287 -4287
View File
File diff suppressed because it is too large Load Diff
+4287 -4287
View File
File diff suppressed because it is too large Load Diff
+4295 -4295
View File
File diff suppressed because it is too large Load Diff
+4624 -4624
View File
File diff suppressed because it is too large Load Diff
+3681 -3680
View File
File diff suppressed because it is too large Load Diff
+4287 -4287
View File
File diff suppressed because it is too large Load Diff
+4287 -4287
View File
File diff suppressed because it is too large Load Diff
+4291 -4291
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,56 @@
{
"en": {
"render.cpu_glass_continue": "Continue reflections",
"render.cpu_glass_help": "Standard uses bounded paths and may darken reflective glass. Continue reflections stops with an error if a supported optical limit is reached. Selected opaque reflections and unsupported glass shapes remain unavailable.",
"render.cpu_glass_label": "Glass rendering",
"render.cpu_glass_standard": "Standard"
},
"ar": {
"render.cpu_glass_continue": "متابعة الانعكاسات",
"render.cpu_glass_help": "يستخدم الوضع القياسي مسارات ضوء محدودة، وقد يجعل الزجاج العاكس أغمق. يوقف خيار «متابعة الانعكاسات» التصيير مع ظهور خطأ عند بلوغ أحد الحدود البصرية المدعومة. تظل الانعكاسات المحددة على الأجسام المعتمة وأشكال الزجاج غير المدعومة غير متاحة.",
"render.cpu_glass_label": "تصيير الزجاج",
"render.cpu_glass_standard": "قياسي"
},
"de": {
"render.cpu_glass_continue": "Reflexionen fortsetzen",
"render.cpu_glass_help": "Standard begrenzt die Lichtwege und kann reflektierendes Glas abdunkeln. Reflexionen fortsetzen stoppt das Rendering mit einem Fehler, wenn eine unterstützte optische Grenze erreicht wird. Ausgewählte opake Reflexionen und nicht unterstützte Glasformen bleiben nicht verfügbar.",
"render.cpu_glass_label": "Glasdarstellung",
"render.cpu_glass_standard": "Standard"
},
"es": {
"render.cpu_glass_continue": "Continuar reflejos",
"render.cpu_glass_help": "Estándar limita los recorridos y puede oscurecer el vidrio reflectante. Continuar reflejos detiene el render con un error si alcanza un límite óptico admitido. Los reflejos opacos seleccionados y las formas de vidrio no compatibles siguen sin estar disponibles.",
"render.cpu_glass_label": "Render de vidrio",
"render.cpu_glass_standard": "Estándar"
},
"he": {
"render.cpu_glass_continue": "המשך השתקפויות",
"render.cpu_glass_help": "המצב הרגיל מגביל את מסלולי האור ועלול להכהות זכוכית מחזירת אור. האפשרות «המשך השתקפויות» עוצרת את הרינדור עם שגיאה כאשר מגיעים לאחת המגבלות האופטיות הנתמכות. השתקפויות נבחרות על גופים אטומים וצורות זכוכית שאינן נתמכות נשארות לא זמינות.",
"render.cpu_glass_label": "רינדור זכוכית",
"render.cpu_glass_standard": "רגיל"
},
"ja": {
"render.cpu_glass_continue": "反射を継続",
"render.cpu_glass_help": "標準では光路を制限するため、反射するガラスが暗くなる場合があります。反射を継続する設定では、対応する光学的な上限に達するとエラーでレンダリングを停止します。選択された不透明面の反射や未対応のガラス形状は引き続き使用できません。",
"render.cpu_glass_label": "ガラスの描画",
"render.cpu_glass_standard": "標準"
},
"ko": {
"render.cpu_glass_continue": "반사 계속",
"render.cpu_glass_help": "표준은 광선 경로를 제한하므로 반사 유리가 어둡게 보일 수 있습니다. 반사 계속은 지원되는 광학 한계에 도달하면 오류와 함께 렌더링을 중지합니다. 선택한 불투명 표면 반사와 지원되지 않는 유리 형상은 계속 사용할 수 없습니다.",
"render.cpu_glass_label": "유리 렌더링",
"render.cpu_glass_standard": "표준"
},
"vi": {
"render.cpu_glass_continue": "Tiếp tục phản xạ",
"render.cpu_glass_help": "Chế độ tiêu chuẩn giới hạn đường truyền ánh sáng và có thể làm kính phản xạ trông tối hơn. Tùy chọn Tiếp tục phản xạ dừng kết xuất kèm lỗi khi đạt một giới hạn quang học được hỗ trợ. Các phản xạ đã chọn trên vật thể đục và các hình dạng kính không được hỗ trợ vẫn không khả dụng.",
"render.cpu_glass_label": "Kết xuất kính",
"render.cpu_glass_standard": "Tiêu chuẩn"
},
"zh": {
"render.cpu_glass_continue": "继续反射",
"render.cpu_glass_help": "标准模式限制光线路径,可能使反射玻璃变暗。继续反射模式在达到支持的光学限制时会报错并停止渲染。所选不透明表面反射和不支持的玻璃形状仍不可用。",
"render.cpu_glass_label": "玻璃渲染",
"render.cpu_glass_standard": "标准"
}
}
@@ -0,0 +1,47 @@
{
"en": {
"render.cpu_directional_shadow_help": "CPU optics are temporary and require certified sheets or axis-aligned boxes with opaque body and face appearances. Directional transmitted shadows require global shadows on and all active assignments to agree. Mixed choices, nesting, environment lighting and local lights are unsupported in that mode. This is straight RGB visibility, not refracted shadows or caustics. Subsurface, coats and maps remain unsupported; fixed and progressive sampling retain finite-depth limits.",
"render.cpu_shadow_conflict": "The global directional transmission option conflicts with an optical assignment whose transmitted shadows are off. No authored option was changed.",
"render.cpu_shadow_mixed": "CPU uses one transmitted-shadow switch for all active optical assignments. Mixed on/off choices cannot be represented and have been preserved."
},
"es": {
"render.cpu_directional_shadow_help": "La óptica CPU es temporal y requiere láminas o cajas alineadas con los ejes certificadas, con aspectos opacos de cuerpo y caras. Las sombras transmitidas direccionales requieren sombras globales activadas y que todas las asignaciones activas coincidan. Ese modo no admite mezclas, nesting, iluminación de entorno ni luces locales. Es visibilidad RGB recta, sin sombras refractadas ni cáusticas. La dispersión subsuperficial, las capas y los mapas siguen sin admitirse; el muestreo fijo y progresivo conserva los límites de profundidad.",
"render.cpu_shadow_conflict": "La opción global de transmisión direccional contradice una asignación óptica con sombras transmitidas desactivadas. No se ha cambiado ninguna opción definida.",
"render.cpu_shadow_mixed": "CPU usa un único control de sombras transmitidas para todas las asignaciones ópticas activas. No puede representar opciones activadas y desactivadas mezcladas; se han conservado."
},
"de": {
"render.cpu_directional_shadow_help": "CPU-Optik ist temporär und erfordert zertifizierte Scheiben oder achsenparallele Quader mit opaken Körper- und Flächenerscheinungen. Gerichtete durchgelassene Schatten erfordern globale Schatten und übereinstimmende aktive Zuweisungen. Gemischte Optionen, Verschachtelung, Umgebungslicht und lokale Lichter sind in diesem Modus nicht unterstützt. Dies ist geradlinige RGB-Sichtbarkeit, keine gebrochenen Schatten oder Kaustiken. Subsurface-Streuung, Beschichtungen und Maps bleiben nicht unterstützt; feste und progressive Abtastung behalten die endliche Tiefengrenze.",
"render.cpu_shadow_conflict": "Die globale gerichtete Transmission widerspricht einer optischen Zuweisung mit ausgeschalteten durchgelassenen Schatten. Keine festgelegte Option wurde geändert.",
"render.cpu_shadow_mixed": "CPU verwendet einen Schalter für durchgelassene Schatten aller aktiven optischen Zuweisungen. Gemischte Ein-/Aus-Werte sind nicht darstellbar und wurden beibehalten."
},
"ko": {
"render.cpu_directional_shadow_help": "CPU 광학은 임시 설정이며 불투명한 바디와 면 외관을 가진 인증된 판 또는 축 정렬 상자가 필요합니다. 방향성 투과 그림자는 전역 그림자가 켜져 있고 모든 활성 할당의 설정이 같아야 합니다. 이 모드는 혼합 설정, 중첩, 환경 조명 및 로컬 광원을 지원하지 않습니다. 이는 직선 RGB 가시성이며 굴절 그림자나 코스틱이 아닙니다. 표면하 산란, 코팅 및 맵은 계속 지원하지 않으며 고정 및 점진적 샘플링은 유한 깊이 제한을 유지합니다.",
"render.cpu_shadow_conflict": "전역 방향성 투과 옵션이 투과 그림자가 꺼진 광학 할당과 충돌합니다. 작성된 옵션은 변경되지 않았습니다.",
"render.cpu_shadow_mixed": "CPU는 모든 활성 광학 할당에 하나의 투과 그림자 스위치를 사용합니다. 켜짐과 꺼짐이 섞인 설정은 표현할 수 없으며 그대로 보존되었습니다."
},
"ar": {
"render.cpu_directional_shadow_help": "بصريات CPU مؤقتة وتتطلب صفائح أو صناديق محاذية للمحاور اجتازت التحقق، بمظاهر معتمة للجسم والأوجه. تتطلب الظلال النافذة الاتجاهية تفعيل الظلال العامة وتطابق كل التعيينات النشطة. لا يدعم هذا الوضع الخيارات المختلطة أو التداخل أو إضاءة البيئة أو الأضواء المحلية. هذه رؤية RGB مستقيمة وليست ظلالًا منكسرة أو كاوستيك. يبقى التشتت تحت السطحي والطلاءات والخرائط غير مدعوم، وتبقى حدود العمق المحدودة لأخذ العينات الثابت والتدريجي.",
"render.cpu_shadow_conflict": "يتعارض خيار النفاذ الاتجاهي العام مع تعيين بصري تكون ظلاله النافذة معطلة. لم يتغير أي خيار محدد.",
"render.cpu_shadow_mixed": "يستخدم CPU مفتاح ظلال نافذة واحدًا لجميع التعيينات البصرية النشطة. لا يمكن تمثيل خليط من التفعيل والتعطيل، وقد تم الاحتفاظ بالخيارات."
},
"he": {
"render.cpu_directional_shadow_help": "אופטיקת CPU היא זמנית ודורשת יריעות או תיבות מיושרות לצירים שעברו אימות, עם מראה אטום של הגוף והפאות. צללים מועברים כיווניים דורשים צללים כלליים פעילים ואחידות בכל ההקצאות הפעילות. מצב זה אינו תומך בערכים מעורבים, קינון, תאורת סביבה או אורות מקומיים. זו נראות RGB בקו ישר, ללא צללים נשברים או קאוסטיקה. פיזור תת-משטחי, ציפויים ומפות עדיין אינם נתמכים; דגימה קבועה והדרגתית שומרות על מגבלות העומק הסופי.",
"render.cpu_shadow_conflict": "אפשרות ההעברה הכיוונית הכללית סותרת הקצאה אופטית שהצללים המועברים שלה כבויים. שום אפשרות שנקבעה לא שונתה.",
"render.cpu_shadow_mixed": "CPU משתמש במתג צללים מועברים אחד לכל ההקצאות האופטיות הפעילות. לא ניתן לייצג ערכים מעורבים של פעיל וכבוי, והם נשמרו."
},
"ja": {
"render.cpu_directional_shadow_help": "CPU 光学設定は一時的で、不透明なボディと面の外観を持つ認証済みの薄板または軸に平行なボックスが必要です。平行光源の透過シャドウには全体のシャドウを有効にし、すべての有効な割り当ての設定を一致させる必要があります。このモードでは混在設定、入れ子、環境光、ローカルライトは非対応です。これは直線上の RGB 可視性であり、屈折シャドウやコースティクスではありません。表面下散乱、コート、マップは引き続き非対応で、固定およびプログレッシブサンプリングでも有限深度制限は残ります。",
"render.cpu_shadow_conflict": "全体の平行光透過オプションが、透過シャドウを無効にした光学割り当てと競合しています。設定したオプションは変更されていません。",
"render.cpu_shadow_mixed": "CPU はすべての有効な光学割り当てに単一の透過シャドウスイッチを使用します。有効と無効の混在は表現できないため、その設定を保持しています。"
},
"vi": {
"render.cpu_directional_shadow_help": "Quang học CPU là tạm thời và cần tấm hoặc hộp song song các trục đã được kiểm chứng, với diện mạo thân và mặt đục. Bóng truyền qua từ đèn định hướng cần bật bóng toàn cục và mọi phép gán đang bật phải cùng lựa chọn. Chế độ này không hỗ trợ lựa chọn lẫn bật/tắt, lồng nhau, ánh sáng môi trường hoặc đèn cục bộ. Đây là độ nhìn thấy RGB theo đường thẳng, không phải bóng khúc xạ hay caustic. Tán xạ dưới bề mặt, lớp phủ và bản đồ vẫn không được hỗ trợ; lấy mẫu cố định và lũy tiến vẫn có giới hạn độ sâu hữu hạn.",
"render.cpu_shadow_conflict": "Tùy chọn truyền sáng định hướng toàn cục xung đột với phép gán quang học có bóng truyền qua đang tắt. Không thay đổi tùy chọn đã soạn nào.",
"render.cpu_shadow_mixed": "CPU dùng một công tắc bóng truyền qua cho tất cả phép gán quang học đang bật. Không thể biểu diễn lựa chọn lẫn bật và tắt; các lựa chọn đã được giữ nguyên."
},
"zh": {
"render.cpu_directional_shadow_help": "CPU 光学设置是临时的,需要通过认证的薄片或轴对齐盒体,以及不透明的实体和面外观。方向光透射阴影要求开启全局阴影,并且所有启用的分配采用相同设置。此模式不支持混合开关、嵌套、环境照明或局部光源。这是直线 RGB 可见性,并非折射阴影或焦散。次表面散射、涂层和贴图仍不支持;固定和渐进采样仍保留有限深度限制。",
"render.cpu_shadow_conflict": "全局方向光透射选项与关闭透射阴影的光学分配冲突。未更改任何已设置的选项。",
"render.cpu_shadow_mixed": "CPU 对所有启用的光学分配使用一个透射阴影开关。无法表示开启和关闭混合的设置,已保留这些设置。"
}
}
@@ -0,0 +1,29 @@
{
"en": {
"render.cpu_optics_certification": "CPU Check and Start validate captured identity, opaque body and face appearances, and the requested sheet or axis-aligned box certificate. Choosing a boundary does not certify geometry. Start rechecks the capture; passing Check does not guarantee later admission."
},
"es": {
"render.cpu_optics_certification": "Comprobar e Iniciar en CPU validan la identidad capturada, los aspectos opacos del cuerpo y sus caras, y el certificado de lámina o caja alineada con los ejes solicitado. Elegir una frontera no certifica la geometría. Iniciar comprueba de nuevo la captura; superar Comprobar no garantiza una admisión posterior."
},
"de": {
"render.cpu_optics_certification": "Prüfen und Starten auf der CPU validieren die erfasste Identität, opake Körper- und Flächenerscheinungen sowie das angeforderte Zertifikat für Scheiben oder achsenparallele Quader. Die Wahl einer Grenze zertifiziert keine Geometrie. Starten prüft die Erfassung erneut; erfolgreiches Prüfen garantiert keine spätere Zulassung."
},
"ko": {
"render.cpu_optics_certification": "CPU의 확인과 시작은 캡처한 식별 정보, 불투명한 바디와 면 외관, 요청한 판 또는 축 정렬 상자의 인증을 검증합니다. 경계를 선택하는 것만으로 형상이 인증되지는 않습니다. 시작 시 캡처를 다시 확인하며, 확인에 성공해도 이후 승인이 보장되지는 않습니다."
},
"ar": {
"render.cpu_optics_certification": "يتحقق الفحص والبدء على CPU من الهوية الملتقطة ومظاهر الجسم والأوجه المعتمة وشهادة الصفيحة أو الصندوق المحاذي للمحاور المطلوبة. اختيار حد لا يثبت صلاحية الهندسة. يعيد البدء فحص اللقطة؛ نجاح الفحص لا يضمن القبول لاحقًا."
},
"he": {
"render.cpu_optics_certification": "בדיקה והתחלה ב-CPU מאמתות את הזהות שנלכדה, מראה אטום של הגוף והפאות ואת האישור המבוקש ליריעה או לתיבה מיושרת לצירים. בחירת גבול אינה מאשרת את הגאומטריה. התחלה בודקת שוב את הלכידה; הצלחה בבדיקה אינה מבטיחה אישור מאוחר יותר."
},
"ja": {
"render.cpu_optics_certification": "CPU の確認と開始では、取得した識別情報、不透明なボディと面の外観、要求された薄板または軸に平行なボックスの認証を検証します。境界の選択だけでは形状は認証されません。開始時に取得内容を再確認するため、確認の成功は後の受け入れを保証しません。"
},
"vi": {
"render.cpu_optics_certification": "Kiểm tra và Bắt đầu trên CPU xác thực danh tính đã chụp, diện mạo thân và mặt đục, cùng chứng nhận tấm hoặc hộp song song các trục được yêu cầu. Chọn biên không chứng nhận hình học. Bắt đầu kiểm tra lại bản chụp; vượt qua Kiểm tra không bảo đảm được chấp nhận về sau."
},
"zh": {
"render.cpu_optics_certification": "CPU 的检查和开始会验证捕获的身份、不透明的实体和面外观,以及请求的薄片或轴对齐盒体认证。选择边界本身并不能认证几何体。开始时会重新检查捕获内容;通过检查并不保证之后仍能获准执行。"
}
}
@@ -0,0 +1,110 @@
{
"en": {
"render.cpu_optics_boundary": "CPU requires an explicit Thin sheet or Box boundary. The captured geometry must pass certification.",
"render.cpu_optics_conflict": "CPU optical assignments cannot replace existing dielectric overrides.",
"render.cpu_optics_enable": "Use these optics on CPU",
"render.cpu_optics_explicit": "Review this assignment and explicitly enable its CPU optical route.",
"render.cpu_optics_fixed": "The installed CPU transport cannot admit this optical sampling request.",
"render.cpu_optics_help": "Temporary CPU optics: fixed or progressive sampling, opaque body and face appearances, certified sheets or axis-aligned boxes. Transmitted shadows, subsurface, coats and maps are unsupported. Box nesting needs explicit opt-in and certification. Accumulation retains the finite-depth transport limits.",
"render.cpu_optics_limit": "CPU supports at most eight optical body assignments.",
"render.cpu_optics_request": "CPU optics require the typed CPU image request and its existing options.",
"render.cpu_optics_saved": "Saved GPU optics cannot be mixed with temporary CPU optical assignments.",
"render.cpu_optics_unsupported": "This CPU route supports glass without transmitted shadows, subsurface, coats or maps. Review the authored options; they have been preserved."
},
"es": {
"render.cpu_optics_boundary": "CPU requiere elegir explícitamente Lámina fina o Caja. La geometría capturada debe superar la certificación.",
"render.cpu_optics_conflict": "Las asignaciones ópticas CPU no pueden sustituir las modificaciones dieléctricas existentes.",
"render.cpu_optics_enable": "Usar esta óptica en CPU",
"render.cpu_optics_explicit": "Revisa esta asignación y activa explícitamente su ruta óptica CPU.",
"render.cpu_optics_fixed": "El transporte CPU instalado no puede admitir esta solicitud de muestreo óptico.",
"render.cpu_optics_help": "Óptica CPU temporal: muestreo fijo o progresivo, aspectos opacos de cuerpo y caras, láminas o cajas alineadas con los ejes certificadas. No admite sombras transmitidas, dispersión subsuperficial, capas ni mapas. Las cajas anidadas requieren activación explícita y certificación. La acumulación conserva los límites de profundidad del transporte.",
"render.cpu_optics_limit": "CPU admite como máximo ocho asignaciones ópticas de cuerpo.",
"render.cpu_optics_request": "La óptica CPU requiere la solicitud de imagen CPU tipada y sus opciones existentes.",
"render.cpu_optics_saved": "La óptica GPU guardada no se puede mezclar con asignaciones ópticas CPU temporales.",
"render.cpu_optics_unsupported": "Esta ruta CPU admite vidrio sin sombras transmitidas, dispersión subsuperficial, capas ni mapas. Revisa las opciones definidas; se han conservado."
},
"de": {
"render.cpu_optics_boundary": "CPU erfordert die ausdrückliche Wahl von Dünner Scheibe oder Quader. Die erfasste Geometrie muss zertifiziert werden.",
"render.cpu_optics_conflict": "Optische CPU-Zuweisungen können vorhandene dielektrische Überschreibungen nicht ersetzen.",
"render.cpu_optics_enable": "Diese Optik auf der CPU verwenden",
"render.cpu_optics_explicit": "Prüfen Sie diese Zuweisung und aktivieren Sie ihren optischen CPU-Pfad ausdrücklich.",
"render.cpu_optics_fixed": "Der installierte CPU-Transport kann diese optische Abtastanforderung nicht zulassen.",
"render.cpu_optics_help": "Temporäre CPU-Optik: feste oder progressive Abtastung, opake Körper- und Flächenerscheinungen, zertifizierte Scheiben oder achsenparallele Quader. Durchgelassene Schatten, Subsurface-Streuung, Beschichtungen und Maps werden nicht unterstützt. Verschachtelte Quader erfordern ausdrückliche Aktivierung und Zertifizierung. Die Akkumulation behält die endliche Transporttiefe bei.",
"render.cpu_optics_limit": "CPU unterstützt höchstens acht optische Körperzuweisungen.",
"render.cpu_optics_request": "CPU-Optik erfordert die typisierte CPU-Bildanforderung und deren vorhandene Optionen.",
"render.cpu_optics_saved": "Gespeicherte GPU-Optik kann nicht mit temporären optischen CPU-Zuweisungen kombiniert werden.",
"render.cpu_optics_unsupported": "Dieser CPU-Pfad unterstützt Glas ohne durchgelassene Schatten, Subsurface-Streuung, Beschichtungen oder Maps. Prüfen Sie die unverändert erhaltenen Optionen."
},
"ko": {
"render.cpu_optics_boundary": "CPU에서는 얇은 판 또는 상자 경계를 명시적으로 선택해야 합니다. 캡처한 형상은 인증을 통과해야 합니다.",
"render.cpu_optics_conflict": "CPU 광학 할당은 기존 유전체 재정의를 대체할 수 없습니다.",
"render.cpu_optics_enable": "이 광학 설정을 CPU에서 사용",
"render.cpu_optics_explicit": "이 할당을 검토하고 CPU 광학 경로를 명시적으로 활성화하세요.",
"render.cpu_optics_fixed": "설치된 CPU 전송 모델은 이 광학 샘플링 요청을 허용할 수 없습니다.",
"render.cpu_optics_help": "임시 CPU 광학: 고정 또는 점진적 샘플링, 불투명한 바디와 면 외관, 인증된 판 또는 축 정렬 상자. 투과 그림자, 표면하 산란, 코팅 및 맵은 지원하지 않습니다. 중첩 상자는 명시적 활성화와 인증이 필요합니다. 누적해도 전송의 유한 깊이 제한은 유지됩니다.",
"render.cpu_optics_limit": "CPU는 최대 8개의 광학 바디 할당을 지원합니다.",
"render.cpu_optics_request": "CPU 광학에는 형식화된 CPU 이미지 요청과 기존 옵션이 필요합니다.",
"render.cpu_optics_saved": "저장된 GPU 광학을 임시 CPU 광학 할당과 혼합할 수 없습니다.",
"render.cpu_optics_unsupported": "이 CPU 경로는 투과 그림자, 표면하 산란, 코팅 및 맵이 없는 유리를 지원합니다. 작성된 옵션을 검토하세요. 옵션은 그대로 보존되었습니다."
},
"ar": {
"render.cpu_optics_boundary": "يتطلب CPU اختيار حد صفيحة رقيقة أو صندوق صراحةً. يجب أن تجتاز الهندسة الملتقطة التحقق.",
"render.cpu_optics_conflict": "لا يمكن لتعيينات البصريات على CPU استبدال تجاوزات العوازل الموجودة.",
"render.cpu_optics_enable": "استخدام هذه البصريات على CPU",
"render.cpu_optics_explicit": "راجع هذا التعيين وفعّل مساره البصري على CPU صراحةً.",
"render.cpu_optics_fixed": "لا يمكن لنموذج النقل المثبت على CPU قبول طلب أخذ العينات البصري هذا.",
"render.cpu_optics_help": "بصريات CPU مؤقتة: أخذ عينات ثابت أو تدريجي، ومظاهر معتمة للجسم والأوجه، وصفائح أو صناديق محاذية للمحاور اجتازت التحقق. الظلال النافذة والتشتت تحت السطحي والطلاءات والخرائط غير مدعومة. تتطلب الصناديق المتداخلة تفعيلًا صريحًا والتحقق. يبقى عمق النقل محدودًا حتى مع التراكم.",
"render.cpu_optics_limit": "يدعم CPU ثمانية تعيينات بصرية للأجسام كحد أقصى.",
"render.cpu_optics_request": "تتطلب بصريات CPU طلب صورة CPU محدد النوع وخياراته الموجودة.",
"render.cpu_optics_saved": "لا يمكن خلط بصريات GPU المحفوظة مع تعيينات بصريات CPU المؤقتة.",
"render.cpu_optics_unsupported": "يدعم مسار CPU هذا الزجاج دون ظلال نافذة أو تشتت تحت سطحي أو طلاءات أو خرائط. راجع الخيارات المحددة؛ فقد تم الاحتفاظ بها."
},
"he": {
"render.cpu_optics_boundary": "CPU דורש בחירה מפורשת בגבול יריעה דקה או תיבה. הגאומטריה שנלכדה חייבת לעבור אימות.",
"render.cpu_optics_conflict": "הקצאות אופטיות של CPU אינן יכולות להחליף דריסות דיאלקטריות קיימות.",
"render.cpu_optics_enable": "שימוש באופטיקה זו ב-CPU",
"render.cpu_optics_explicit": "יש לבדוק הקצאה זו ולהפעיל במפורש את הנתיב האופטי שלה ב-CPU.",
"render.cpu_optics_fixed": "מודל התעבורה המותקן ב-CPU אינו יכול לאשר בקשת דגימה אופטית זו.",
"render.cpu_optics_help": "אופטיקה זמנית ב-CPU: דגימה קבועה או הדרגתית, מראה אטום של הגוף והפאות, יריעות או תיבות מיושרות לצירים שעברו אימות. צללים מועברים, פיזור תת-משטחי, ציפויים ומפות אינם נתמכים. תיבות מקוננות דורשות הפעלה מפורשת ואימות. הצבירה שומרת על מגבלת עומק התעבורה הסופית.",
"render.cpu_optics_limit": "CPU תומך בעד שמונה הקצאות אופטיות לגופים.",
"render.cpu_optics_request": "אופטיקת CPU דורשת בקשת תמונת CPU מטיפוס מוגדר ואת האפשרויות הקיימות שלה.",
"render.cpu_optics_saved": "לא ניתן לערבב אופטיקת GPU שמורה עם הקצאות אופטיות זמניות של CPU.",
"render.cpu_optics_unsupported": "נתיב CPU זה תומך בזכוכית ללא צללים מועברים, פיזור תת-משטחי, ציפויים או מפות. יש לבדוק את האפשרויות שנקבעו; הן נשמרו ללא שינוי."
},
"ja": {
"render.cpu_optics_boundary": "CPU では薄板またはボックス境界を明示的に選択する必要があります。取得した形状は認証に合格する必要があります。",
"render.cpu_optics_conflict": "CPU の光学割り当てで既存の誘電体オーバーライドを置き換えることはできません。",
"render.cpu_optics_enable": "この光学設定を CPU で使用",
"render.cpu_optics_explicit": "この割り当てを確認し、CPU 光学経路を明示的に有効にしてください。",
"render.cpu_optics_fixed": "インストール済みの CPU 光輸送では、この光学サンプリング要求を受け付けられません。",
"render.cpu_optics_help": "一時的な CPU 光学設定:固定またはプログレッシブサンプリング、不透明なボディと面の外観、認証済みの薄板または軸に平行なボックス。透過シャドウ、表面下散乱、コート、マップは非対応です。入れ子のボックスには明示的な有効化と認証が必要です。累積しても光輸送の有限深度制限は残ります。",
"render.cpu_optics_limit": "CPU はボディへの光学割り当てを最大 8 個までサポートします。",
"render.cpu_optics_request": "CPU 光学設定には型付きの CPU 画像リクエストと既存のオプションが必要です。",
"render.cpu_optics_saved": "保存済み GPU 光学設定と一時的な CPU 光学割り当ては混在できません。",
"render.cpu_optics_unsupported": "この CPU 経路は透過シャドウ、表面下散乱、コート、マップのないガラスに対応します。設定したオプションを確認してください。内容は保持されています。"
},
"vi": {
"render.cpu_optics_boundary": "CPU yêu cầu chọn rõ ràng biên Tấm mỏng hoặc Hộp. Hình học đã chụp phải vượt qua kiểm chứng.",
"render.cpu_optics_conflict": "Các gán quang học CPU không thể thay thế các ghi đè điện môi hiện có.",
"render.cpu_optics_enable": "Dùng quang học này trên CPU",
"render.cpu_optics_explicit": "Hãy xem lại phép gán này và bật rõ ràng tuyến quang học CPU của nó.",
"render.cpu_optics_fixed": "Mô hình truyền ánh sáng CPU đã cài không thể chấp nhận yêu cầu lấy mẫu quang học này.",
"render.cpu_optics_help": "Quang học CPU tạm thời: lấy mẫu cố định hoặc lũy tiến, diện mạo thân và mặt đục, tấm hoặc hộp song song các trục đã được kiểm chứng. Không hỗ trợ bóng truyền qua, tán xạ dưới bề mặt, lớp phủ và bản đồ. Hộp lồng nhau cần được bật rõ ràng và kiểm chứng. Tích lũy vẫn giữ giới hạn độ sâu hữu hạn của truyền ánh sáng.",
"render.cpu_optics_limit": "CPU hỗ trợ tối đa tám phép gán quang học cho thân.",
"render.cpu_optics_request": "Quang học CPU yêu cầu yêu cầu ảnh CPU có kiểu cùng các tùy chọn hiện có.",
"render.cpu_optics_saved": "Không thể trộn quang học GPU đã lưu với các gán quang học CPU tạm thời.",
"render.cpu_optics_unsupported": "Tuyến CPU này hỗ trợ kính không có bóng truyền qua, tán xạ dưới bề mặt, lớp phủ hoặc bản đồ. Hãy xem lại các tùy chọn đã soạn; chúng đã được giữ nguyên."
},
"zh": {
"render.cpu_optics_boundary": "CPU 要求明确选择薄片或盒体边界。捕获的几何体必须通过认证。",
"render.cpu_optics_conflict": "CPU 光学分配不能替换现有的电介质覆盖设置。",
"render.cpu_optics_enable": "在 CPU 上使用此光学设置",
"render.cpu_optics_explicit": "请检查此分配并明确启用其 CPU 光学路径。",
"render.cpu_optics_fixed": "已安装的 CPU 光传输无法接受此光学采样请求。",
"render.cpu_optics_help": "临时 CPU 光学设置:固定或渐进采样、不透明的实体和面外观、通过认证的薄片或轴对齐盒体。不支持透射阴影、次表面散射、涂层或贴图。嵌套盒体需要明确启用并通过认证。累积仍保留光传输的有限深度限制。",
"render.cpu_optics_limit": "CPU 最多支持八个实体光学分配。",
"render.cpu_optics_request": "CPU 光学设置要求类型化的 CPU 图像请求及其现有选项。",
"render.cpu_optics_saved": "已保存的 GPU 光学设置不能与临时 CPU 光学分配混用。",
"render.cpu_optics_unsupported": "此 CPU 路径支持不含透射阴影、次表面散射、涂层或贴图的玻璃。请检查所编写的选项;这些选项已保留。"
}
}
@@ -0,0 +1,20 @@
{
"de": {
"render.cpu_pass_progress": "Vollständige Durchgänge: {current} / {target} · vorläufig"
},
"en": {
"render.cpu_pass_progress": "Complete passes: {current} / {target} · provisional"
},
"es": {
"render.cpu_pass_progress": "Pasadas completas: {current} / {target} · provisional"
},
"ja": {
"render.cpu_pass_progress": "完了したパス: {current} / {target} · 暫定"
},
"ko": {
"render.cpu_pass_progress": "완료된 패스: {current} / {target} · 임시"
},
"zh": {
"render.cpu_pass_progress": "完整遍数:{current} / {target} · 临时结果"
}
}
@@ -0,0 +1,29 @@
{
"ar": {
"render.product_exposure_invalid": "يجب أن يكون تعريض معاينة النموذج قيمة محدودة بين ‎−32 و32 EV."
},
"de": {
"render.product_exposure_invalid": "Die Belichtung der Modellvorschau muss endlich sein und zwischen −32 und 32 EV liegen."
},
"en": {
"render.product_exposure_invalid": "Model Preview exposure must be finite and between −32 and 32 EV."
},
"es": {
"render.product_exposure_invalid": "La exposición de la vista previa del modelo debe ser finita y estar entre −32 y 32 EV."
},
"he": {
"render.product_exposure_invalid": "חשיפת תצוגת המודל המקדימה חייבת להיות סופית ובטווח שבין ‎−32 ל־32 EV."
},
"ja": {
"render.product_exposure_invalid": "モデルプレビューの露出は −32~32 EV の有限値にしてください。"
},
"ko": {
"render.product_exposure_invalid": "모델 미리보기 노출은 −32~32 EV 범위의 유한한 값이어야 합니다."
},
"vi": {
"render.product_exposure_invalid": "Phơi sáng xem trước Mô hình phải là số hữu hạn trong khoảng −32 đến 32 EV."
},
"zh": {
"render.product_exposure_invalid": "模型预览曝光必须是 −32 到 32 EV 之间的有限值。"
}
}
@@ -0,0 +1,47 @@
{
"ar": {
"render.preview_environment_fixed": "تتطلب معاينة النموذج دوران بيئة بقيمة 0 وشدة بقيمة 1.",
"render.raster_exposure_help": "يضبط تعريض التصيير النقطي من ‎−32 إلى 32 EV بعد التظليل. يحافظ الصفر على الاستجابة الحالية، دون تغيير الأضواء أو المواد.",
"render.raster_exposure_invalid": "يجب أن يكون تعريض التصيير النقطي قيمة محدودة بين ‎−32 و32 EV."
},
"de": {
"render.preview_environment_fixed": "Die Modellvorschau benötigt eine Umgebungsdrehung von 0 und eine Intensität von 1.",
"render.raster_exposure_help": "Passt die Rasterbelichtung nach der Schattierung von −32 bis 32 EV an. Null erhält die bisherige Darstellung; Licht und Materialien bleiben unverändert.",
"render.raster_exposure_invalid": "Die Rasterbelichtung muss endlich sein und zwischen −32 und 32 EV liegen."
},
"en": {
"render.preview_environment_fixed": "Model Preview requires environment rotation 0 and intensity 1.",
"render.raster_exposure_help": "Adjusts Raster exposure from −32 to 32 EV after shading. Zero preserves the current response; lights and materials stay unchanged.",
"render.raster_exposure_invalid": "Raster exposure must be finite and between −32 and 32 EV."
},
"es": {
"render.preview_environment_fixed": "La vista previa del modelo requiere una rotación del entorno de 0 y una intensidad de 1.",
"render.raster_exposure_help": "Ajusta la exposición Raster de −32 a 32 EV después del sombreado. Cero conserva la respuesta actual; no cambia luces ni materiales.",
"render.raster_exposure_invalid": "La exposición Raster debe ser finita y estar entre −32 y 32 EV."
},
"he": {
"render.preview_environment_fixed": "תצוגת המודל המקדימה מחייבת סיבוב סביבה של 0 ועוצמה של 1.",
"render.raster_exposure_help": "התאמת חשיפת רסטר בין ‎−32 ל־32 EV לאחר ההצללה. אפס שומר על התוצאה הנוכחית; התאורה והחומרים אינם משתנים.",
"render.raster_exposure_invalid": "חשיפת הרסטר חייבת להיות סופית ובטווח שבין ‎−32 ל־32 EV."
},
"ja": {
"render.preview_environment_fixed": "モデルプレビューでは、環境の回転を 0、強度を 1 にしてください。",
"render.raster_exposure_help": "シェーディング後のラスター露出を −32~32 EV で調整します。0 は現在の表示を維持し、ライトやマテリアルは変更しません。",
"render.raster_exposure_invalid": "ラスター露出は −32~32 EV の有限値にしてください。"
},
"ko": {
"render.preview_environment_fixed": "모델 미리보기에서는 환경 회전이 0이고 강도가 1이어야 합니다.",
"render.raster_exposure_help": "셰이딩 후 래스터 노출을 −32~32 EV로 조정합니다. 0은 현재 표현을 유지하며 조명과 재질은 변경하지 않습니다.",
"render.raster_exposure_invalid": "래스터 노출은 −32~32 EV 범위의 유한한 값이어야 합니다."
},
"vi": {
"render.preview_environment_fixed": "Xem trước Mô hình yêu cầu góc xoay môi trường là 0 và cường độ là 1.",
"render.raster_exposure_help": "Điều chỉnh phơi sáng Raster từ −32 đến 32 EV sau khi tô bóng. Giá trị 0 giữ cách hiển thị hiện tại; ánh sáng và vật liệu không thay đổi.",
"render.raster_exposure_invalid": "Phơi sáng Raster phải là số hữu hạn trong khoảng −32 đến 32 EV."
},
"zh": {
"render.preview_environment_fixed": "模型预览要求环境旋转为 0,强度为 1。",
"render.raster_exposure_help": "在着色后将光栅曝光调整为 −32 到 32 EV。0 保留当前效果,不更改灯光和材质。",
"render.raster_exposure_invalid": "光栅曝光必须是 −32 到 32 EV 之间的有限值。"
}
}
@@ -0,0 +1,65 @@
{
"en": {
"render.glass_library_conflict": "This body already has optical settings. Edit or remove them before preparing a different recipe.",
"render.glass_library_edit": "Prepare physical glass",
"render.glass_library_help": "Prepares the recipe in the existing editor without assigning an appearance or rendering. CPU needs explicit optical permission and a Thin sheet or Box that passes actual certification. Closed-mesh and shadow settings stay unchanged.",
"render.glass_library_method": "Choose CPU or GPU rendering to prepare physical glass. Raster has no physical-glass route.",
"render.glass_library_prepared": "Physical glass recipe prepared for editing; review its boundary and optical settings before rendering."
},
"es": {
"render.glass_library_conflict": "Este cuerpo ya tiene ajustes ópticos. Edítalos o elimínalos antes de preparar otra receta.",
"render.glass_library_edit": "Preparar vidrio físico",
"render.glass_library_help": "Prepara la receta en el editor existente sin asignar un aspecto ni renderizar. CPU necesita permiso óptico explícito y una lámina o caja que supere la certificación real. No cambia los ajustes de malla cerrada ni de sombras.",
"render.glass_library_method": "Elige renderizado CPU o GPU para preparar vidrio físico. Raster no tiene una ruta de vidrio físico.",
"render.glass_library_prepared": "Receta de vidrio físico preparada para editar; revisa su frontera y sus ajustes ópticos antes de renderizar."
},
"de": {
"render.glass_library_conflict": "Dieser Körper hat bereits optische Einstellungen. Bearbeite oder entferne sie, bevor du ein anderes Rezept vorbereitest.",
"render.glass_library_edit": "Physikalisches Glas vorbereiten",
"render.glass_library_help": "Bereitet das Rezept im vorhandenen Editor vor, ohne ein Aussehen zuzuweisen oder zu rendern. CPU benötigt eine ausdrückliche Freigabe der Optik und eine dünne Scheibe oder einen Quader mit tatsächlicher Zertifizierung. Einstellungen für geschlossene Netze und Schatten bleiben unverändert.",
"render.glass_library_method": "Wähle CPU- oder GPU-Rendering, um physikalisches Glas vorzubereiten. Raster unterstützt kein physikalisches Glas.",
"render.glass_library_prepared": "Glasrezept zur Bearbeitung vorbereitet; prüfe vor dem Rendern seine Begrenzung und optischen Einstellungen."
},
"ko": {
"render.glass_library_conflict": "이 바디에는 이미 광학 설정이 있습니다. 다른 레시피를 준비하기 전에 기존 설정을 편집하거나 제거하세요.",
"render.glass_library_edit": "물리 기반 유리 준비",
"render.glass_library_help": "모양을 지정하거나 렌더링하지 않고 기존 편집기에서 레시피를 준비합니다. CPU에는 명시적인 광학 허용과 실제 검증을 통과하는 얇은 판 또는 상자가 필요합니다. 닫힌 메시와 그림자 설정은 변경하지 않습니다.",
"render.glass_library_method": "물리 기반 유리를 준비하려면 CPU 또는 GPU 렌더링을 선택하세요. Raster는 물리 기반 유리를 지원하지 않습니다.",
"render.glass_library_prepared": "편집할 유리 레시피를 준비했습니다. 렌더링 전에 경계와 광학 설정을 검토하세요."
},
"ar": {
"render.glass_library_conflict": "لهذا الجسم إعدادات بصرية بالفعل. عدّلها أو أزلها قبل تجهيز وصفة مختلفة.",
"render.glass_library_edit": "تجهيز زجاج فيزيائي",
"render.glass_library_help": "يجهّز الوصفة في المحرر الحالي دون تعيين مظهر أو تصيير. يتطلب CPU إذنًا بصريًا صريحًا وصفيحة رقيقة أو صندوقًا يجتاز التحقق الفعلي. تبقى إعدادات الشبكة المغلقة والظلال دون تغيير.",
"render.glass_library_method": "اختر التصيير عبر CPU أو GPU لتجهيز زجاج فيزيائي. لا يدعم Raster الزجاج الفيزيائي.",
"render.glass_library_prepared": "جُهّزت وصفة الزجاج للتحرير؛ راجع حدودها وإعداداتها البصرية قبل التصيير."
},
"he": {
"render.glass_library_conflict": "לגוף הזה כבר יש הגדרות אופטיות. יש לערוך או להסיר אותן לפני הכנת מתכון אחר.",
"render.glass_library_edit": "הכנת זכוכית פיזיקלית",
"render.glass_library_help": "מכין את המתכון בעורך הקיים בלי להקצות מראה או לרנדר. CPU דורש אישור אופטי מפורש ולוח דק או תיבה שעוברים אימות בפועל. הגדרות רשת סגורה וצללים אינן משתנות.",
"render.glass_library_method": "יש לבחור רינדור CPU או GPU כדי להכין זכוכית פיזיקלית. Raster אינו תומך בזכוכית פיזיקלית.",
"render.glass_library_prepared": "מתכון הזכוכית הוכן לעריכה; יש לבדוק את הגבול ואת ההגדרות האופטיות לפני הרינדור."
},
"ja": {
"render.glass_library_conflict": "このボディには既に光学設定があります。別のレシピを準備する前に、既存の設定を編集または削除してください。",
"render.glass_library_edit": "物理ガラスを準備",
"render.glass_library_help": "外観の割り当てやレンダリングを行わず、既存のエディターでレシピを準備します。CPU では光学処理の明示的な許可と、実際の認証に合格する薄板またはボックスが必要です。閉じたメッシュと影の設定は変更しません。",
"render.glass_library_method": "物理ガラスを準備するには CPU または GPU レンダリングを選択してください。Raster は物理ガラスに対応していません。",
"render.glass_library_prepared": "編集用のガラスレシピを準備しました。レンダリング前に境界と光学設定を確認してください。"
},
"vi": {
"render.glass_library_conflict": "Thân này đã có thiết lập quang học. Hãy sửa hoặc xóa chúng trước khi chuẩn bị công thức khác.",
"render.glass_library_edit": "Chuẩn bị kính vật lý",
"render.glass_library_help": "Chuẩn bị công thức trong trình chỉnh sửa hiện có mà không gán diện mạo hay kết xuất. CPU cần cho phép quang học rõ ràng và tấm mỏng hoặc hộp vượt qua kiểm chứng thực tế. Không đổi thiết lập lưới kín và bóng đổ.",
"render.glass_library_method": "Chọn kết xuất CPU hoặc GPU để chuẩn bị kính vật lý. Raster không hỗ trợ kính vật lý.",
"render.glass_library_prepared": "Đã chuẩn bị công thức kính để chỉnh sửa; hãy kiểm tra biên và thiết lập quang học trước khi kết xuất."
},
"zh": {
"render.glass_library_conflict": "此实体已有光学设置。准备其他配方前,请编辑或移除现有设置。",
"render.glass_library_edit": "准备物理玻璃",
"render.glass_library_help": "在现有编辑器中准备配方,不分配外观,也不进行渲染。CPU 需要明确启用光学处理,并使用通过实际认证的薄片或盒体。封闭网格和阴影设置保持不变。",
"render.glass_library_method": "请选择 CPU 或 GPU 渲染来准备物理玻璃。Raster 不支持物理玻璃。",
"render.glass_library_prepared": "玻璃配方已准备好供编辑;渲染前请检查边界和光学设置。"
}
}
@@ -0,0 +1,227 @@
{
"en": {
"cmd.render.interactive": "Interactive Render",
"help.render.interactive": "Explicitly enable a provisional CPU preview while editing. Final render settings and exportable images stay unchanged.",
"render.interactive_error": "Interactive preview error",
"render.interactive_feedback_milestones_complete": "No more preview updates are scheduled for this render.",
"render.interactive_feedback_policy": "Preview updates are limited per render; final image settings stay unchanged.",
"render.interactive_feedback_quota_exhausted": "Preview update limit reached for this render.",
"render.interactive_feedback_usage": "{started}/{limit} preview updates started for this render.",
"render.interactive_final_active": "Final render active",
"render.interactive_off": "Off",
"render.interactive_on": "On",
"render.interactive_partial_dimensions": "Display {display_width} × {display_height} px · Sampling {source_width} × {source_height} px · Output {final_width} × {final_height} px",
"render.interactive_partial_exhausted": "Not enough remaining work budget for another partial image.",
"render.interactive_partial_samples": "Samples/pixel {min}–{max} · {known}/{pixels} source pixels sampled",
"render.interactive_pause": "Pause",
"render.interactive_paused": "Paused",
"render.interactive_provisional": "Provisional preview",
"render.interactive_refining": "Refining preview",
"render.interactive_requires_cpu": "Interactive preview requires CPU ray tracing.",
"render.interactive_resume": "Resume",
"render.interactive_samples": "{done}/{total} SPP",
"render.interactive_stale": "Out of date",
"render.interactive_target": "Sample target reached",
"render.interactive_updating": "Updating"
},
"ar": {
"cmd.render.interactive": "تصيير تفاعلي",
"help.render.interactive": "فعّل معاينة مؤقتة باستخدام المعالج CPU أثناء التحرير. تبقى إعدادات التصيير النهائي والصور القابلة للتصدير دون تغيير.",
"render.interactive_error": "خطأ في المعاينة التفاعلية",
"render.interactive_feedback_milestones_complete": "لم تُجدول تحديثات معاينة أخرى لهذا التصيير.",
"render.interactive_feedback_policy": "تحديثات المعاينة محدودة لكل تصيير؛ تبقى إعدادات الصورة النهائية دون تغيير.",
"render.interactive_feedback_quota_exhausted": "تم بلوغ حد تحديثات المعاينة لهذا التصيير.",
"render.interactive_feedback_usage": "بدأ {started}/{limit} تحديثًا للمعاينة لهذا التصيير.",
"render.interactive_final_active": "التصيير النهائي قيد التنفيذ",
"render.interactive_off": "إيقاف",
"render.interactive_on": "تفعيل",
"render.interactive_partial_dimensions": "العرض {display_width} × {display_height} بكسل · أخذ العينات {source_width} × {source_height} بكسل · الإخراج {final_width} × {final_height} بكسل",
"render.interactive_partial_exhausted": "لا تكفي ميزانية المعالجة المتبقية لصورة جزئية أخرى.",
"render.interactive_partial_samples": "عينات لكل بكسل {min}–{max} · أُخذت عينات من {known}/{pixels} بكسل من المصدر",
"render.interactive_pause": "إيقاف مؤقت",
"render.interactive_paused": "متوقف مؤقتًا",
"render.interactive_provisional": "معاينة مؤقتة",
"render.interactive_refining": "جارٍ تحسين المعاينة",
"render.interactive_requires_cpu": "تتطلب المعاينة التفاعلية تتبّع الأشعة على المعالج CPU.",
"render.interactive_resume": "استئناف",
"render.interactive_samples": "{done}/{total} SPP",
"render.interactive_stale": "غير محدثة",
"render.interactive_target": "تم بلوغ عدد العينات المطلوب",
"render.interactive_updating": "جارٍ التحديث"
},
"de": {
"cmd.render.interactive": "Interaktives Rendering",
"help.render.interactive": "Aktivieren Sie die vorläufige CPU-Vorschau zum Bearbeiten manuell. Einstellungen für das finale Rendering und exportierbare Bilder bleiben unverändert.",
"render.interactive_error": "Fehler in der interaktiven Vorschau",
"render.interactive_feedback_milestones_complete": "Für dieses Rendering sind keine weiteren Vorschauaktualisierungen geplant.",
"render.interactive_feedback_policy": "Vorschauaktualisierungen sind pro Rendering begrenzt; die Einstellungen des finalen Bildes bleiben unverändert.",
"render.interactive_feedback_quota_exhausted": "Limit für Vorschauaktualisierungen dieses Renderings erreicht.",
"render.interactive_feedback_usage": "{started}/{limit} Vorschauaktualisierungen für dieses Rendering gestartet.",
"render.interactive_final_active": "Finales Rendering aktiv",
"render.interactive_off": "Aus",
"render.interactive_on": "Ein",
"render.interactive_partial_dimensions": "Anzeige {display_width} × {display_height} px · Abtastung {source_width} × {source_height} px · Ausgabe {final_width} × {final_height} px",
"render.interactive_partial_exhausted": "Das verbleibende Rechenbudget reicht nicht für ein weiteres Teilbild.",
"render.interactive_partial_samples": "Samples/Pixel {min}–{max} · {known}/{pixels} Quellpixel abgetastet",
"render.interactive_pause": "Pausieren",
"render.interactive_paused": "Pausiert",
"render.interactive_provisional": "Vorläufige Vorschau",
"render.interactive_refining": "Vorschau wird verfeinert",
"render.interactive_requires_cpu": "Die interaktive Vorschau erfordert CPU-Raytracing.",
"render.interactive_resume": "Fortsetzen",
"render.interactive_samples": "{done}/{total} SPP",
"render.interactive_stale": "Nicht aktuell",
"render.interactive_target": "Zielanzahl an Samples erreicht",
"render.interactive_updating": "Wird aktualisiert"
},
"es": {
"cmd.render.interactive": "Render interactivo",
"help.render.interactive": "Activa explícitamente una vista previa provisional por CPU mientras editas. Los ajustes del render final y las imágenes exportables se mantienen intactos.",
"render.interactive_error": "Error en la vista previa interactiva",
"render.interactive_feedback_milestones_complete": "No hay más actualizaciones de vista previa programadas para este renderizado.",
"render.interactive_feedback_policy": "Las actualizaciones de vista previa se limitan por renderizado; los ajustes de la imagen final no cambian.",
"render.interactive_feedback_quota_exhausted": "Se ha alcanzado el límite de actualizaciones de vista previa de este renderizado.",
"render.interactive_feedback_usage": "Se han iniciado {started}/{limit} actualizaciones de vista previa para este renderizado.",
"render.interactive_final_active": "Render final activo",
"render.interactive_off": "Desactivar",
"render.interactive_on": "Activar",
"render.interactive_partial_dimensions": "Visualización {display_width} × {display_height} px · Muestreo {source_width} × {source_height} px · Salida {final_width} × {final_height} px",
"render.interactive_partial_exhausted": "El presupuesto de cálculo restante no permite otra imagen parcial.",
"render.interactive_partial_samples": "Muestras/píxel {min}–{max} · {known}/{pixels} píxeles de origen muestreados",
"render.interactive_pause": "Pausar",
"render.interactive_paused": "En pausa",
"render.interactive_provisional": "Vista previa provisional",
"render.interactive_refining": "Refinando vista previa",
"render.interactive_requires_cpu": "La vista previa interactiva requiere trazado de rayos por CPU.",
"render.interactive_resume": "Reanudar",
"render.interactive_samples": "{done}/{total} SPP",
"render.interactive_stale": "Desactualizada",
"render.interactive_target": "Número objetivo de muestras alcanzado",
"render.interactive_updating": "Actualizando"
},
"he": {
"cmd.render.interactive": "רינדור אינטראקטיבי",
"help.render.interactive": "יש להפעיל במפורש תצוגה מקדימה זמנית במעבד CPU בזמן העריכה. הגדרות הרינדור הסופי והתמונות הזמינות לייצוא נשארות ללא שינוי.",
"render.interactive_error": "שגיאה בתצוגה המקדימה האינטראקטיבית",
"render.interactive_feedback_milestones_complete": "לא מתוכננים עדכוני תצוגה מקדימה נוספים לרינדור זה.",
"render.interactive_feedback_policy": "עדכוני התצוגה המקדימה מוגבלים לכל רינדור; הגדרות התמונה הסופית אינן משתנות.",
"render.interactive_feedback_quota_exhausted": "הושגה מגבלת עדכוני התצוגה המקדימה לרינדור זה.",
"render.interactive_feedback_usage": "החלו {started}/{limit} עדכוני תצוגה מקדימה לרינדור זה.",
"render.interactive_final_active": "הרינדור הסופי פעיל",
"render.interactive_off": "כיבוי",
"render.interactive_on": "הפעלה",
"render.interactive_partial_dimensions": "תצוגה {display_width} × {display_height} פיקסלים · דגימה {source_width} × {source_height} פיקסלים · פלט {final_width} × {final_height} פיקסלים",
"render.interactive_partial_exhausted": "תקציב החישוב שנותר אינו מספיק לתמונה חלקית נוספת.",
"render.interactive_partial_samples": "דגימות לפיקסל {min}–{max} · נדגמו {known}/{pixels} פיקסלים במקור",
"render.interactive_pause": "השהיה",
"render.interactive_paused": "מושהה",
"render.interactive_provisional": "תצוגה מקדימה זמנית",
"render.interactive_refining": "משפר את התצוגה המקדימה",
"render.interactive_requires_cpu": "תצוגה מקדימה אינטראקטיבית דורשת עקיבת קרניים במעבד CPU.",
"render.interactive_resume": "המשך",
"render.interactive_samples": "{done}/{total} SPP",
"render.interactive_stale": "לא עדכני",
"render.interactive_target": "הושג מספר הדגימות המבוקש",
"render.interactive_updating": "מעדכן"
},
"ja": {
"cmd.render.interactive": "インタラクティブレンダリング",
"help.render.interactive": "編集中にCPUによる暫定プレビューを明示的に有効にします。最終レンダリングの設定とエクスポート可能な画像は変更しません。",
"render.interactive_error": "インタラクティブプレビューのエラー",
"render.interactive_feedback_milestones_complete": "このレンダリングでは、これ以上のプレビュー更新は予定されていません。",
"render.interactive_feedback_policy": "プレビュー更新はレンダリングごとに制限されます。最終画像の設定は変わりません。",
"render.interactive_feedback_quota_exhausted": "このレンダリングのプレビュー更新上限に達しました。",
"render.interactive_feedback_usage": "このレンダリングで開始したプレビュー更新:{started}/{limit}。",
"render.interactive_final_active": "最終レンダリング中",
"render.interactive_off": "無効にする",
"render.interactive_on": "有効にする",
"render.interactive_partial_dimensions": "表示 {display_width} × {display_height} px · サンプリング {source_width} × {source_height} px · 出力 {final_width} × {final_height} px",
"render.interactive_partial_exhausted": "残りの計算予算では、次の部分画像を生成できません。",
"render.interactive_partial_samples": "ピクセルあたり {min}~{max} サンプル · 元画像の {known}/{pixels} ピクセルをサンプリング済み",
"render.interactive_pause": "一時停止",
"render.interactive_paused": "一時停止中",
"render.interactive_provisional": "暫定プレビュー",
"render.interactive_refining": "プレビューを改善中",
"render.interactive_requires_cpu": "インタラクティブプレビューにはCPUレイトレーシングが必要です。",
"render.interactive_resume": "再開",
"render.interactive_samples": "{done}/{total} SPP",
"render.interactive_stale": "最新ではありません",
"render.interactive_target": "目標サンプル数に到達",
"render.interactive_updating": "更新中"
},
"ko": {
"cmd.render.interactive": "대화형 렌더링",
"help.render.interactive": "편집하는 동안 임시 CPU 미리 보기를 직접 켭니다. 최종 렌더링 설정과 내보낼 수 있는 이미지는 변경하지 않습니다.",
"render.interactive_error": "대화형 미리 보기 오류",
"render.interactive_feedback_milestones_complete": "이 렌더링에는 추가 미리 보기 업데이트가 예정되어 있지 않습니다.",
"render.interactive_feedback_policy": "미리 보기 업데이트는 렌더링별로 제한되며 최종 이미지 설정은 바뀌지 않습니다.",
"render.interactive_feedback_quota_exhausted": "이 렌더링의 미리 보기 업데이트 한도에 도달했습니다.",
"render.interactive_feedback_usage": "이 렌더링에서 시작한 미리 보기 업데이트: {started}/{limit}.",
"render.interactive_final_active": "최종 렌더링 진행 중",
"render.interactive_off": "끄기",
"render.interactive_on": "켜기",
"render.interactive_partial_dimensions": "표시 {display_width} × {display_height} px · 샘플링 {source_width} × {source_height} px · 출력 {final_width} × {final_height} px",
"render.interactive_partial_exhausted": "남은 연산 예산으로는 부분 이미지를 더 만들 수 없습니다.",
"render.interactive_partial_samples": "픽셀당 샘플 {min}–{max} · 원본 픽셀 {known}/{pixels}개 샘플링됨",
"render.interactive_pause": "일시 중지",
"render.interactive_paused": "일시 중지됨",
"render.interactive_provisional": "임시 미리 보기",
"render.interactive_refining": "미리 보기 개선 중",
"render.interactive_requires_cpu": "대화형 미리 보기에는 CPU 레이 트레이싱이 필요합니다.",
"render.interactive_resume": "계속",
"render.interactive_samples": "{done}/{total} SPP",
"render.interactive_stale": "최신 상태가 아님",
"render.interactive_target": "목표 샘플 수 도달",
"render.interactive_updating": "업데이트 중"
},
"vi": {
"cmd.render.interactive": "Kết xuất tương tác",
"help.render.interactive": "Chủ động bật bản xem trước tạm thời bằng CPU khi chỉnh sửa. Thiết lập kết xuất cuối và các ảnh có thể xuất vẫn được giữ nguyên.",
"render.interactive_error": "Lỗi xem trước tương tác",
"render.interactive_feedback_milestones_complete": "Không còn cập nhật xem trước nào được lên lịch cho lần kết xuất này.",
"render.interactive_feedback_policy": "Số lần cập nhật xem trước được giới hạn theo từng lần kết xuất; cài đặt ảnh cuối cùng không thay đổi.",
"render.interactive_feedback_quota_exhausted": "Đã đạt giới hạn cập nhật xem trước cho lần kết xuất này.",
"render.interactive_feedback_usage": "Đã bắt đầu {started}/{limit} cập nhật xem trước cho lần kết xuất này.",
"render.interactive_final_active": "Đang kết xuất ảnh cuối",
"render.interactive_off": "Tắt",
"render.interactive_on": "Bật",
"render.interactive_partial_dimensions": "Hiển thị {display_width} × {display_height} px · Lấy mẫu {source_width} × {source_height} px · Đầu ra {final_width} × {final_height} px",
"render.interactive_partial_exhausted": "Ngân sách tính toán còn lại không đủ cho một ảnh từng phần nữa.",
"render.interactive_partial_samples": "Mẫu/điểm ảnh {min}–{max} · Đã lấy mẫu {known}/{pixels} điểm ảnh nguồn",
"render.interactive_pause": "Tạm dừng",
"render.interactive_paused": "Đã tạm dừng",
"render.interactive_provisional": "Bản xem trước tạm thời",
"render.interactive_refining": "Đang cải thiện bản xem trước",
"render.interactive_requires_cpu": "Xem trước tương tác yêu cầu dò tia bằng CPU.",
"render.interactive_resume": "Tiếp tục",
"render.interactive_samples": "{done}/{total} SPP",
"render.interactive_stale": "Chưa cập nhật",
"render.interactive_target": "Đã đạt số mẫu mục tiêu",
"render.interactive_updating": "Đang cập nhật"
},
"zh": {
"cmd.render.interactive": "交互式渲染",
"help.render.interactive": "编辑时手动启用临时 CPU 预览。最终渲染设置和可导出的图像保持不变。",
"render.interactive_error": "交互式预览错误",
"render.interactive_feedback_milestones_complete": "本次渲染不再计划新的预览更新。",
"render.interactive_feedback_policy": "每次渲染的预览更新次数有限;最终图像设置保持不变。",
"render.interactive_feedback_quota_exhausted": "本次渲染的预览更新已达到上限。",
"render.interactive_feedback_usage": "本次渲染已启动 {started}/{limit} 次预览更新。",
"render.interactive_final_active": "最终渲染进行中",
"render.interactive_off": "关闭",
"render.interactive_on": "启用",
"render.interactive_partial_dimensions": "显示 {display_width} × {display_height} px · 采样 {source_width} × {source_height} px · 输出 {final_width} × {final_height} px",
"render.interactive_partial_exhausted": "剩余计算预算不足以生成另一张部分图像。",
"render.interactive_partial_samples": "每像素样本数 {min}–{max} · 已采样 {known}/{pixels} 个源像素",
"render.interactive_pause": "暂停",
"render.interactive_paused": "已暂停",
"render.interactive_provisional": "临时预览",
"render.interactive_refining": "正在细化预览",
"render.interactive_requires_cpu": "交互式预览需要 CPU 光线追踪。",
"render.interactive_resume": "继续",
"render.interactive_samples": "{done}/{total} SPP",
"render.interactive_stale": "不是最新状态",
"render.interactive_target": "已达到目标采样数",
"render.interactive_updating": "正在更新"
}
}
@@ -0,0 +1,38 @@
{
"ar": {
"prefs.render_open_result_on_start": "فتح النتيجة عند بدء التصيير",
"prefs.render_open_result_on_start_help": "افتح النتيجة مرة واحدة بعد بدء التصيير بنجاح. عطّل هذا الخيار للاحتفاظ بالعرض المحدد. يمكنك العودة إلى النموذج أثناء التصيير."
},
"de": {
"prefs.render_open_result_on_start": "Ergebnis beim Renderstart öffnen",
"prefs.render_open_result_on_start_help": "Nach erfolgreichem Renderstart einmal das Ergebnis öffnen. Deaktivieren, um die gewählte Ansicht beizubehalten. Während des Renderns können Sie zum Modell zurückkehren."
},
"en": {
"prefs.render_open_result_on_start": "Open Result when rendering starts",
"prefs.render_open_result_on_start_help": "After starting a render successfully, open Result once. Turn off to keep your selected view. You can return to Model while rendering."
},
"es": {
"prefs.render_open_result_on_start": "Abrir Resultado al iniciar el renderizado",
"prefs.render_open_result_on_start_help": "Abre Resultado una vez tras iniciar correctamente el renderizado. Desactiva esta opción para conservar la vista seleccionada. Puedes volver a Modelo durante el renderizado."
},
"he": {
"prefs.render_open_result_on_start": "פתיחת התוצאה עם תחילת הרינדור",
"prefs.render_open_result_on_start_help": "פתיחת התוצאה פעם אחת לאחר התחלת רינדור בהצלחה. יש לבטל אפשרות זו כדי לשמור על התצוגה שנבחרה. אפשר לחזור למודל בזמן הרינדור."
},
"ja": {
"prefs.render_open_result_on_start": "レンダリング開始時に結果を開く",
"prefs.render_open_result_on_start_help": "レンダリングの開始に成功したら、結果を一度だけ開きます。無効にすると、選択中の表示を維持します。レンダリング中でもモデルに戻れます。"
},
"ko": {
"prefs.render_open_result_on_start": "렌더링 시작 시 결과 열기",
"prefs.render_open_result_on_start_help": "렌더링이 성공적으로 시작되면 결과를 한 번 엽니다. 끄면 선택한 보기가 유지됩니다. 렌더링 중에도 모델로 돌아갈 수 있습니다."
},
"vi": {
"prefs.render_open_result_on_start": "Mở Kết quả khi bắt đầu kết xuất",
"prefs.render_open_result_on_start_help": "Mở Kết quả một lần sau khi bắt đầu kết xuất thành công. Tắt để giữ chế độ xem đã chọn. Bạn có thể quay lại Mô hình trong khi kết xuất."
},
"zh": {
"prefs.render_open_result_on_start": "开始渲染时打开结果",
"prefs.render_open_result_on_start_help": "成功开始渲染后打开结果一次。关闭此选项可保留当前选中的视图。渲染期间也可以返回模型视图。"
}
}
+4287 -4287
View File
File diff suppressed because it is too large Load Diff
+4022 -4022
View File
File diff suppressed because it is too large Load Diff
+4310 -4310
View File
File diff suppressed because it is too large Load Diff
+4287 -4287
View File
File diff suppressed because it is too large Load Diff
+4306 -4306
View File
File diff suppressed because it is too large Load Diff
+4287 -4287
View File
File diff suppressed because it is too large Load Diff
+4624 -4624
View File
File diff suppressed because it is too large Load Diff
+4287 -4287
View File
File diff suppressed because it is too large Load Diff
+4624 -4624
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,6 @@
//! CPU render setups save their explicit dielectric transport choice. Missing or
//! older values retain LegacyBounded; its default is omitted. StrictTir preserves
//! deterministic internal reflection and refuses unresolved positive optical tails;
//! it does not make captured body bindings portable or add unsupported geometry.
//! No existing value needs migration or clamping. The last release refuses the
//! newer design format and asks for an update instead of losing the saved choice.
@@ -0,0 +1,6 @@
//! Saved CPU render setups accept finite exposure from -32 through32 EV, matching the
//! existing RenderSetup/UI range. Exposure is still saved only on RenderSetup, applied
//! once after linear accumulation, with default0 unchanged. Existing valid designs keep
//! their numerical intent; no migration/clamping or second exposure field is introduced.
//! The last release refuses this newer design format and requests an update instead of
//! accepting a CPU setup whose wider exposure it cannot execute.
@@ -0,0 +1,4 @@
//! Saves up to eight bounded linear-RGB directional lights in a Raster render job.
//! Older jobs without lights retain the exact implicit studio; an explicit empty
//! rig remains dark. No older data rewrite is needed. The last released reader
//! asks for an update instead of silently discarding this newer saved intent.
@@ -0,0 +1,4 @@
//! Raster product images now apply the existing saved exposure_ev control.
//! Older zero/omitted exposure retains the exact old path. Nonzero exposure
//! changes explicit rendering intent that was previously refused or ignored;
//! the last released reader asks for an update for this newer meaning.
+53 -4
View File
@@ -6,8 +6,8 @@ use serde::{Deserialize, Serialize};
use solidcraft_render::{
Camera,
job::{
CpuBudget, CpuProgressiveOptions, DirectionalLightOptions, JobOptions, LightingPreset, PunctualLightOptions, RaytraceEnvironmentOptions,
RaytraceOptions, RectangleLightOptions, StudioLighting, ThinLens, ToneMap,
CpuBudget, CpuDielectricTransport, CpuProgressiveOptions, DirectionalLightOptions, JobOptions, LightingPreset, PunctualLightOptions,
RaytraceEnvironmentOptions, RaytraceOptions, RectangleLightOptions, StudioLighting, ThinLens, ToneMap,
},
lighting::environment::SkyGradient,
};
@@ -25,6 +25,9 @@ pub enum RenderJob {
Raster {
#[serde(default)]
transparent_background: bool,
/// Omission retains the fixed studio; an explicit empty rig is dark.
#[serde(default, deserialize_with = "solidcraft_render::job::deserialize_raster_lights", skip_serializing_if = "Option::is_none")]
lights: Option<Vec<DirectionalLightOptions>>,
},
CpuRaytrace {
#[serde(default)]
@@ -42,8 +45,12 @@ pub enum RenderJob {
impl RenderJob {
pub fn validate(&self, camera: &Camera, exposure_ev: f64) -> Result<()> {
match self {
Self::Raster { .. } if exposure_ev != 0.0 => Err(DocError::Invalid("saved raster rendering requires exposure_ev: 0".into())),
Self::Raster { .. } => Ok(()),
Self::Raster { lights, .. } => {
if !exposure_ev.is_finite() || !(-32.0..=32.0).contains(&exposure_ev) {
return Err(DocError::Invalid("saved Raster exposure must be finite and within -32..32 EV".into()));
}
solidcraft_render::job::validate_raster_lights(lights).map_err(|e| DocError::Invalid(e.to_string()))
}
Self::CpuRaytrace { options } => options.to_runtime(exposure_ev).map(|_| ()),
Self::GpuPath { options, transparent_background } => {
if *transparent_background && options.backdrop.is_some() {
@@ -67,6 +74,8 @@ impl RenderJob {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct CpuJob {
#[serde(skip_serializing_if = "CpuDielectricTransport::is_legacy")]
pub dielectric_transport: CpuDielectricTransport,
#[serde(skip_serializing_if = "CpuBudget::is_compact")]
pub budget: CpuBudget,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -94,6 +103,7 @@ pub struct CpuJob {
impl Default for CpuJob {
fn default() -> Self {
Self {
dielectric_transport: CpuDielectricTransport::LegacyBounded,
budget: CpuBudget::Compact,
progressive: None,
primary_samples: 1,
@@ -129,6 +139,7 @@ impl CpuJob {
// a light family that is explicitly absent. This does not rewrite the file.
let active_area = self.area_lights.as_ref().is_some_and(|lights| !lights.is_empty());
let options = RaytraceOptions {
dielectric_transport: self.dielectric_transport,
budget: self.budget,
progressive: self.progressive,
primary_samples: self.primary_samples,
@@ -346,3 +357,41 @@ fn punctual_lights<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Resul
fn area_lights<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Option<Vec<RectangleLightOptions>>, D::Error> {
optional_lights::<D, RectangleLightOptions, 4>(d)
}
#[cfg(test)]
#[test]
fn cpu_dielectric_transport_saved_field_has_legacy_default_and_explicit_runtime_forward() {
let legacy: CpuJob = serde_json::from_str("{}").unwrap();
assert_eq!(legacy.dielectric_transport, CpuDielectricTransport::LegacyBounded);
assert!(serde_json::to_value(&legacy).unwrap().get("dielectric_transport").is_none());
for policy in [CpuDielectricTransport::LegacyBounded, CpuDielectricTransport::StrictTir] {
let saved = CpuJob { dielectric_transport: policy, ..Default::default() };
let reopened: CpuJob = serde_json::from_value(serde_json::to_value(&saved).unwrap()).unwrap();
assert_eq!(reopened, saved);
let runtime = reopened.to_runtime(0.0).unwrap();
assert_eq!(runtime.dielectric_transport, policy);
assert_eq!(runtime.exposure_ev, 0.0);
assert_eq!(runtime.budget, CpuBudget::Compact);
}
for value in [serde_json::json!(null), serde_json::json!(true), serde_json::json!(17), serde_json::json!("unlimited")] {
assert!(serde_json::from_value::<CpuJob>(serde_json::json!({"dielectric_transport":value})).is_err());
}
}
#[cfg(test)]
#[test]
fn cpu_dielectric_transport_document_roundtrip_keeps_choice_without_captured_bindings() {
for policy in [CpuDielectricTransport::LegacyBounded, CpuDielectricTransport::StrictTir] {
let setup = crate::render_settings::RenderSetup {
job: Some(RenderJob::CpuRaytrace { options: CpuJob { dielectric_transport: policy, ..Default::default() } }),
..Default::default()
};
let mut document = crate::Document::default();
std::sync::Arc::make_mut(&mut document.render).setups.insert("Policy".into(), setup.clone());
let reopened = crate::Document::from_json(&document.to_json()).unwrap();
assert_eq!(document, reopened);
assert_eq!(reopened.render.setups["Policy"], setup);
let Some(RenderJob::CpuRaytrace { options }) = &reopened.render.setups["Policy"].job else { panic!("saved CPU method changed") };
assert_eq!(options.to_runtime(0.0).unwrap().dielectric_transport, policy);
}
}
+124 -2
View File
@@ -238,6 +238,81 @@ mod tests {
use crate::Document;
use std::sync::Arc;
#[test]
fn raster_saved_exposure_uses_the_existing_field_and_roundtrips_its_real_bounds() {
for ev in [-32.0, -1.0, 0.0, 1.0, 2.0, 32.0] {
let setup =
RenderSetup { exposure_ev: ev, job: Some(RenderJob::Raster { transparent_background: false, lights: None }), ..Default::default() };
setup.validate().unwrap();
let mut document = Document::default();
Arc::make_mut(&mut document.render).setups.insert("EV".into(), setup.clone());
let loaded = Document::from_json(&document.to_json()).unwrap();
assert_eq!(loaded.render.setups["EV"], setup);
}
}
#[test]
fn raster_directional_jobs_roundtrip_original_intent_and_omit_legacy_none() {
use serde_json::json;
use solidcraft_render::job::DirectionalLightOptions;
let old = json!({"method":"raster","transparent_background":false});
let job: RenderJob = serde_json::from_value(old.clone()).unwrap();
assert_eq!(serde_json::to_value(&job).unwrap(), old);
let null: RenderJob = serde_json::from_value(json!({"method":"raster","lights":null})).unwrap();
assert_eq!(null, job);
let source = DirectionalLightOptions { direction: [3.0, -4.0, 0.0], color: [0.25, 0.5, 1.0], intensity: 2.0 };
for count in [0, 1, 8] {
let setup = RenderSetup {
job: Some(RenderJob::Raster { transparent_background: true, lights: Some(vec![source.clone(); count]) }),
width: 32,
height: 24,
background: [17, 29, 53],
..Default::default()
};
setup.validate().unwrap();
let mut document = Document::default();
let settings = Arc::make_mut(&mut document.render);
settings.setups.insert("Raster".into(), setup.clone());
settings
.setups
.insert("CPU".into(), RenderSetup { job: Some(RenderJob::CpuRaytrace { options: CpuJob::default() }), ..Default::default() });
settings.setups.insert(
"GPU".into(),
RenderSetup { job: Some(RenderJob::GpuPath { transparent_background: false, options: Box::default() }), ..Default::default() },
);
let loaded = Document::from_json(&document.to_json()).unwrap();
assert_eq!(loaded, document);
assert_eq!(loaded.render.setups["Raster"], setup);
let Some(RenderJob::Raster { lights: Some(lights), .. }) = &loaded.render.setups["Raster"].job else {
panic!("saved method or explicit rig lost")
};
assert_eq!(lights, &vec![source.clone(); count]);
}
}
#[test]
fn saved_raster_rigs_refuse_invalid_values_unknown_controls_and_excess_capacity() {
use serde_json::json;
let light = json!({"direction":[0,0,1],"color":[1,1,1],"intensity":1});
for job in [
json!({"method":"raster","lights":vec![light;9]}),
json!({"method":"raster","lights":[{"direction":[0,0,0],"color":[1,1,1],"intensity":0}]}),
json!({"method":"raster","lights":[{"direction":[0,0,1],"color":[2,1,1],"intensity":1}]}),
json!({"method":"raster","lights":[],"options":{"shadows":true}}),
] {
assert!(serde_json::from_value::<RenderJob>(job).is_err());
}
let setup =
RenderSetup { job: Some(RenderJob::Raster { transparent_background: false, lights: Some(Vec::with_capacity(9)) }), ..Default::default() };
assert!(setup.validate().is_err());
let exposure = RenderSetup {
exposure_ev: 33.0,
job: Some(RenderJob::Raster { transparent_background: false, lights: Some(Vec::new()) }),
..Default::default()
};
assert!(exposure.validate().is_err());
}
#[test]
fn saved_light_coordinates_are_admitted_only_when_the_document_can_reopen() {
use serde_json::json;
@@ -642,8 +717,9 @@ mod tests {
for setup in [
json!({"job":{}}),
json!({"job":{"method":"cpu_raytrace","options":{"primary_samples":2}}}),
json!({"exposure_ev":17,"job":{"method":"cpu_raytrace"}}),
json!({"exposure_ev":1,"job":{"method":"raster"}}),
// CPU17 is now valid within the shared saved range;33 remains invalid.
json!({"exposure_ev":33,"job":{"method":"cpu_raytrace"}}),
json!({"exposure_ev":33,"job":{"method":"raster"}}),
json!({"environment_rotation":0.5,"job":{"method":"gpu_path"}}),
json!({"environment_intensity":0,"job":{"method":"cpu_raytrace"}}),
json!({"job":{"method":"cpu_raytrace","options":{"seed":-1}}}),
@@ -739,7 +815,9 @@ mod tests {
#[cfg(test)]
mod cpu_budget_tests {
use super::*;
use crate::Document;
use solidcraft_render::job::{CpuBudget, CpuProgressiveOptions};
use std::sync::Arc;
#[test]
fn inactive_area_tuning_roundtrips_without_leaking_into_runtime_options() {
@@ -783,4 +861,48 @@ mod cpu_budget_tests {
let legacy_large = RenderSetup { width: 3840, height: 2160, ..Default::default() };
assert!(legacy_large.validate().is_err(), "large intent must name an explicit CPU job profile");
}
#[test]
fn cpu_exposure_expanded_range_roundtrips_offline_with_one_saved_authority() {
use serde_json::json;
assert_eq!(RenderSetup::default().exposure_ev, 0.0);
for ev in [-32.0, -17.0, 17.0, 32.0] {
for progressive in [false, true] {
let mut options = json!({"lights":[],"shadows":false});
if progressive {
options["progressive"] = json!({"samples_per_pixel":3,"seed":31});
}
let saved = json!({"setups":{"EV":{"width":2,"height":1,"exposure_ev":ev,
"job":{"method":"cpu_raytrace","options":options}}}});
let settings: RenderSettings = serde_json::from_value(saved).unwrap();
let document = Document { render: Arc::new(settings), ..Default::default() };
let text = document.to_json();
let wire: serde_json::Value = serde_json::from_str(&text).unwrap();
let pending = crate::format::pending_names();
if !pending.is_empty() {
assert_eq!(wire["format_pending"], json!(pending));
}
let reopened = Document::from_json(&text).unwrap();
assert_eq!(document, reopened);
let setup = &reopened.render.setups["EV"];
assert_eq!(setup.exposure_ev, ev);
let RenderJob::CpuRaytrace { options } = setup.job.as_ref().unwrap() else { panic!("CPU method changed") };
let runtime = options.to_runtime(setup.exposure_ev).unwrap();
assert_eq!(runtime.exposure_ev, ev);
assert_eq!(runtime.budget, solidcraft_render::job::CpuBudget::Compact);
assert_eq!(runtime.progressive.is_some(), progressive);
assert!(serde_json::to_value(options).unwrap().get("exposure_ev").is_none());
}
}
}
#[test]
fn cpu_exposure_expansion_keeps_nonfinite_and_outside_saved_values_invalid() {
for ev in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -33.0, 33.0, -32.01, 32.01] {
let options = CpuJob::default();
assert!(options.to_runtime(ev).is_err());
let setup = RenderSetup { exposure_ev: ev, job: Some(RenderJob::CpuRaytrace { options }), ..Default::default() };
assert!(setup.validate().is_err());
}
}
}
+402 -398
View File
@@ -1,400 +1,404 @@
//! The command registry. Ids are SolidCraft's own dotted ids (`solid.extrude`,
//! `sketch.rectangle.two_point`); the toolbar placement (`tab`, `panel`) follows the familiar
//! Design workspace layout.
//! The command registry. Ids are SolidCraft's own dotted ids (`solid.extrude`,
//! `sketch.rectangle.two_point`); the toolbar placement (`tab`, `panel`) follows the familiar
//! Design workspace layout.
/// The params-doc note of every command that adds sketch geometry (see `sketch::created`).
macro_rules! created_doc {
() => {
". Returns created: {points, curves, constraints, dimensions}: the ids of what it added (stable and never reused: usable right away in sketch.dimension, sketch.constraint.*, sketch.delete…)"
};
}
/// A command that is designed and registered (id, toolbar place, icon, params doc) but not built
/// yet. It is disabled with the reason, so the toolbar greys it and says why, and its `run`
/// refuses too, however it is reached. `$issue` is the GitHub issue that tracks building it.
macro_rules! planned {
($id:literal, $label:literal, $issue:literal) => {
CommandSpec::new($id, $label, |_, _| Err(crate::EngineError::Other(concat!("not supported yet: ", $label, " (issue #", $issue, ")").into())))
.enabled(|_| Err(concat!("not supported yet (issue #", $issue, ")").into()))
.planned($issue)
};
}
mod bom;
mod browser;
pub(crate) mod clearance;
mod clipboard;
mod compare;
mod component;
mod component_clone;
mod component_clone_assembly;
mod construct;
mod delete;
mod diagnostics;
mod edit;
mod face;
mod features;
mod features_more;
mod join_pieces;
pub(crate) use face::{FaceRefForm, face_ref_form};
pub(crate) use features::profiles as feature_profiles;
pub(crate) use features::{EdgeRefForm, axis_dir_form, edge_ref_form, sketch_key};
pub use features::{auto_operation, extrude_reach, shown_scope};
pub use join_pieces::{Addition, addition};
pub use sketch::dimension_roles;
pub use sketch_import::drawing_import_command;
pub use sketch_modify::{extend_arc_preview, extend_preview, trim_piece};
pub use view_dxf::sketch::exportable as view_dxf_exportable;
pub use view_navigation::standard_view;
mod component_link;
pub use component_link::resolve_design_links;
mod config;
mod design_link;
mod file;
mod frame;
pub(crate) mod inspect;
mod interference;
pub(crate) mod joints;
mod libraries;
mod material_properties;
mod materials;
mod measure_sel;
mod measure_shape;
mod mesh;
mod metadata;
pub(crate) mod motion;
mod parts;
mod point_cloud;
mod script;
mod section;
pub(crate) use measure_sel::{measure_items, shape_of};
pub(crate) use measure_shape::Shape;
#[cfg(test)]
mod kernel_heal_tests;
mod parameter_sweep;
mod parameters;
mod plastic;
mod render;
mod render_ui;
#[cfg(test)]
mod script_tests;
mod sheet;
pub(crate) use sheet::{
FlangeType, ThenStep, flange_edge, flange_edge_list, flange_position, flange_type, fold_points, fold_position, perforation_pattern, sheet_points,
then_list,
};
mod silhouette;
mod sketch;
pub(crate) use sketch::author_path_for_rebind;
pub(crate) use sketch::created as sketch_created;
mod canvas_sketch;
mod sketch_3d;
mod sketch_canvas;
pub mod sketch_clipboard;
mod sketch_constraints;
mod sketch_create;
mod sketch_ends;
mod sketch_freeform;
mod sketch_import;
mod sketch_inspect;
mod sketch_modify;
mod sketch_project;
mod sketch_store;
mod surface;
mod surface_analysis;
mod surface_boundary;
mod surface_match;
mod view_dxf;
mod view_navigation;
use serde::Serialize;
use serde_json::Value;
use crate::{Result, Session};
pub type Run = fn(&mut Session, &Value) -> Result<Value>;
/// A command's form for one frame of an interactive gesture: its result and the sketch it
/// changed, for [`Session::execute`] to show without evaluating the rest of the model (`None`:
/// evaluate as usual).
pub type LiveRun = fn(&mut Session, &Value) -> Result<(Value, Option<crate::LiveEdit>)>;
pub type Enabled = fn(&Session) -> std::result::Result<(), String>;
pub type SchemaFn = fn() -> schemars::Schema;
pub struct CommandSpec {
pub id: &'static str,
pub label: &'static str,
/// Toolbar tab (`SOLID`, `SKETCH`…); empty = not on the toolbar.
pub tab: &'static str,
/// Toolbar panel (`CREATE`, `MODIFY`…).
pub panel: &'static str,
/// Icon name for the UI (drawn in code).
pub icon: &'static str,
pub shortcut: Option<&'static str>,
/// One-line parameter documentation.
pub params: &'static str,
pub enabled: Enabled,
pub run: Run,
pub undoable: bool,
/// What runs instead of `run` when the command is given `live: true` (a frame of an
/// interactive gesture, see [`Session::execute`]).
pub live: Option<LiveRun>,
/// Reads the evaluated model (the default). A command that reads only the document and the
/// session's history runs on a model left stale by a live gesture without evaluating it first.
pub reads_model: bool,
/// Registered but not built yet (see `planned!`): the GitHub issue number that tracks it.
pub planned: Option<&'static str>,
/// The command's typed parameter contract, which carries its input schema; `None` until
/// the command is converted (see `crate::schema`).
pub contract: Option<&'static dyn crate::schema::Contract>,
/// The JSON Schema of a successful result; `None` until described.
pub output: Option<SchemaFn>,
}
impl CommandSpec {
pub const fn new(id: &'static str, label: &'static str, run: Run) -> Self {
CommandSpec {
id,
label,
tab: "",
panel: "",
icon: "",
shortcut: None,
params: "",
enabled: always,
run,
undoable: true,
live: None,
reads_model: true,
planned: None,
contract: None,
output: None,
}
}
/// Not built yet; `issue` tracks building it.
pub const fn planned(mut self, issue: &'static str) -> Self {
self.planned = Some(issue);
self
}
/// Takes `live: true`, run by `run`.
pub const fn live(mut self, run: LiveRun) -> Self {
self.live = Some(run);
self
}
/// Reads only the document (and history), never the evaluated model.
pub const fn doc_only(mut self) -> Self {
self.reads_model = false;
self
}
pub const fn at(mut self, tab: &'static str, panel: &'static str) -> Self {
self.tab = tab;
self.panel = panel;
self
}
pub const fn icon(mut self, i: &'static str) -> Self {
self.icon = i;
self
}
pub const fn key(mut self, k: &'static str) -> Self {
self.shortcut = Some(k);
self
}
/// The command's typed parameter contract (see `crate::schema`).
pub const fn contract(mut self, c: &'static dyn crate::schema::Contract) -> Self {
self.contract = Some(c);
self
}
/// The JSON Schema of a successful result.
pub const fn output(mut self, f: SchemaFn) -> Self {
self.output = Some(f);
self
}
pub const fn params(mut self, p: &'static str) -> Self {
self.params = p;
self
}
pub const fn enabled(mut self, e: Enabled) -> Self {
self.enabled = e;
self
}
pub const fn noundo(mut self) -> Self {
self.undoable = false;
self
}
/// Run inside another command: when it is enabled, its `run` (the caller's command owns the
/// undo step and the evaluation).
pub(crate) fn run_nested(&self, s: &mut Session, p: &Value) -> Result<Value> {
(self.enabled)(s).map_err(|why| crate::EngineError::Disabled(self.id.to_string(), why))?;
let r = (self.run)(s, p);
// A parameter error that did not name the command names it here (see `Session::execute`).
match r {
Err(crate::EngineError::BadParams { cmd, msg }) if cmd.is_empty() => Err(crate::EngineError::BadParams { cmd: self.id.to_string(), msg }),
other => other,
}
}
pub fn info(&self, s: &Session) -> CommandInfo {
let e = (self.enabled)(s);
let (label, icon) = match self.id {
"inspect.colored_components" if s.colored_components.is_some() => ("Disable Component Colors", "colored_components_off"),
_ => (self.label, self.icon),
};
CommandInfo {
id: self.id,
label,
tab: self.tab,
panel: self.panel,
icon,
shortcut: self.shortcut,
params: self.params,
enabled: e.is_ok(),
disabled_reason: e.err(),
planned: self.planned,
}
}
}
#[derive(Clone, Debug, Serialize)]
pub struct CommandInfo {
pub id: &'static str,
pub label: &'static str,
pub tab: &'static str,
pub panel: &'static str,
pub icon: &'static str,
pub shortcut: Option<&'static str>,
pub params: &'static str,
pub enabled: bool,
pub disabled_reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub planned: Option<&'static str>,
}
pub fn always(_: &Session) -> std::result::Result<(), String> {
Ok(())
}
pub fn in_sketch(s: &Session) -> std::result::Result<(), String> {
if s.active_sketch.is_some() { Ok(()) } else { Err("no sketch is being edited (Create Sketch or Edit Sketch first)".into()) }
}
/// The mirror of [`in_sketch`], for a command that acts on bodies and has nothing to offer the
/// geometry under the cursor while a sketch is open.
pub fn not_in_sketch(s: &Session) -> std::result::Result<(), String> {
if s.active_sketch.is_none() { Ok(()) } else { Err("a sketch is being edited (this works on bodies: Finish Sketch first)".into()) }
}
/// Every command, in toolbar order.
pub fn command_specs() -> Vec<&'static CommandSpec> {
let mut v: Vec<&'static CommandSpec> = Vec::new();
v.extend(sketch::COMMANDS.iter());
v.extend(sketch_create::COMMANDS.iter());
v.extend(sketch_constraints::COMMANDS.iter());
v.extend(sketch_import::COMMANDS.iter());
v.extend(sketch_inspect::COMMANDS.iter());
v.extend(sketch_ends::COMMANDS.iter());
v.extend(sketch_canvas::COMMANDS.iter());
v.extend(canvas_sketch::COMMANDS.iter());
v.extend(point_cloud::COMMANDS.iter());
v.extend(sketch_3d::COMMANDS.iter());
v.extend(surface_analysis::COMMANDS.iter());
v.extend(surface_boundary::COMMANDS.iter());
v.extend(surface::COMMANDS.iter());
v.extend(surface_match::COMMANDS.iter());
v.extend(sketch_modify::COMMANDS.iter());
v.extend(sketch_project::COMMANDS.iter());
v.extend(features::COMMANDS.iter());
v.extend(features_more::COMMANDS.iter());
v.extend(mesh::COMMANDS.iter());
v.extend(construct::COMMANDS.iter());
v.extend(sheet::COMMANDS.iter());
v.extend(plastic::COMMANDS.iter());
v.extend(frame::COMMANDS.iter());
v.extend(component::COMMANDS.iter());
v.extend(component_link::COMMANDS.iter());
v.extend(joints::COMMANDS.iter());
v.extend(clearance::COMMANDS.iter());
v.extend(motion::COMMANDS.iter());
v.extend(config::COMMANDS.iter());
v.extend(parts::COMMANDS.iter());
v.extend(bom::COMMANDS.iter());
v.extend(browser::COMMANDS.iter());
v.extend(delete::COMMANDS.iter());
v.extend(edit::COMMANDS.iter());
v.extend(clipboard::COMMANDS.iter());
v.extend(sketch_clipboard::COMMANDS.iter());
v.extend(script::COMMANDS.iter());
v.extend(parameters::COMMANDS.iter());
v.extend(parameter_sweep::COMMANDS.iter());
v.extend(file::COMMANDS.iter());
v.extend(metadata::COMMANDS.iter());
v.extend(inspect::COMMANDS.iter());
v.extend(compare::COMMANDS.iter());
v.extend(silhouette::COMMANDS.iter());
v.extend(materials::COMMANDS.iter());
v.extend(material_properties::COMMANDS.iter());
v.extend(libraries::COMMANDS.iter());
v.extend(section::COMMANDS.iter());
v.extend(view_navigation::COMMANDS.iter());
v.extend(view_dxf::COMMANDS.iter());
v.extend(diagnostics::COMMANDS.iter());
v.extend(render::COMMANDS.iter());
v.extend(render_ui::COMMANDS.iter());
v
}
pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
let all = command_specs();
all.iter().find(|c| c.id == id).or_else(|| all.iter().find(|c| c.id.eq_ignore_ascii_case(id))).copied()
}
/// The end of an unknown command's error: up to three ids like `id`, nearest first. Ids a few
/// letters away (`solid.extrud` → `solid.extrude`) come before ids that end in the same word
/// (`file.open` → `doc.open`). Empty when nothing is close.
pub(crate) fn did_you_mean(id: &str) -> String {
let id = id.to_ascii_lowercase();
if id.len() > 64 {
return String::new();
}
let last = |s: &str| s.rsplit('.').next().unwrap_or(s).to_string();
let near = (id.chars().count() / 4).max(2);
let mut found: Vec<(bool, usize, &str)> = command_specs()
.iter()
.filter_map(|c| {
let d = edit_distance(&id, c.id);
(d <= near || last(&id) == last(c.id)).then_some((d > near, d, c.id))
})
.collect();
found.sort();
let ids: Vec<String> = found.iter().take(3).map(|(_, _, c)| format!("`{c}`")).collect();
match ids.as_slice() {
[] => String::new(),
[one] => format!(" (did you mean {one}?)"),
[rest @ .., end] => format!(" (did you mean {} or {end}?)", rest.join(", ")),
}
}
/// Levenshtein distance: the single-character edits that turn `a` into `b`.
pub(crate) fn edit_distance(a: &str, b: &str) -> usize {
let b: Vec<char> = b.chars().collect();
let mut row: Vec<usize> = (0..=b.len()).collect();
for (i, ca) in a.chars().enumerate() {
let mut prev = row[0];
row[0] = i + 1;
for (j, cb) in b.iter().enumerate() {
let next = (prev + usize::from(ca != *cb)).min(row[j] + 1).min(row[j + 1] + 1);
prev = row[j + 1];
row[j + 1] = next;
}
}
row[b.len()]
}
pub(super) fn alignment_param(p: &Value, key: &str, cmd: &str) -> Result<solidcraft_sketch::PatternAlignment> {
p.get(key).map_or(Ok(solidcraft_sketch::PatternAlignment::default()), |value| {
serde_json::from_value(value.clone()).map_err(|_| crate::params::bad(cmd, format!("`{key}` must be left, center or right")))
})
}
pub(super) fn reversed_param(p: &Value, key: &str, cmd: &str) -> Result<bool> {
p.get(key).map_or(Ok(false), |value| {
serde_json::from_value(value.clone()).map_err(|_| crate::params::bad(cmd, format!("`{key}` must be true or false")))
})
}
/// The params-doc note of every command that adds sketch geometry (see `sketch::created`).
macro_rules! created_doc {
() => {
". Returns created: {points, curves, constraints, dimensions}: the ids of what it added (stable and never reused: usable right away in sketch.dimension, sketch.constraint.*, sketch.delete…)"
};
}
/// A command that is designed and registered (id, toolbar place, icon, params doc) but not built
/// yet. It is disabled with the reason, so the toolbar greys it and says why, and its `run`
/// refuses too, however it is reached. `$issue` is the GitHub issue that tracks building it.
macro_rules! planned {
($id:literal, $label:literal, $issue:literal) => {
CommandSpec::new($id, $label, |_, _| Err(crate::EngineError::Other(concat!("not supported yet: ", $label, " (issue #", $issue, ")").into())))
.enabled(|_| Err(concat!("not supported yet (issue #", $issue, ")").into()))
.planned($issue)
};
}
mod bom;
mod browser;
pub(crate) mod clearance;
mod clipboard;
mod compare;
mod component;
mod component_clone;
mod component_clone_assembly;
mod construct;
mod delete;
mod diagnostics;
mod edit;
mod face;
mod features;
mod features_more;
mod join_pieces;
pub(crate) use face::{FaceRefForm, face_ref_form};
pub(crate) use features::profiles as feature_profiles;
pub(crate) use features::{EdgeRefForm, axis_dir_form, edge_ref_form, sketch_key};
pub use features::{auto_operation, extrude_reach, shown_scope};
pub use join_pieces::{Addition, addition};
pub use sketch::dimension_roles;
pub use sketch_import::drawing_import_command;
pub use sketch_modify::{extend_arc_preview, extend_preview, trim_piece};
pub use view_dxf::sketch::exportable as view_dxf_exportable;
pub use view_navigation::standard_view;
mod component_link;
pub use component_link::resolve_design_links;
mod config;
mod design_link;
mod file;
mod frame;
pub(crate) mod inspect;
mod interference;
pub(crate) mod joints;
mod libraries;
mod material_properties;
mod materials;
mod measure_sel;
mod measure_shape;
mod mesh;
mod metadata;
pub(crate) mod motion;
mod parts;
mod point_cloud;
mod script;
mod section;
pub(crate) use measure_sel::{measure_items, shape_of};
pub(crate) use measure_shape::Shape;
#[cfg(test)]
mod kernel_heal_tests;
mod parameter_sweep;
mod parameters;
mod plastic;
mod render;
mod render_ui;
#[cfg(test)]
mod script_tests;
mod sheet;
pub(crate) use sheet::{
FlangeType, ThenStep, flange_edge, flange_edge_list, flange_position, flange_type, fold_points, fold_position, perforation_pattern, sheet_points,
then_list,
};
mod silhouette;
mod sketch;
pub(crate) use sketch::author_path_for_rebind;
pub(crate) use sketch::created as sketch_created;
mod canvas_sketch;
mod sketch_3d;
mod sketch_canvas;
pub mod sketch_clipboard;
mod sketch_constraints;
mod sketch_create;
mod sketch_ends;
mod sketch_freeform;
mod sketch_import;
mod sketch_inspect;
mod sketch_modify;
mod sketch_project;
mod sketch_store;
mod surface;
mod surface_analysis;
mod surface_boundary;
mod surface_match;
mod view_dxf;
mod view_navigation;
use serde::Serialize;
use serde_json::Value;
use crate::{Result, Session};
pub type Run = fn(&mut Session, &Value) -> Result<Value>;
/// A command's form for one frame of an interactive gesture: its result and the sketch it
/// changed, for [`Session::execute`] to show without evaluating the rest of the model (`None`:
/// evaluate as usual).
pub type LiveRun = fn(&mut Session, &Value) -> Result<(Value, Option<crate::LiveEdit>)>;
pub type Enabled = fn(&Session) -> std::result::Result<(), String>;
pub type SchemaFn = fn() -> schemars::Schema;
pub struct CommandSpec {
pub id: &'static str,
pub label: &'static str,
/// Toolbar tab (`SOLID`, `SKETCH`…); empty = not on the toolbar.
pub tab: &'static str,
/// Toolbar panel (`CREATE`, `MODIFY`…).
pub panel: &'static str,
/// Icon name for the UI (drawn in code).
pub icon: &'static str,
pub shortcut: Option<&'static str>,
/// One-line parameter documentation.
pub params: &'static str,
pub enabled: Enabled,
pub run: Run,
pub undoable: bool,
/// What runs instead of `run` when the command is given `live: true` (a frame of an
/// interactive gesture, see [`Session::execute`]).
pub live: Option<LiveRun>,
/// Reads the evaluated model (the default). A command that reads only the document and the
/// session's history runs on a model left stale by a live gesture without evaluating it first.
pub reads_model: bool,
/// Registered but not built yet (see `planned!`): the GitHub issue number that tracks it.
pub planned: Option<&'static str>,
/// The command's typed parameter contract, which carries its input schema; `None` until
/// the command is converted (see `crate::schema`).
pub contract: Option<&'static dyn crate::schema::Contract>,
/// The JSON Schema of a successful result; `None` until described.
pub output: Option<SchemaFn>,
}
impl CommandSpec {
pub const fn new(id: &'static str, label: &'static str, run: Run) -> Self {
CommandSpec {
id,
label,
tab: "",
panel: "",
icon: "",
shortcut: None,
params: "",
enabled: always,
run,
undoable: true,
live: None,
reads_model: true,
planned: None,
contract: None,
output: None,
}
}
/// Not built yet; `issue` tracks building it.
pub const fn planned(mut self, issue: &'static str) -> Self {
self.planned = Some(issue);
self
}
/// Takes `live: true`, run by `run`.
pub const fn live(mut self, run: LiveRun) -> Self {
self.live = Some(run);
self
}
/// Reads only the document (and history), never the evaluated model.
pub const fn doc_only(mut self) -> Self {
self.reads_model = false;
self
}
pub const fn at(mut self, tab: &'static str, panel: &'static str) -> Self {
self.tab = tab;
self.panel = panel;
self
}
pub const fn icon(mut self, i: &'static str) -> Self {
self.icon = i;
self
}
pub const fn key(mut self, k: &'static str) -> Self {
self.shortcut = Some(k);
self
}
/// The command's typed parameter contract (see `crate::schema`).
pub const fn contract(mut self, c: &'static dyn crate::schema::Contract) -> Self {
self.contract = Some(c);
self
}
/// The JSON Schema of a successful result.
pub const fn output(mut self, f: SchemaFn) -> Self {
self.output = Some(f);
self
}
pub const fn params(mut self, p: &'static str) -> Self {
self.params = p;
self
}
pub const fn enabled(mut self, e: Enabled) -> Self {
self.enabled = e;
self
}
pub const fn noundo(mut self) -> Self {
self.undoable = false;
self
}
/// Run inside another command: when it is enabled, its `run` (the caller's command owns the
/// undo step and the evaluation).
pub(crate) fn run_nested(&self, s: &mut Session, p: &Value) -> Result<Value> {
(self.enabled)(s).map_err(|why| crate::EngineError::Disabled(self.id.to_string(), why))?;
let r = (self.run)(s, p);
// A parameter error that did not name the command names it here (see `Session::execute`).
match r {
Err(crate::EngineError::BadParams { cmd, msg }) if cmd.is_empty() => Err(crate::EngineError::BadParams { cmd: self.id.to_string(), msg }),
other => other,
}
}
pub fn info(&self, s: &Session) -> CommandInfo {
let e = (self.enabled)(s);
let (label, icon) = match self.id {
"inspect.colored_components" if s.colored_components.is_some() => ("Disable Component Colors", "colored_components_off"),
_ => (self.label, self.icon),
};
CommandInfo {
id: self.id,
label,
tab: self.tab,
panel: self.panel,
icon,
shortcut: self.shortcut,
params: self.params,
enabled: e.is_ok(),
disabled_reason: e.err(),
planned: self.planned,
}
}
}
#[derive(Clone, Debug, Serialize)]
pub struct CommandInfo {
pub id: &'static str,
pub label: &'static str,
pub tab: &'static str,
pub panel: &'static str,
pub icon: &'static str,
pub shortcut: Option<&'static str>,
pub params: &'static str,
pub enabled: bool,
pub disabled_reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub planned: Option<&'static str>,
}
pub fn always(_: &Session) -> std::result::Result<(), String> {
Ok(())
}
pub fn in_sketch(s: &Session) -> std::result::Result<(), String> {
if s.active_sketch.is_some() { Ok(()) } else { Err("no sketch is being edited (Create Sketch or Edit Sketch first)".into()) }
}
/// The mirror of [`in_sketch`], for a command that acts on bodies and has nothing to offer the
/// geometry under the cursor while a sketch is open.
pub fn not_in_sketch(s: &Session) -> std::result::Result<(), String> {
if s.active_sketch.is_none() { Ok(()) } else { Err("a sketch is being edited (this works on bodies: Finish Sketch first)".into()) }
}
/// Every command, in toolbar order.
pub fn command_specs() -> Vec<&'static CommandSpec> {
let mut v: Vec<&'static CommandSpec> = Vec::new();
v.extend(sketch::COMMANDS.iter());
v.extend(sketch_create::COMMANDS.iter());
v.extend(sketch_constraints::COMMANDS.iter());
v.extend(sketch_import::COMMANDS.iter());
v.extend(sketch_inspect::COMMANDS.iter());
v.extend(sketch_ends::COMMANDS.iter());
v.extend(sketch_canvas::COMMANDS.iter());
v.extend(canvas_sketch::COMMANDS.iter());
v.extend(point_cloud::COMMANDS.iter());
v.extend(sketch_3d::COMMANDS.iter());
v.extend(surface_analysis::COMMANDS.iter());
v.extend(surface_boundary::COMMANDS.iter());
v.extend(surface::COMMANDS.iter());
v.extend(surface_match::COMMANDS.iter());
v.extend(sketch_modify::COMMANDS.iter());
v.extend(sketch_project::COMMANDS.iter());
v.extend(features::COMMANDS.iter());
v.extend(features_more::COMMANDS.iter());
v.extend(mesh::COMMANDS.iter());
v.extend(construct::COMMANDS.iter());
v.extend(sheet::COMMANDS.iter());
v.extend(plastic::COMMANDS.iter());
v.extend(frame::COMMANDS.iter());
v.extend(component::COMMANDS.iter());
v.extend(component_link::COMMANDS.iter());
v.extend(joints::COMMANDS.iter());
v.extend(clearance::COMMANDS.iter());
v.extend(motion::COMMANDS.iter());
v.extend(config::COMMANDS.iter());
v.extend(parts::COMMANDS.iter());
v.extend(bom::COMMANDS.iter());
v.extend(browser::COMMANDS.iter());
v.extend(delete::COMMANDS.iter());
v.extend(edit::COMMANDS.iter());
v.extend(clipboard::COMMANDS.iter());
v.extend(sketch_clipboard::COMMANDS.iter());
v.extend(script::COMMANDS.iter());
v.extend(parameters::COMMANDS.iter());
v.extend(parameter_sweep::COMMANDS.iter());
v.extend(file::COMMANDS.iter());
v.extend(metadata::COMMANDS.iter());
v.extend(inspect::COMMANDS.iter());
v.extend(compare::COMMANDS.iter());
v.extend(silhouette::COMMANDS.iter());
v.extend(materials::COMMANDS.iter());
v.extend(material_properties::COMMANDS.iter());
v.extend(libraries::COMMANDS.iter());
v.extend(section::COMMANDS.iter());
v.extend(view_navigation::COMMANDS.iter());
v.extend(view_dxf::COMMANDS.iter());
v.extend(diagnostics::COMMANDS.iter());
v.extend(render::COMMANDS.iter());
v.extend(render_ui::COMMANDS.iter());
v
}
pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
let all = command_specs();
all.iter().find(|c| c.id == id).or_else(|| all.iter().find(|c| c.id.eq_ignore_ascii_case(id))).copied()
}
/// The end of an unknown command's error: up to three ids like `id`, nearest first. Ids a few
/// letters away (`solid.extrud` → `solid.extrude`) come before ids that end in the same word
/// (`file.open` → `doc.open`). Empty when nothing is close.
pub(crate) fn did_you_mean(id: &str) -> String {
let id = id.to_ascii_lowercase();
if id.len() > 64 {
return String::new();
}
let last = |s: &str| s.rsplit('.').next().unwrap_or(s).to_string();
let near = (id.chars().count() / 4).max(2);
let mut found: Vec<(bool, usize, &str)> = command_specs()
.iter()
.filter_map(|c| {
let d = edit_distance(&id, c.id);
(d <= near || last(&id) == last(c.id)).then_some((d > near, d, c.id))
})
.collect();
found.sort();
let ids: Vec<String> = found.iter().take(3).map(|(_, _, c)| format!("`{c}`")).collect();
match ids.as_slice() {
[] => String::new(),
[one] => format!(" (did you mean {one}?)"),
[rest @ .., end] => format!(" (did you mean {} or {end}?)", rest.join(", ")),
}
}
/// Levenshtein distance: the single-character edits that turn `a` into `b`.
pub(crate) fn edit_distance(a: &str, b: &str) -> usize {
let b: Vec<char> = b.chars().collect();
let mut row: Vec<usize> = (0..=b.len()).collect();
for (i, ca) in a.chars().enumerate() {
let mut prev = row[0];
row[0] = i + 1;
for (j, cb) in b.iter().enumerate() {
let next = (prev + usize::from(ca != *cb)).min(row[j] + 1).min(row[j + 1] + 1);
prev = row[j + 1];
row[j + 1] = next;
}
}
row[b.len()]
}
pub(super) fn alignment_param(p: &Value, key: &str, cmd: &str) -> Result<solidcraft_sketch::PatternAlignment> {
p.get(key).map_or(Ok(solidcraft_sketch::PatternAlignment::default()), |value| {
serde_json::from_value(value.clone()).map_err(|_| crate::params::bad(cmd, format!("`{key}` must be left, center or right")))
})
}
pub(super) fn reversed_param(p: &Value, key: &str, cmd: &str) -> Result<bool> {
p.get(key).map_or(Ok(false), |value| {
serde_json::from_value(value.clone()).map_err(|_| crate::params::bad(cmd, format!("`{key}` must be true or false")))
})
pub(crate) fn prepare_interactive(s: &crate::Session, params: &serde_json::Value) -> crate::Result<crate::render_interactive::Prepared> {
render::prepare_interactive(s, params)
}
File diff suppressed because one or more lines are too long
+273 -3
View File
@@ -1,6 +1,205 @@
use super::*;
use solidcraft_render::job::{CpuHost, JobId};
#[test]
fn raster_saved_ev_reopens_captures_check_start_and_changes_real_png_with_one_authority() {
let mut offline = Session::default();
offline.execute("solid.box", &json!({"length":20,"width":15,"height":10})).unwrap();
for (name, ev) in [("Minus", -1.0), ("Zero", 0.0), ("Plus", 1.0)] {
offline
.execute("render.settings.set", &json!({"name":name,"set":{"width":32,"height":32,"exposure_ev":ev,"job":{"method":"raster"}}}))
.unwrap();
}
let mut s = Session::new(solidcraft_doc::Document::from_json(&offline.doc.to_json()).unwrap());
let host = CpuHost::cooperative();
s.set_render_job_host(Some(host.clone())).unwrap();
s.execute("render.scene", &json!({})).unwrap();
let doc = s.doc.clone();
let mut images = Vec::new();
for (name, ev) in [("Minus", -1.0), ("Zero", 0.0), ("Plus", 1.0)] {
let checked = s.execute("render.preflight", &json!({"setup":name})).unwrap();
assert_eq!(checked["ignored_controls"], json!([]));
assert_eq!(checked["request"]["integrator"], "raster");
assert_eq!(checked["request"].get("raster_exposure_ev").and_then(Value::as_f64).unwrap_or(0.0), ev);
let started = s.execute("render.start", &json!({"setup":name})).unwrap();
let job: JobId = serde_json::from_value(started["job"].clone()).unwrap();
finish(&host);
let handle = s.render_job_handle(job).unwrap();
assert_eq!(json!(handle.status().unwrap().request), checked["request"]);
images.push(handle.frame().unwrap().unwrap().png().to_vec());
}
assert_ne!(images[0], images[1]);
assert_ne!(images[1], images[2]);
let explicit = s
.execute(
"render.preflight",
&json!({"settings":{"width":16,"height":16,"exposure_ev":1,
"job":{"method":"raster","lights":[{"direction":[0,0,1],"color":[1,1,1],"intensity":2}]}}}),
)
.unwrap();
assert_eq!(explicit["request"]["raster_exposure_ev"], 1.0);
assert_eq!(explicit["request"]["raster_lights"].as_array().unwrap().len(), 1);
assert!(Arc::ptr_eq(&doc, &s.doc));
for ev in [-33.0, 33.0] {
assert!(s.execute("render.settings.set", &json!({"name":"Zero","set":{"exposure_ev":ev}})).is_err());
assert!(Arc::ptr_eq(&doc, &s.doc));
}
assert!(s.execute("render.preflight", &json!({"raster_exposure_ev":1})).is_err());
}
#[test]
fn saved_raster_directional_rig_reopens_and_reaches_check_start_status_png_without_cpu_switch() {
let mut offline = Session::default();
offline.execute("solid.box", &json!({"length":20,"width":15,"height":10})).unwrap();
offline
.execute(
"render.settings.set",
&json!({"name":"SavedRig","set":{"width":32,"height":32,"job":{"method":"raster",
"lights":[{"direction":[3,-4,0],"color":[1,0,0],"intensity":2}]}}}),
)
.unwrap();
let document = solidcraft_doc::Document::from_json(&offline.doc.to_json()).unwrap();
let mut s = Session::new(document);
let host = CpuHost::cooperative();
s.set_render_job_host(Some(host.clone())).unwrap();
s.execute("render.scene", &json!({})).unwrap();
let doc = s.doc.clone();
let checked = s.execute("render.preflight", &json!({"setup":"SavedRig"})).unwrap();
assert_eq!(checked["request"]["integrator"], "raster");
assert!(checked["request"].get("raytrace").is_none());
assert_eq!(checked["request"]["raster_lights"][0]["direction"][0].as_f64().unwrap(), 3.0);
let started = s.execute("render.start", &json!({"setup":"SavedRig"})).unwrap();
let job: JobId = serde_json::from_value(started["job"].clone()).unwrap();
finish(&host);
let handle = s.render_job_handle(job).unwrap();
assert_eq!(json!(handle.status().unwrap().request), checked["request"]);
let lit_png = handle.frame().unwrap().unwrap().png().to_vec();
assert!(lit_png.starts_with(b"\x89PNG\r\n\x1a\n"));
let mut dark = serde_json::to_value(&s.doc.render.setups["SavedRig"]).unwrap();
dark["job"]["lights"] = json!([]);
let dark_started = s.execute("render.start", &json!({"settings":dark})).unwrap();
let dark_job: JobId = serde_json::from_value(dark_started["job"].clone()).unwrap();
finish(&host);
assert_ne!(s.render_job_handle(dark_job).unwrap().frame().unwrap().unwrap().png(), lit_png.as_slice());
for runtime in [Value::Null, json!([])] {
for command in ["render.preflight", "render.start"] {
let error = s.execute(command, &json!({"setup":"SavedRig","raster_lights":runtime})).unwrap_err().to_string();
assert!(error.contains("authoritative"), "{error}");
}
}
assert!(Arc::ptr_eq(&doc, &s.doc));
}
#[test]
fn raster_json_is_bounded_before_saved_normalization_and_preserves_null_empty_semantics() {
let (mut s, host) = session();
s.execute(
"render.settings.set",
&json!({"name":"Bounded","set":{"width":16,"height":16,"job":{"method":"raster","transparent_background":false}}}),
)
.unwrap();
let light = json!({"direction":[0,0,1],"color":[1,1,1],"intensity":1});
let mut unknown = light.clone();
unknown["unexpected"] = json!({"nested":vec![0;32]});
let doc = s.doc.clone();
for rig in [json!(vec![light; 9]), json!([unknown])] {
for saved in [false, true] {
let mut params = json!({"raster_lights":rig});
if saved {
params["setup"] = json!("Bounded");
}
let before = params.clone(); // deliberately small adversarial fixture, not production lowering
for command in ["render.preflight", "render.start"] {
assert!(s.execute(command, &params).is_err());
assert_eq!(params, before);
assert_eq!(host.admitted_bytes(), 0);
assert!(Arc::ptr_eq(&doc, &s.doc));
}
}
}
for saved in [false, true] {
for rig in [None, Some(Value::Null), Some(json!([]))] {
let empty = rig.as_ref().is_some_and(Value::is_array);
let mut params = json!({});
if saved {
params["setup"] = json!("Bounded");
}
if let Some(rig) = rig {
params["raster_lights"] = rig;
}
let checked = s.execute("render.preflight", &params).unwrap();
if empty {
assert_eq!(checked["request"]["raster_lights"], json!([]));
} else {
assert!(checked["request"].get("raster_lights").is_none());
}
}
}
// Source inspection, not this outcome test, proves the raw new array was
// borrowed/validated before materialization. No real OOM test is claimed.
}
#[test]
fn transient_raster_rig_survives_saved_normalization_check_start_status_and_png() {
let (mut s, host) = session();
s.execute("solid.box", &json!({"length":20,"width":15,"height":10})).unwrap();
s.execute("render.settings.set", &json!({"name":"Rig","set":{"width":32,"height":32,"job":{"method":"raster","transparent_background":false}}}))
.unwrap();
let doc = s.doc.clone();
let before = (s.revision, s.undo.len(), s.redo.len(), s.evaluations);
let legacy = s.execute("render.start", &json!({"setup":"Rig"})).unwrap();
let legacy_id: JobId = serde_json::from_value(legacy["job"].clone()).unwrap();
finish(&host);
let legacy_png = s.render_job_handle(legacy_id).unwrap().frame().unwrap().unwrap().png().to_vec();
for rig in [json!([]), json!([{"direction":[0,-1,1],"color":[1,0,0],"intensity":2}])] {
let params = json!({"setup":"Rig","raster_lights":rig});
let checked = s.execute("render.preflight", &params).unwrap();
assert_eq!(checked["request"]["raster_lights"].as_array().unwrap().len(), params["raster_lights"].as_array().unwrap().len());
assert_eq!(checked["retained_request_bytes"].as_u64().unwrap() > 0, !params["raster_lights"].as_array().unwrap().is_empty());
let started = s.execute("render.start", &params).unwrap();
let job: JobId = serde_json::from_value(started["job"].clone()).unwrap();
finish(&host);
let status = s.execute("render.status", &json!({"job":job})).unwrap();
assert_eq!(status["request"], checked["request"]);
assert_eq!(status["progress"]["phase"], "completed");
let handle = s.render_job_handle(job).unwrap();
let frame = handle.frame().unwrap().unwrap();
assert!(frame.png().starts_with(b"\x89PNG\r\n\x1a\n"));
assert_ne!(frame.png(), legacy_png.as_slice());
assert!(Arc::ptr_eq(&doc, &s.doc));
assert_eq!(before, (s.revision, s.undo.len(), s.redo.len(), s.evaluations));
assert!(!s.doc.to_json().contains("raster_lights"));
}
}
#[test]
fn raster_rig_rejects_foreign_intent_and_unsupported_setup_controls_without_reserving() {
let (mut s, host) = session();
s.execute("solid.box", &json!({"length":20,"width":15,"height":10})).unwrap();
s.execute("render.preflight", &json!({"raster_lights":[]})).unwrap();
let doc = s.doc.clone();
for command in ["render.preflight", "render.start"] {
for extra in [
json!({"integrator":"raytrace"}),
json!({"progressive":true}),
json!({"studio_weights":[1,1,1]}),
json!({"raytrace":{}}),
json!({"nested_media":true}),
json!({"progressive_feedback":true}),
json!({"settings":{"exposure_ev":33}}),
json!({"settings":{"environment_rotation":1}}),
] {
let mut params = json!({"raster_lights":[]});
for (key, value) in extra.as_object().unwrap() {
params[key] = value.clone();
}
assert!(s.execute(command, &params).is_err(), "{command}: {params}");
assert_eq!(host.admitted_bytes(), 0);
assert!(Arc::ptr_eq(&doc, &s.doc));
}
}
}
fn session() -> (Session, CpuHost) {
let host = CpuHost::cooperative();
let mut s = Session::default();
@@ -1182,17 +1381,18 @@ fn explicit_render_camera_preserves_orientation_and_refuses_invalid_state_atomic
}
/// #5089: a setup saved with exposure (the render workspace and the other executors use it)
/// renders on the raster executor, which has no exposure, and the reply says what it left out.
/// now renders with captured Raster exposure; only unsupported environment controls remain ignored.
#[test]
fn a_setup_saved_with_exposure_renders_and_names_the_controls_it_leaves_out() {
let (mut s, host) = session();
s.execute("render.settings.set", &json!({"name":"x","set":{"mode":"preview","width":16,"height":16,"exposure_ev":0.3}})).unwrap();
let reply = s.execute("render.preflight", &json!({"setup":"x"})).unwrap();
assert_eq!(reply["ignored_controls"], json!(["exposure_ev"]));
assert_eq!(reply["ignored_controls"], json!([]));
assert_eq!(reply["request"]["raster_exposure_ev"], 0.3);
let plain = s.execute("render.preflight", &request()).unwrap();
assert_eq!(plain["ignored_controls"], json!([]));
let started = s.execute("render.start", &json!({"setup":"x"})).unwrap();
assert_eq!(started["ignored_controls"], json!(["exposure_ev"]));
assert_eq!(started["ignored_controls"], json!([]));
finish(&host);
let id: JobId = serde_json::from_value(started["job"].clone()).unwrap();
assert_eq!(s.execute("render.status", &json!({"job":id})).unwrap()["progress"]["phase"], "completed");
@@ -1216,3 +1416,73 @@ fn output_viewer_copy_validates_completed_job_without_mutation_or_pixel_payloads
assert!(Arc::ptr_eq(&document, &s.doc));
assert_eq!(before, (s.revision, s.undo.len(), s.redo.len(), host.admitted_bytes()));
}
#[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
#[test]
fn cpu_exposure_full_saved_range_check_start_preserves_fixed_and_progressive_capture() {
let host = CpuHost::for_platform().unwrap();
assert_eq!(host.kind(), solidcraft_render::job::HostKind::NativeWorker);
host.validate_integrator(solidcraft_render::job::Integrator::Raytrace).unwrap();
eprintln!("CPU_EXPOSURE_RANGE_BRANCH cpu_supported=true");
for ev in [-32.0, -17.0, 17.0, 32.0] {
for progressive in [false, true] {
let mut s = Session::default();
s.execute("solid.box", &json!({"length":20,"width":20,"height":10})).unwrap();
let mut options = json!({"lights":[],"shadows":false,"primary_samples":1});
if progressive {
options["progressive"] = json!({"samples_per_pixel":3,"seed":31});
}
s.execute(
"render.settings.set",
&json!({"name":"EV","set":{"width":4,"height":4,"exposure_ev":ev,
"job":{"method":"cpu_raytrace","options":options}}}),
)
.unwrap();
let saved = s.doc.render.setups["EV"].clone();
s = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
s.set_render_job_host(Some(host.clone())).unwrap();
s.execute("render.scene", &json!({})).unwrap();
let doc = s.doc.clone();
let before = (s.revision, s.evaluations, s.undo.len(), s.redo.len(), s.is_dirty());
let named = json!({"setup":"EV"});
let checked = s.execute("render.preflight", &named).unwrap();
assert_eq!(checked, s.execute("render.preflight", &json!({"settings":saved})).unwrap());
assert_eq!(checked["resolved"]["executor"], "cpu-raytrace");
assert_eq!(checked["request"]["raytrace"]["exposure_ev"], ev);
let started = s.execute("render.start", &named).unwrap();
let id: JobId = serde_json::from_value(started["job"].clone()).unwrap();
let handle = s.render_job_handle(id).unwrap();
let status = handle.wait_timeout(std::time::Duration::from_secs(60)).unwrap();
assert_eq!(status.progress.phase, solidcraft_render::job::Phase::Completed, "{:?}", status.error);
assert_eq!(json!(status.request), checked["request"]);
let captured = status.request.raytrace.as_ref().unwrap();
assert_eq!(captured.exposure_ev, ev);
assert_eq!(captured.progressive.is_some(), progressive);
assert_eq!(captured.budget, solidcraft_render::job::CpuBudget::Compact);
assert_eq!(status.options.exposure_ev, 0.0, "no second GPU exposure authority");
assert!(handle.frame().unwrap().is_some());
assert_eq!(s.doc.render.setups["EV"], saved);
assert!(Arc::ptr_eq(&doc, &s.doc));
assert_eq!(before, (s.revision, s.evaluations, s.undo.len(), s.redo.len(), s.is_dirty()));
}
}
}
#[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
#[test]
fn cpu_exposure_outside_expanded_range_fails_check_and_start_before_missing_hdr_input() {
let mut s = Session::default();
s.set_render_job_host(Some(CpuHost::for_platform().unwrap())).unwrap();
for ev in [-33.0, 33.0, -32.01, 32.01] {
let params = json!({"integrator":"raytrace","settings":{"width":1,"height":1},
"raytrace":{"exposure_ev":ev},"environment":{"path":"never-read-exposure-boundary.hdr"}});
let doc = s.doc.clone();
let before = (s.revision, s.evaluations, s.undo.len(), s.redo.len(), s.is_dirty());
for command in ["render.preflight", "render.start"] {
let error = s.execute(command, &params).unwrap_err().to_string();
assert!(error.contains("raytrace exposure_ev must be finite and within -32..32"), "{error}");
}
assert!(Arc::ptr_eq(&doc, &s.doc));
assert_eq!(before, (s.revision, s.evaluations, s.undo.len(), s.redo.len(), s.is_dirty()));
}
}
+762 -34
View File
@@ -11,6 +11,14 @@ use solidcraft_render::{
#[derive(Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct Start {
#[serde(default, deserialize_with = "solidcraft_render::job::deserialize_raster_lights")]
raster_lights: Option<Vec<solidcraft_render::job::DirectionalLightOptions>>,
#[serde(default, deserialize_with = "solidcraft_render::job::deserialize_raster_punctual_lights")]
raster_punctual_lights: Option<Vec<solidcraft_render::job::PunctualLightOptions>>,
#[serde(default, deserialize_with = "solidcraft_render::job::deserialize_raster_area_lights")]
raster_area_lights: Option<Vec<solidcraft_render::job::RectangleLightOptions>>,
progressive_feedback: bool,
nested_media: bool,
studio_weights: Option<[f64; 3]>,
progressive: bool,
denoise: bool,
@@ -47,6 +55,7 @@ impl Default for EnvironmentRequest {
#[derive(Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct ProgressiveStart {
progressive_feedback: bool,
studio_weights: Option<[f64; 3]>,
aging: solidcraft_doc::render_aging::SavedAgingStack,
#[serde(default)]
@@ -75,6 +84,7 @@ struct ProgressiveStart {
}
struct Prepared {
progressive_feedback: bool,
replace: bool,
scene: solidcraft_render::ProductScene,
request: ImageRequest,
@@ -99,8 +109,207 @@ fn lower_led(input: &mut Start, command: &str) -> Result<()> {
Ok(())
}
/// Pure preview lowering. Parse the existing CPU wire before changing its sampling;
/// unsupported effects remain errors rather than disappearing from the request.
fn interactive_params(params: &Value) -> Result<Value> {
let command = "render.interactive";
let mut input = Start::deserialize(params).map_err(|e| bad(command, e.to_string()))?;
let object = params.as_object().ok_or_else(|| bad(command, "Interactive requires an explicit CPU request object"))?;
if object.contains_key("setup") || object.contains_key("replace") {
return Err(bad(command, "Interactive requires an explicit settings snapshot without setup or replace"));
}
if input.raster_lights.is_some() || input.raster_punctual_lights.is_some() || input.raster_area_lights.is_some() {
return Err(bad(command, "Interactive does not accept explicit Raster lights"));
}
if input.integrator != Integrator::Raytrace || input.progressive || input.transparent_background {
return Err(bad(command, "Interactive requires opaque CPU ray tracing, not GPU progressive or raster rendering"));
}
if input.studio_weights.is_some() || input.denoise || input.samples.is_some() || input.max_depth.is_some() || input.seed.is_some() {
return Err(bad(command, "Interactive cannot accept GPU studio, denoise or sampling controls"));
}
let mut settings = input.settings.take().ok_or_else(|| bad(command, "Interactive requires explicit settings"))?;
if settings.job.is_some() {
return Err(bad(command, "Interactive requires a lowered CPU runtime request with settings.job: null"));
}
if settings.mode != solidcraft_render::RenderMode::Preview || settings.format != solidcraft_render::RenderFormat::Png {
return Err(bad(command, "Interactive CPU requires the existing Preview/Image/PNG profile"));
}
// Keep the existing CPU runtime exposure authority; never silently neutralize
// saved/common controls in order to make a request acceptable.
if settings.exposure_ev != 0.0 || settings.environment_rotation != 0.0 || settings.environment_intensity != 1.0 {
return Err(bad(command, "Interactive CPU exposure belongs to raytrace.exposure_ev; environment controls belong to the captured input"));
}
lower_led(&mut input, command)?;
if let Some(environment) = &input.environment {
// Same numerical controls as EnvironmentInput::new, checked before a path
// can be read. Resource bytes and primaries remain prepare's responsibility.
if !environment.rotation_radians.is_finite()
|| !environment.intensity.is_finite()
|| !(0.0..=64.0).contains(&environment.intensity)
|| !matches!(environment.samples, 1 | 4 | 16 | 64)
{
return Err(bad(command, "invalid Interactive environment controls"));
}
if input.raytrace.as_ref().is_some_and(|options| options.environment.is_some()) {
return Err(bad(command, "choose either captured HDR or constant CPU environment"));
}
}
let final_request = ImageRequest {
raster_punctual_lights: None,
raster_area_lights: None,
raster_exposure_ev: 0.0,
raster_lights: None,
camera: settings.camera,
width: settings.width,
height: settings.height,
background: settings.background,
transparent_background: input.transparent_background,
integrator: input.integrator,
raytrace: input.raytrace,
studio_weights: input.studio_weights,
environment: None,
textures: None,
};
let preview = solidcraft_render::job::interactive::cpu_request(&final_request).map_err(job_error)?;
settings.width = preview.width;
settings.height = preview.height;
let budget = preview.raytrace.as_ref().map_or(solidcraft_render::job::CpuBudget::Compact, |options| options.budget);
settings.validate_for_cpu(budget).map_err(|e| bad(command, e.to_string()))?;
// Preserve every original field after strict deserialization. Only the LED
// recipe has been consumed into real area lights; do not lower it twice.
let mut lowered = object.clone();
lowered.remove("led_strip");
lowered.remove("led_samples");
lowered.insert("settings".into(), serde_json::to_value(settings).map_err(|e| bad(command, e.to_string()))?);
lowered.insert("raytrace".into(), serde_json::to_value(preview.raytrace).map_err(|e| bad(command, e.to_string()))?);
Ok(Value::Object(lowered))
}
/// Capture/admit only. The engine's Interactive owner submits outside the final
/// registry; this helper never starts, replaces, cancels or exports a job.
pub(super) fn interactive(s: &Session, params: &Value) -> Result<crate::render_interactive::Prepared> {
let params = interactive_params(params)?;
let prepared = prepare(s, &params, "render.interactive")?;
if prepared.executor.as_str() != "cpu-raytrace" {
return Err(bad("render.interactive", "Interactive requires the installed cpu-raytrace executor"));
}
Ok(crate::render_interactive::Prepared {
scene: prepared.scene,
request: prepared.request,
options: prepared.options,
environment: prepared.environment,
})
}
#[cfg(test)]
mod union_lowering_tests {
#[cfg(not(target_arch = "wasm32"))]
fn raster_local_command_fixture() -> (Session, Value) {
let mut s = Session::default();
s.execute(
"solid.box",
&json!({"length":20,"width":20,"height":4,"center":[0,0,0],"operation":"new","body_name":"Raster local command fixture"}),
)
.unwrap();
s.execute("render.scene", &json!({})).unwrap();
s.set_render_job_host(Some(solidcraft_render::job::CpuHost::for_platform().unwrap())).unwrap();
let mut camera = solidcraft_render::Camera { distance: 50.0, ..Default::default() };
camera.set_view(solidcraft_render::StandardView::Top);
let wire = json!({"settings":{"width":8,"height":8,"camera":camera,"background":[20,40,60],"exposure_ev":1.25,"job":null},
"raster_punctual_lights":[{"position_mm":[0.0,0.0,20.0],"color":[0.25,0.5,1.0],"intensity":2.0,"reference_distance_mm":20.0,"minimum_distance_mm":0.001}],
"raster_area_lights":[{"center_mm":[0.0,0.0,20.0],"edge_u_mm":[4.0,0.0,0.0],"edge_v_mm":[0.0,-4.0,0.0],"color":[1.0,0.5,0.25],"intensity":2.0}]});
(s, wire)
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn raster_local_public_preflight_start_status_and_real_png_keep_both_sources_and_one_ev() {
use solidcraft_render::job::{JobId, Phase};
let (mut s, wire) = raster_local_command_fixture();
let original = wire.clone();
let checked = s.execute("render.preflight", &wire).unwrap();
assert_eq!(checked["request"]["raster_punctual_lights"], wire["raster_punctual_lights"]);
assert_eq!(checked["request"]["raster_area_lights"], wire["raster_area_lights"]);
assert_eq!(checked["request"]["raster_exposure_ev"], 1.25);
assert!(checked["request"].get("raster_lights").is_none(), "no implicit directionals added");
let started = s.execute("render.start", &wire).unwrap();
let id: JobId = serde_json::from_value(started["job"].clone()).unwrap();
let handle = s.render_job_handle(id).unwrap();
let status = handle.wait_timeout(std::time::Duration::from_secs(60)).unwrap();
assert_eq!(status.progress.phase, Phase::Completed, "{:?}", status.error);
assert_eq!(status.request.raster_exposure_ev, 1.25);
assert_eq!(serde_json::to_value(&status.request.raster_punctual_lights).unwrap(), wire["raster_punctual_lights"]);
assert_eq!(serde_json::to_value(&status.request.raster_area_lights).unwrap(), wire["raster_area_lights"]);
let frame = handle.frame().unwrap().unwrap();
let bright = image::load_from_memory(frame.png()).unwrap().to_rgba8();
assert_eq!(bright.dimensions(), (8, 8));
let mut empty = wire.clone();
empty["raster_punctual_lights"] = json!([]);
empty["raster_area_lights"] = json!([]);
let started = s.execute("render.start", &empty).unwrap();
let id: JobId = serde_json::from_value(started["job"].clone()).unwrap();
let dark_handle = s.render_job_handle(id).unwrap();
assert_eq!(dark_handle.wait_timeout(std::time::Duration::from_secs(60)).unwrap().progress.phase, Phase::Completed);
let dark_frame = dark_handle.frame().unwrap().unwrap();
let dark = image::load_from_memory(dark_frame.png()).unwrap().to_rgba8();
assert_ne!(bright, dark, "actual local lights must change the real producer output");
assert_eq!(wire, original, "input intent is immutable");
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn raster_local_public_commands_refuse_saved_and_foreign_controls_without_reservation() {
let (mut s, wire) = raster_local_command_fixture();
let before_revision = s.revision;
let mut saved = wire.clone();
saved["settings"]["job"] = serde_json::to_value(RenderJob::Raster { transparent_background: false, lights: None }).unwrap();
for command in ["render.preflight", "render.start"] {
let error = s.execute(command, &saved).unwrap_err().to_string();
assert!(error.contains("saved local-light authoring"), "{error}");
}
for (key, value) in [
("integrator", json!("raytrace")),
("studio_weights", json!([1, 1, 1])),
("raytrace", json!({})),
("environment", json!({"path":"must-not-be-opened.hdr"})),
("nested_media", json!(true)),
("progressive_feedback", json!(true)),
("progressive", json!(true)),
("samples", json!(1)),
("seed", json!(0)),
("denoise", json!(true)),
] {
let mut invalid = wire.clone();
invalid[key] = value;
for command in ["render.preflight", "render.start"] {
assert!(s.execute(command, &invalid).is_err(), "{command} accepted {key}");
assert_eq!(s.render_job_host().unwrap().admitted_bytes(), 0, "no failed request reserved work");
}
}
let mut interactive = wire;
interactive["integrator"] = json!("raytrace");
assert!(interactive_params(&interactive).is_err());
assert_eq!(s.revision, before_revision);
}
#[test]
fn raster_local_public_command_caps_refuse_before_host_lookup_or_scene_capture() {
let point = json!({"position_mm":[0,0,20],"color":[1,1,1],"intensity":1,"reference_distance_mm":20,"minimum_distance_mm":0.001});
let area = json!({"center_mm":[0,0,20],"edge_u_mm":[4,0,0],"edge_v_mm":[0,-4,0],"color":[1,1,1],"intensity":1});
let mut s = Session::default();
for (key, value, reason) in
[("raster_punctual_lights", json!(vec![point; 9]), "at most eight"), ("raster_area_lights", json!(vec![area; 5]), "at most four")]
{
let mut wire = json!({});
wire[key] = value;
for command in ["render.preflight", "render.start"] {
let error = s.execute(command, &wire).unwrap_err().to_string();
assert!(error.contains(reason), "bound must fail before absent host: {error}");
}
}
}
use super::*;
#[test]
fn led_lowering_keeps_hdr_reflection_aa_and_anisotropy_inputs_without_reading_hdr() {
@@ -123,6 +332,361 @@ mod union_lowering_tests {
assert!(input.led_strip.is_none() && input.led_samples.is_none());
assert_eq!(wire["environment"]["path"], json!("unopened.hdr"));
}
fn interactive_final_wire() -> Value {
let camera = solidcraft_render::Camera { fov: 0.8, ..Default::default() };
json!({"integrator":"raytrace","settings":{
"width":3840,"height":2160,"camera":camera,"background":[20,40,60],"job":null,
"stage":[{"id":"floor","center_mm":[0.0,0.0,-5.0],"half_u_mm":[40.0,0.0,0.0],
"half_v_mm":[0.0,40.0,0.0],"reflectance_linear":[0.1,0.2,0.3]}]},
"raytrace":{"primary_samples":4,"shadows":false,"exposure_ev":1.25,"lights":[],
"reflections":{"bodies":[{"body":"receiver","mode":"mirror"}],"samples":16,"max_bounces":3},
"punctual_lights":[{"position_mm":[0.0,0.0,20.0],"color":[1.0,0.5,0.25],"intensity":2.0,
"reference_distance_mm":20.0,"minimum_distance_mm":0.01}],
"emissions":[{"body_index":0,"body":"receiver","color":[0.2,0.4,0.8],"strength":3.0}],
"surface_effects":[{"body":"receiver","repeat_mm":[2.0,3.0,4.0],"roughness_amplitude":0.1,"bump_amplitude_mm":0.01}]},
"environment":{"path":"unopened-original.hdr","expected_sha256":"a".repeat(64),
"rotation_radians":0.75,"intensity":2.0,"samples":16}})
}
#[test]
fn interactive_fits_4k_before_admission_and_preserves_final_wire_and_transport() {
let wire = interactive_final_wire();
let original = wire.clone();
let lowered = interactive_params(&wire).unwrap();
let before: Start = serde_json::from_value(wire.clone()).unwrap();
let after: Start = serde_json::from_value(lowered.clone()).unwrap();
let final_settings = before.settings.unwrap();
let preview_settings = after.settings.unwrap();
assert_eq!((final_settings.width, final_settings.height), (3840, 2160));
assert_eq!((preview_settings.width, preview_settings.height), (1024, 576));
assert!(before.raytrace.as_ref().unwrap().budget.validate_image(3840, 2160, None).is_err(), "Compact final exceeds pixel cap");
let mut expected_settings = final_settings;
expected_settings.width = 1024;
expected_settings.height = 576;
assert_eq!(preview_settings, expected_settings, "camera, stage, background and other settings remain exact");
let mut expected_options = before.raytrace.unwrap();
expected_options.primary_samples = 1;
expected_options.progressive = Some(Default::default());
expected_options.reflections.as_mut().unwrap().samples = 1;
assert_eq!(after.raytrace.unwrap(), expected_options, "no lighting, emission, shading effect or EV is discarded");
assert_eq!(lowered["environment"], wire["environment"], "unopened HDR path and expected digest stay pinned");
assert_eq!(lowered["raytrace"]["progressive"], json!({"samples_per_pixel":16,"seed":0}));
assert_eq!(wire, original);
}
#[test]
fn interactive_small_and_extreme_aspects_fit_without_enlarging_or_zero_dimensions() {
for (width, height, expected) in [(320, 200, (320, 200)), (4096, 1, (1024, 1)), (1, 4096, (1, 1024))] {
let wire = json!({"integrator":"raytrace","settings":{"width":width,"height":height}});
let output = interactive_params(&wire).unwrap();
assert_eq!((output["settings"]["width"].as_u64().unwrap(), output["settings"]["height"].as_u64().unwrap()), (expected.0, expected.1));
}
}
#[test]
fn interactive_refuses_alternate_authorities_and_unknown_fields_without_io() {
let s = Session::default();
let base = interactive_final_wire();
for (key, value) in [
("setup", json!(null)),
("setup", json!("Saved")),
("replace", json!(false)),
("replace", json!(true)),
("integrator", json!("raster")),
("progressive", json!(true)),
("transparent_background", json!(true)),
("denoise", json!(true)),
("samples", json!(1)),
("max_depth", json!(2)),
("seed", json!(0)),
("studio_weights", json!([1, 1, 1])),
("unknown", json!(true)),
("lens", json!(null)),
] {
let mut wire = base.clone();
wire[key] = value;
let error = match interactive(&s, &wire) {
Err(error) => error.to_string(),
Ok(_) => panic!("Interactive accepted conflicting input {key}"),
};
assert!(!error.contains("could not read") && !error.contains("no image host"), "refuse before host/IO: {key}: {error}");
}
for settings in [
json!(null),
json!({"job":{"method":"cpu_raytrace"}}),
json!({"job":{"method":"gpu_path"}}),
json!({"exposure_ev":1.0}),
json!({"environment_rotation":1.0}),
json!({"width":0}),
json!({"mode":"final"}),
json!({"format":"exr"}),
] {
let mut wire = base.clone();
wire["settings"] = settings;
assert!(interactive_params(&wire).is_err());
}
let mut missing = base.clone();
missing.as_object_mut().unwrap().remove("settings");
assert!(interactive_params(&missing).is_err());
for wire in [json!(null), json!([]), json!(false)] {
assert!(interactive_params(&wire).is_err());
}
assert!(s.render_job_host().is_none());
}
#[test]
fn interactive_refuses_invalid_or_unsupported_effects_before_unopened_hdr() {
let base = interactive_final_wire();
// Retain numerically valid dielectric intent for capture-time certification.
// This fixture still mixes same-body mirror, emission and nonidentity effects;
// successful lowering is not scene or job admission.
let mut authored = base.clone();
authored["raytrace"]["dielectrics"] = json!([{"body_index":0,"body_name":"receiver","ior":1.5,"absorption_per_mm":[0,0,0],
"boundary":"closed_box","thickness_mm":0}]);
let original = authored.clone();
let authored_options: RaytraceOptions = serde_json::from_value(original["raytrace"].clone()).unwrap();
let mut expected_options = authored_options.clone();
expected_options.primary_samples = 1;
expected_options.progressive = Some(solidcraft_render::job::CpuProgressiveOptions { samples_per_pixel: 16, seed: 0 });
expected_options.reflections.as_mut().unwrap().samples = 1;
let mut expected_settings: RenderSetup = serde_json::from_value(original["settings"].clone()).unwrap();
expected_settings.width = 1024;
expected_settings.height = 576;
let mut expected_wire = original.clone();
expected_wire["settings"] = serde_json::to_value(&expected_settings).unwrap();
expected_wire["raytrace"] = serde_json::to_value(&expected_options).unwrap();
let lowered = interactive_params(&authored).unwrap();
let lowered_options: RaytraceOptions = serde_json::from_value(lowered["raytrace"].clone()).unwrap();
let lowered_settings: RenderSetup = serde_json::from_value(lowered["settings"].clone()).unwrap();
assert_eq!(lowered_options.dielectrics, authored_options.dielectrics, "the exact original dielectric selector is retained");
assert_eq!(lowered_options.surface_effects, authored_options.surface_effects, "shading intent is not removed");
assert_eq!(lowered_options.emissions, authored_options.emissions, "surface emission is not removed");
assert_eq!(lowered_options.reflections, expected_options.reflections, "only the selected reflection sample count changes");
assert_eq!(lowered_options, expected_options, "only documented Interactive sampling is normalized");
assert_eq!(lowered_settings, expected_settings, "camera, stage, background and other settings remain exact");
assert_eq!(lowered["environment"], original["environment"], "unopened HDR path and expected digest remain exact");
assert_eq!(lowered, expected_wire, "no other request field changes");
assert_eq!(authored, original, "the authored final wire remains unchanged");
for (key, value) in [
("primary_samples", json!(0)),
("exposure_ev", json!(33.0)),
(
"anisotropic_faces",
json!([{"body_index":0,"body_name":"receiver","face":0,"tangent":[1,0,0],
"orientation":"zero","roughness_x":0.2,"roughness_y":0.4},null]),
),
("textures", json!([])),
] {
let mut wire = base.clone();
wire["raytrace"][key] = value;
assert!(interactive_params(&wire).is_err(), "unsupported {key} must not disappear");
}
for (key, value) in [("thickness_mm", json!(1.0)), ("ior", json!(0.5))] {
let mut wire = authored.clone();
wire["raytrace"]["dielectrics"][0][key] = value;
assert!(interactive_params(&wire).is_err(), "invalid dielectric {key} must refuse before unopened HDR");
}
for (key, value) in [("intensity", json!(-1.0)), ("samples", json!(3))] {
let mut wire = base.clone();
wire["environment"][key] = value;
assert!(interactive_params(&wire).is_err());
}
let mut ambiguous = base.clone();
ambiguous["raytrace"]["environment"] = json!({"color":[1.0,1.0,1.0],"intensity":1.0});
assert!(interactive_params(&ambiguous).is_err());
let mut orphaned_led_samples = base;
orphaned_led_samples["led_samples"] = json!(16);
assert!(interactive_params(&orphaned_led_samples).is_err());
}
#[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
#[test]
fn interactive_capture_reuses_cpu_scene_stage_and_admission_without_starting_a_job() {
let mut s = Session::default();
s.execute("solid.box", &json!({"length":10,"width":8,"height":4,"body_name":"receiver"})).unwrap();
s.execute(
"appearance.assign",
&json!({"bodies":["receiver"],"color":"#4080c0",
"pbr":{"metallic":1.0,"roughness":0.25,"dielectric_ior":1.5}}),
)
.unwrap();
s.execute("render.scene", &json!({})).unwrap();
let subject = crate::product_scene::extract(&s).unwrap();
let host = solidcraft_render::job::CpuHost::for_platform().unwrap();
s.set_render_job_host(Some(host.clone())).unwrap();
let mut wire = interactive_final_wire();
wire.as_object_mut().unwrap().remove("environment");
let document = s.doc.clone();
let before = (s.revision, s.undo.len(), s.redo.len(), s.evaluations, s.is_dirty());
let captured = interactive(&s, &wire).unwrap();
assert_eq!((captured.request.width, captured.request.height), (1024, 576));
assert_eq!(captured.request.integrator, Integrator::Raytrace);
assert_eq!(captured.request.raytrace.as_ref().unwrap().exposure_ev, 1.25);
let body = captured.scene.meshes.iter().find(|mesh| mesh.name == "receiver").unwrap();
assert_eq!(body.surface, subject.meshes[0].surface);
assert_eq!(body.faces, subject.meshes[0].faces);
assert!(std::sync::Arc::ptr_eq(&body.mesh, &subject.meshes[0].mesh));
assert_eq!(captured.scene.meshes.len(), 2, "subject and render-only floor are both captured");
assert!(captured.scene.meshes.iter().any(|mesh| mesh.name == "__render_stage__/floor"));
assert_eq!(captured.options, solidcraft_render::job::JobOptions::default());
assert!(captured.environment.is_none());
assert_eq!(host.admitted_bytes(), 0, "capture validates but does not submit/reserve a job");
assert!(std::sync::Arc::ptr_eq(&document, &s.doc));
assert_eq!(before, (s.revision, s.undo.len(), s.redo.len(), s.evaluations, s.is_dirty()));
assert_eq!(wire["settings"]["width"], 3840);
}
#[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
fn nested_command_fixture() -> (Session, Value) {
let mut s = Session::default();
for (name, length, width, height) in [("Outer", 20, 20, 4), ("Inner", 10, 10, 2)] {
s.execute(
"solid.box",
&json!({
"length":length,"width":width,"height":height,"center":[0,0,2],
"operation":"new","body_name":name,
}),
)
.unwrap();
}
s.execute("render.scene", &json!({})).unwrap();
let scene = crate::product_scene::extract(&s).unwrap();
assert_eq!(scene.meshes.len(), 2);
let dielectrics: Vec<_> = scene
.meshes
.iter()
.enumerate()
.map(|(index, body)| {
assert!(matches!(body.name.as_str(), "Outer" | "Inner"));
json!({
"body_index":index,"body_name":body.name,"boundary":"closed_box","ior":1.0,
"absorption_per_mm":if body.name == "Outer" { [0.1,0.2,0.0] } else { [0.3,0.4,0.0] },
"thickness_mm":0.0,
})
})
.collect();
let mut camera = solidcraft_render::Camera { distance: 50.0, fov: 0.0, ..Default::default() };
camera.set_view(solidcraft_render::StandardView::Top);
camera.target = solidcraft_geom::Vec3::new(0.0, 0.0, 2.0);
let host = solidcraft_render::job::CpuHost::for_platform().unwrap();
assert_eq!(host.kind(), solidcraft_render::job::HostKind::NativeWorker);
assert!(host.is_available(), "this native test requires an available real CPU worker");
s.set_render_job_host(Some(host)).unwrap();
let wire = json!({
"integrator":"raytrace","nested_media":true,
"settings":{"width":1,"height":1,"camera":camera,"background":[255,255,255],"job":null},
"raytrace":{"dielectrics":dielectrics,"lights":[],"shadows":false,
"environment":{"color":[1,1,1],"intensity":1}},
});
(s, wire)
}
#[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
#[test]
fn command_nested_opt_in_reaches_retained_job_options_and_real_beer_transport() {
use solidcraft_render::job::{JobId, Phase};
let (mut s, wire) = nested_command_fixture();
let before = (s.revision, s.undo.len(), s.redo.len(), s.evaluations, s.is_dirty());
let document = s.doc.clone();
for value in [None, Some(false)] {
let mut default = wire.clone();
if let Some(value) = value {
default["nested_media"] = json!(value);
} else {
default.as_object_mut().unwrap().remove("nested_media");
}
for command in ["render.preflight", "render.start"] {
let error = s.execute(command, &default).unwrap_err().to_string();
assert!(error.contains("nested, touching or overlapping dielectric volumes are unsupported"), "{command}: {error}");
}
}
let checked = s.execute("render.preflight", &wire).unwrap();
assert_eq!(checked["resolved"]["executor"], "cpu-raytrace");
assert_eq!(checked["request"]["raytrace"]["dielectrics"], wire["raytrace"]["dielectrics"]);
let started = s.execute("render.start", &wire).unwrap();
let id: JobId = serde_json::from_value(started["job"].clone()).unwrap();
let handle = s.render_job_handle(id).unwrap();
let status = handle.wait_timeout(std::time::Duration::from_secs(60)).unwrap();
assert_eq!(status.progress.phase, Phase::Completed, "{:?}", status.error);
assert_eq!(status.options, solidcraft_render::job::JobOptions { nested_media: true, ..Default::default() });
let retained = s.execute("render.status", &json!({"job":id})).unwrap();
assert_eq!(retained["options"]["nested_media"], true);
let frame = handle.frame().unwrap().unwrap();
let image = image::load_from_memory(frame.png()).unwrap().to_rgba8();
// Independent Beer oracle: outer2mm + inner2mm, replacing the parent
// interval, yields exp(-.8), exp(-1.2),1 -> Reinhard+sRGB [151,132,188].
assert_eq!(image.get_pixel(0, 0).0, [151, 132, 188, 255]);
assert!(std::sync::Arc::ptr_eq(&document, &s.doc));
assert_eq!(before, (s.revision, s.undo.len(), s.redo.len(), s.evaluations, s.is_dirty()));
}
#[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
#[test]
fn command_nesting_keeps_default_false_rejects_gpu_controls_and_captures_progressive_glass() {
use solidcraft_render::job::{JobId, JobOptions, Phase};
let (mut s, wire) = nested_command_fixture();
for (key, value) in [("samples", json!(2)), ("max_depth", json!(2)), ("seed", json!(7)), ("denoise", json!(true)), ("lens", json!({}))] {
let mut invalid = wire.clone();
invalid[key] = value;
for command in ["render.preflight", "render.start"] {
assert!(s.execute(command, &invalid).is_err(), "{command} accepted unrelated control {key}");
}
}
let mut progressive = wire.clone();
progressive["raytrace"]["progressive"] = json!({"samples_per_pixel":2,"seed":0});
// Original narrow perspective fixture: every possible pixel jitter ray
// enters both boxes, rather than allowing primary misses/background.
progressive["settings"]["camera"]["fov"] = json!(1.0e-6);
// This previously blanket-negative branch is now an actual supported
// progressive transport/capture case. GPU-only control refusals above
// and the default nesting assertions below are unchanged.
let checked = s.execute("render.preflight", &progressive).unwrap();
assert_eq!(checked["resolved"]["executor"], "cpu-raytrace");
assert_eq!(checked["request"]["raytrace"]["dielectrics"], wire["raytrace"]["dielectrics"]);
assert_eq!(checked["request"]["raytrace"]["progressive"], progressive["raytrace"]["progressive"]);
let started = s.execute("render.start", &progressive).unwrap();
let id: JobId = serde_json::from_value(started["job"].clone()).unwrap();
let handle = s.render_job_handle(id).unwrap();
let status = handle.wait_timeout(std::time::Duration::from_secs(60)).unwrap();
assert_eq!(status.progress.phase, Phase::Completed, "{:?}", status.error);
assert!(status.options.nested_media);
let captured = status.request.raytrace.as_ref().unwrap();
assert_eq!(captured.progressive.unwrap().samples_per_pixel, 2);
assert_eq!(captured.progressive.unwrap().seed, 0);
assert_eq!(serde_json::to_value(&captured.dielectrics).unwrap(), wire["raytrace"]["dielectrics"]);
let frame = handle.frame().unwrap().unwrap();
let image = image::load_from_memory(frame.png()).unwrap().to_rgba8();
assert_eq!(image.dimensions(), (1, 1));
// Camera is exterior at z52. At every box face its full jitter cone
// lies within52*tan(1e-6) < 0.000053mm of the axis, far inside the
// inner +/-5mm face. Even using twice the actual half-angle bounds,
// cos(theta)>1-2e-12. Parent-only and child lengths are each2/cos mm.
// IOR1 removes Fresnel/refraction; scalar Beer is exp(-.8/cos),
// exp(-1.2/cos),1. Their bounded variation is far below the nearest
// integer thresholds of existing Reinhard/sRGB: [151,132,188].
// Actual primary-background pixels would be255 and fail this oracle.
// This remains a command-route smoke, not a Fresnel/TIR quality case.
assert_eq!(image.get_pixel(0, 0).0, [151, 132, 188, 255]);
let interactive = interactive_params(&wire).unwrap();
assert_eq!(interactive["raytrace"]["dielectrics"], wire["raytrace"]["dielectrics"]);
assert_eq!(interactive["nested_media"], true);
assert!(interactive["raytrace"]["progressive"].is_object());
// Ordinary CPU requests retain the exact default JobOptions; omission is
// not silently promoted to nesting by adding this explicit opt-in.
let mut opaque = wire;
opaque.as_object_mut().unwrap().remove("nested_media");
opaque["raytrace"].as_object_mut().unwrap().remove("dielectrics");
s.execute("render.preflight", &opaque).unwrap();
let started = s.execute("render.start", &opaque).unwrap();
let id: JobId = serde_json::from_value(started["job"].clone()).unwrap();
let status = s.render_job_handle(id).unwrap().wait_timeout(std::time::Duration::from_secs(60)).unwrap();
assert_eq!(status.progress.phase, Phase::Completed, "{:?}", status.error);
assert_eq!(status.options, JobOptions::default());
let retained = s.execute("render.status", &json!({"job":id})).unwrap();
assert_eq!(retained["options"]["nested_media"], false);
}
}
/// Resolve portable intent before choosing an executor. Legacy requests are left intact;
@@ -140,6 +704,27 @@ struct Normalized {
}
fn normalize_saved_job(s: &Session, params: &Value, command: &str) -> Result<Option<Normalized>> {
// Validate the new payload by reference even if this turns out to be an
// unsaved request. Never clone an unbounded caller-owned light array.
let raster_override = params
.get("raster_lights")
.map(|value| solidcraft_render::job::deserialize_raster_lights(value).map_err(|e| bad(command, e.to_string())))
.transpose()?;
// Validate both local arrays by reference before any saved setup or JSON clone.
if let Some(value) = params.get("raster_punctual_lights") {
let _ = solidcraft_render::job::deserialize_raster_punctual_lights(value).map_err(|e| bad(command, e.to_string()))?;
}
if let Some(value) = params.get("raster_area_lights") {
let _ = solidcraft_render::job::deserialize_raster_area_lights(value).map_err(|e| bad(command, e.to_string()))?;
}
let local_intent = ["raster_punctual_lights", "raster_area_lights"].into_iter().any(|key| params.get(key).is_some_and(|v| !v.is_null()));
if local_intent {
let inline_saved = params.get("settings").and_then(|v| v.get("job")).is_some_and(|v| !v.is_null());
let named_saved = params.get("setup").and_then(Value::as_str).and_then(|name| s.doc.render.setups.get(name)).is_some_and(|v| v.job.is_some());
if inline_saved || named_saved {
return Err(bad(command, "runtime Raster local lights require settings.job: null; saved local-light authoring is not supported yet"));
}
}
let Some(object) = params.as_object() else {
return Ok(None);
};
@@ -152,16 +737,29 @@ fn normalize_saved_job(s: &Session, params: &Value, command: &str) -> Result<Opt
if value.get("job").is_none_or(Value::is_null) {
return Ok(None);
}
serde_json::from_value::<RenderSetup>(value.clone()).map_err(|e| bad(command, e.to_string()))?
RenderSetup::deserialize(value).map_err(|e| bad(command, e.to_string()))?
} else if let Some(settings) = named.and_then(Value::as_str).and_then(|name| s.doc.render.setups.get(name)) {
if settings.job.is_none() {
return Ok(None);
}
settings.validate().map_err(|e| bad(command, e.to_string()))?;
settings.clone()
} else {
return Ok(None);
};
if let Some(key) = object.keys().find(|key| !matches!(key.as_str(), "setup" | "settings" | "replace" | "studio_weights")) {
if let Some(key) = object.keys().find(|key| {
!matches!(
key.as_str(),
"setup"
| "settings"
| "replace"
| "studio_weights"
| "progressive_feedback"
| "raster_lights"
| "raster_punctual_lights"
| "raster_area_lights"
)
}) {
return Err(bad(
command,
format!(
@@ -178,7 +776,16 @@ fn normalize_saved_job(s: &Session, params: &Value, command: &str) -> Result<Opt
let surfaces = std::mem::take(&mut settings.surfaces);
let mut hdr = None;
let mut lowered = match job {
RenderJob::Raster { transparent_background } => json!({"transparent_background":transparent_background}),
RenderJob::Raster { transparent_background, lights } => {
if lights.is_some() && object.contains_key("raster_lights") {
return Err(bad(command, "saved Raster lights are authoritative; remove the runtime raster_lights override"));
}
let mut request = json!({"transparent_background":transparent_background});
if lights.is_some() {
request["raster_lights"] = serde_json::to_value(lights).map_err(|e| bad(command, e.to_string()))?;
}
request
}
RenderJob::CpuRaytrace { options } => {
hdr = options.hdr.map(SavedHdr::Cpu);
let raytrace = options.to_runtime(settings.exposure_ev).map_err(|e| bad(command, e.to_string()))?;
@@ -200,12 +807,19 @@ fn normalize_saved_job(s: &Session, params: &Value, command: &str) -> Result<Opt
}
};
lowered["settings"] = serde_json::to_value(settings).map_err(|e| bad(command, e.to_string()))?;
if let Some(lights) = raster_override {
// Only the already validated, bounded representation is materialized.
lowered["raster_lights"] = serde_json::to_value(lights).map_err(|e| bad(command, e.to_string()))?;
}
if let Some(weights) = object.get("studio_weights") {
lowered["studio_weights"] = weights.clone();
}
if let Some(replace) = object.get("replace") {
lowered["replace"] = replace.clone();
}
if let Some(feedback) = object.get("progressive_feedback") {
lowered["progressive_feedback"] = feedback.clone();
}
Ok(Some(Normalized { params: lowered, hdr, surfaces, optics }))
}
@@ -241,7 +855,26 @@ fn prepare(s: &Session, params: &Value, command: &str) -> Result<Prepared> {
"runtime GPU transmission, texture, lens, environment, lighting, tone-map and denoise controls require progressive rendering",
));
}
let mut input: Start = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
let mut input = Start::deserialize(params).map_err(|e| bad(command, e.to_string()))?;
solidcraft_render::job::validate_raster_lights(&input.raster_lights).map_err(job_error)?;
solidcraft_render::job::validate_raster_punctual_lights(&input.raster_punctual_lights).map_err(job_error)?;
solidcraft_render::job::validate_raster_area_lights(&input.raster_area_lights).map_err(job_error)?;
if (input.raster_lights.is_some() || input.raster_punctual_lights.is_some() || input.raster_area_lights.is_some())
&& (input.integrator != Integrator::Raster
|| input.progressive
|| input.progressive_feedback
|| input.nested_media
|| input.raytrace.is_some()
|| input.studio_weights.is_some()
|| input.environment.is_some())
{
return Err(bad(command, "explicit Raster lights require Raster without CPU/GPU controls, environment or studio_weights"));
}
if input.progressive_feedback
&& (input.integrator != Integrator::Raytrace || input.raytrace.as_ref().and_then(|options| options.progressive).is_none())
{
return Err(bad(command, "progressive_feedback requires CPU progressive raytracing"));
}
validate_studio_weights(input.studio_weights, command)?;
if input.integrator == Integrator::Raytrace && input.studio_weights.is_some() {
return Err(bad(command, "studio_weights apply to raster or the implicit GPU studio; use explicit CPU light intensities"));
@@ -274,6 +907,14 @@ fn prepare(s: &Session, params: &Value, command: &str) -> Result<Prepared> {
}
if let Some(options) = &input.raytrace {
options.retained_bytes().map_err(job_error)?;
if options.directional_shadow_transmission {
if input.nested_media {
return Err(bad(command, "directional shadow transmission does not support nested media"));
}
if saved_hdr.is_some() || input.environment.is_some() {
return Err(bad(command, "directional shadow transmission does not support HDR environments"));
}
}
options.budget.validate_execution().map_err(job_error)?;
// Check the complete image envelope before any source file is read.
let dimensions = if let Some(name) = &input.setup {
@@ -364,10 +1005,13 @@ fn prepare(s: &Session, params: &Value, command: &str) -> Result<Prepared> {
} else {
settings.validate().map_err(|e| bad(command, e.to_string()))?;
}
// The raster image executor has no exposure or environment controls. A setup saved with them
// (the render workspace and the other executors use them) still renders here, and the reply
// names what this render leaves out (#5089). The raytrace path takes them only through a
// saved job, so a raytrace request with them is still refused.
// Raster captures the existing exposure authority. Legacy environment controls
// still have no transport here and are reported/refused under their old policy.
if (input.raster_lights.is_some() || input.raster_punctual_lights.is_some() || input.raster_area_lights.is_some())
&& (settings.environment_rotation != 0.0 || settings.environment_intensity != 1.0)
{
return Err(bad(command, "explicit Raster lights do not implement environment controls"));
}
let ignored = if input.integrator == Integrator::Raster { ignored_controls(&settings) } else { Vec::new() };
if input.integrator != Integrator::Raster
&& (settings.exposure_ev != 0.0 || settings.environment_rotation != 0.0 || settings.environment_intensity != 1.0)
@@ -408,6 +1052,10 @@ fn prepare(s: &Session, params: &Value, command: &str) -> Result<Prepared> {
return Err(bad(command, "resolved executor is not installed as an image job host"));
}
let request = ImageRequest {
raster_punctual_lights: input.raster_punctual_lights.take(),
raster_area_lights: input.raster_area_lights.take(),
raster_exposure_ev: if input.integrator == Integrator::Raster { settings.exposure_ev } else { 0.0 },
raster_lights: input.raster_lights.take(),
studio_weights: validate_studio_weights(input.studio_weights, command)?,
environment: None,
textures: None,
@@ -419,28 +1067,47 @@ fn prepare(s: &Session, params: &Value, command: &str) -> Result<Prepared> {
background: settings.background,
transparent_background: input.transparent_background,
};
let mut admission = solidcraft_render::job::admit(&scene, &request).map_err(job_error)?;
let environment_bytes = environment.as_ref().map_or(Ok(0), |e| e.required_bytes()).map_err(|e| bad(command, e.to_string()))?;
let options = solidcraft_render::job::JobOptions { nested_media: input.nested_media, ..Default::default() };
let mut admission = if input.progressive_feedback {
host.admit_cpu_protected_partial_with_environment_and_options(&scene, &request, options, environment.as_ref()).map_err(job_error)?
} else {
host.admit(&scene, &request, options).map_err(job_error)?
};
let retained_lights = request.raytrace.as_ref().map_or(Ok(0), |o| o.retained_bytes()).map_err(job_error)?;
let retained_lights = retained_lights
.checked_add(request.raster_lights_retained_bytes().map_err(job_error)?)
.ok_or_else(|| bad(command, "retained light metadata overflow"))?;
let retained_environment =
if environment_info.is_some() { std::mem::size_of::<solidcraft_render::lighting::environment_input::EnvironmentInfo>() as u64 } else { 0 };
let retained_request_bytes = retained_lights.checked_add(retained_environment).ok_or_else(|| bad(command, "request metadata overflow"))?;
admission.estimated_total_bytes = admission
.estimated_total_bytes
.checked_add(environment_bytes)
.and_then(|v| v.checked_add(retained_request_bytes))
.filter(|v| *v <= budget.limits().max_job_bytes)
.ok_or_else(|| bad(command, "environment job exceeds per-job memory limit"))?;
admission.preparation_bytes =
admission.preparation_bytes.checked_add(environment_bytes).ok_or_else(|| bad(command, "environment admission overflow"))?;
if !input.progressive_feedback {
let environment_bytes = environment.as_ref().map_or(Ok(0), |e| e.required_bytes()).map_err(|e| bad(command, e.to_string()))?;
admission.estimated_total_bytes = admission
.estimated_total_bytes
.checked_add(environment_bytes)
.and_then(|v| v.checked_add(retained_request_bytes))
.filter(|v| *v <= budget.limits().max_job_bytes)
.ok_or_else(|| bad(command, "environment job exceeds per-job memory limit"))?;
admission.preparation_bytes =
admission.preparation_bytes.checked_add(environment_bytes).ok_or_else(|| bad(command, "environment admission overflow"))?;
}
let transparency = match input.integrator {
Integrator::Raster => "sorted_alpha_approximation",
Integrator::Raytrace if refraction => "physical_transmission",
Integrator::Raytrace => "opaque_only",
};
let reply = json!({"ignored_controls":ignored,"stage_panels":settings.stage.len(),"stage_transport":"CPU direct/specular only; no diffuse interreflection", "cpu_budget":budget,"cpu_limits":budget.limits(),"environment":environment_info,"retained_request_bytes":retained_request_bytes,"resolved":resolved,"admission":admission,"request":request,"scene_generation":s.revision,"host":host.kind(),"transparency":transparency,"refraction":refraction,"dielectric_boundaries":if refraction {vec!["thin_sheet","closed_box"]} else {Vec::<&str>::new()}});
Ok(Prepared { replace: input.replace, scene, request, reply, environment, executor: resolved.executor, options: Default::default() })
Ok(Prepared {
progressive_feedback: input.progressive_feedback,
replace: input.replace,
scene,
request,
reply,
environment,
executor: resolved.executor,
options,
})
}
fn prepare_progressive(
@@ -465,6 +1132,9 @@ fn prepare_progressive(
return Err(bad(command, "progressive rendering does not accept CPU raytrace, environment or LED options"));
}
let mut input: ProgressiveStart = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
if input.progressive_feedback {
return Err(bad(command, "progressive_feedback requires CPU progressive raytracing, not GPU progressive rendering"));
}
validate_studio_weights(input.studio_weights, command)?;
if input.studio_weights.is_some() && (input.lighting.is_some() || input.environment.is_some() || saved_hdr.is_some()) {
return Err(bad(command, "studio_weights require the implicit GPU studio without environment lighting"));
@@ -615,6 +1285,10 @@ fn prepare_progressive(
return Err(bad(command, "image host is unavailable"));
}
let request = ImageRequest {
raster_punctual_lights: None,
raster_area_lights: None,
raster_exposure_ev: 0.0,
raster_lights: None,
studio_weights: validate_studio_weights(input.studio_weights, command)?,
integrator: Default::default(),
raytrace: None,
@@ -644,15 +1318,21 @@ fn prepare_progressive(
"raw_linear_accumulator_preserved":true, "output_stage":"linear_before_tone_map"
});
}
Ok(Prepared { replace: input.replace, scene, request, reply, executor: resolved.executor, options, environment: None })
Ok(Prepared {
progressive_feedback: false,
replace: input.replace,
scene,
request,
reply,
executor: resolved.executor,
options,
environment: None,
})
}
/// The setup's controls the raster image executor doesn't apply: those not at their neutral value.
/// Nonneutral environment controls the Raster image executor still cannot apply.
fn ignored_controls(settings: &RenderSetup) -> Vec<&'static str> {
let mut ignored = Vec::new();
if settings.exposure_ev != 0.0 {
ignored.push("exposure_ev");
}
if settings.environment_rotation != 0.0 {
ignored.push("environment_rotation");
}
@@ -668,16 +1348,13 @@ pub(super) fn preflight(s: &mut Session, params: &Value) -> Result<Value> {
pub(super) fn start(s: &mut Session, params: &Value) -> Result<Value> {
let p = prepare(s, params, "render.start")?;
let status = s
.render_jobs
.start_with_environment(
s.revision,
p.scene,
p.request,
p.replace,
crate::render_jobs::Execution { executor: p.executor, options: p.options, environment: p.environment },
)
.map_err(job_error)?;
let execution = crate::render_jobs::Execution { executor: p.executor, options: p.options, environment: p.environment };
let status = if p.progressive_feedback {
s.render_jobs.start_with_environment_and_feedback(s.revision, p.scene, p.request, p.replace, execution, true)
} else {
s.render_jobs.start_with_environment(s.revision, p.scene, p.request, p.replace, execution)
}
.map_err(job_error)?;
let mut status = json!(status);
if let (Some(out), Some(ignored)) = (status.as_object_mut(), p.reply.get("ignored_controls")) {
out.insert("ignored_controls".into(), ignored.clone());
@@ -807,3 +1484,54 @@ fn validate_studio_weights(weights: Option<[f64; 3]>, command: &str) -> Result<O
}
Ok(weights)
}
#[cfg(test)]
mod directional_shadow_guard_tests {
use super::*;
fn wire() -> Value {
json!({"integrator":"raytrace","settings":{"width":1,"height":1,"job":null},
"raytrace":{"directional_shadow_transmission":true,"shadows":true,"lights":[],
"dielectrics":[{"body_index":0,"body_name":"guard-only target","boundary":"thin_sheet",
"ior":1.0,"absorption_per_mm":[0.0,0.0,0.0],"thickness_mm":1.0}]}})
}
#[test]
fn interactive_lowering_preserves_real_directional_mode_without_selecting_it_by_default() {
for enabled in [false, true] {
let mut input = wire();
input["raytrace"]["directional_shadow_transmission"] = json!(enabled);
let lowered = interactive_params(&input).unwrap();
let after: Start = serde_json::from_value(lowered.clone()).unwrap();
assert_eq!(after.raytrace.unwrap().directional_shadow_transmission, enabled);
assert_eq!(lowered["raytrace"].get("directional_shadow_transmission").and_then(Value::as_bool), enabled.then_some(true));
}
}
#[cfg(all(feature = "raytrace-cpu", not(target_arch = "wasm32")))]
#[test]
fn directional_context_refusals_precede_unopened_hdr_and_product_capture() {
let mut session = Session::default();
let host = solidcraft_render::job::CpuHost::for_platform().unwrap();
session.set_render_job_host(Some(host)).unwrap();
for nested in [false, true] {
let mut input = wire();
if nested {
input["nested_media"] = json!(true);
} else {
input["environment"] = json!({"path":"must-not-open-original-directional.hdr","samples":4});
}
let error = prepare(&session, &input, "render.start").err().unwrap().to_string();
assert!(
error.contains(if nested {
"directional shadow transmission does not support nested media"
} else {
"directional shadow transmission does not support HDR environments"
}),
"{error}"
);
assert!(!error.contains("could not read"));
assert_eq!(session.render_job_host().unwrap().admitted_bytes(), 0);
}
}
}
+167 -146
View File
@@ -1,146 +1,167 @@
//! Read-only light inspection. No host, geometry evaluation, camera or design mutation.
use serde_json::Value;
use solidcraft_doc::render_settings::{RenderSettings, RenderSetup};
use crate::params::bad;
use crate::schema::render_lights::{EditParams, EditResult, Geometry, Light};
use crate::schema::render_lights::{ListParams, ListResult};
use crate::{Result, Session};
pub(super) fn list(s: &mut Session, params: &Value) -> Result<Value> {
let cmd = "render.lights.list";
if !params.is_object() {
return Err(bad(cmd, "expected an object"));
}
let request: ListParams = serde_json::from_value(params.clone()).map_err(|e| bad(cmd, e.to_string()))?;
if params.get("setup").is_some_and(Value::is_null) || params.get("settings").is_some_and(Value::is_null) {
return Err(bad(cmd, "setup and settings cannot be null"));
}
let setup = match (request.setup, request.settings) {
(Some(_), Some(_)) => return Err(bad(cmd, "choose setup or settings, not both")),
(Some(name), None) => {
RenderSettings::validate_name(&name).map_err(|e| bad(cmd, e.to_string()))?;
s.doc.render.setups.get(&name).cloned().ok_or_else(|| bad(cmd, "no saved render setup with that name"))?
}
(None, Some(settings)) => {
if !settings.is_object() {
return Err(bad(cmd, "settings must be an object"));
}
serde_json::from_value(settings).map_err(|e| bad(cmd, e.to_string()))?
}
(None, None) => RenderSetup::default(),
};
setup.validate().map_err(|e| bad(cmd, e.to_string()))?;
serde_json::to_value(ListResult { lights: crate::render_lights::inspect(&setup).map_err(|e| bad(cmd, e))? }).map_err(|e| bad(cmd, e.to_string()))
}
fn change(s: &mut Session, params: &Value, operation: &str) -> Result<Value> {
let command = format!("render.lights.{operation}");
if !params.is_object() {
return Err(bad(&command, "expected an object"));
}
let input: EditParams = serde_json::from_value(params.clone()).map_err(|e| bad(&command, e.to_string()))?;
RenderSettings::validate_name(&input.name).map_err(|e| bad(&command, e.to_string()))?;
if input.expected_revision.is_some_and(|revision| revision != s.revision) {
return Err(bad(&command, "light settings changed; refresh before editing"));
}
let mut setup = match input.settings {
Some(value) if value.is_object() => serde_json::from_value::<RenderSetup>(value).map_err(|e| bad(&command, e.to_string()))?,
Some(_) => return Err(bad(&command, "settings must be an object")),
None => s.doc.render.setups.get(&input.name).cloned().unwrap_or_default(),
};
let mut lights = crate::render_lights::inspect(&setup).map_err(|e| bad(&command, e))?;
let target = [setup.camera.target.x, setup.camera.target.y, setup.camera.target.z];
let mut selected = None;
match operation {
"add" => {
if input.id.is_some() {
return Err(bad(&command, "add does not accept an id"));
}
let mut patch = input.set.unwrap_or_default();
if patch.kind.is_none() {
patch.kind = Some("area".into());
}
let [x, y, z] = target;
let mut light = Light {
id: String::new(),
name: String::new(),
name_key: String::new(),
number: 0,
implicit: false,
intensity: 1.0,
colour: [1.0; 3],
geometry: Geometry::Area { position_mm: [x, y - 100.0, z + 100.0], edge_u_mm: [10.0, 0.0, 0.0], edge_v_mm: [0.0, -10.0, 0.0] },
};
crate::render_lights::patch(&mut light, &patch, target, setup.camera.distance).map_err(|e| bad(&command, e))?;
lights.push(light);
selected = lights.len().checked_sub(1);
}
"set" | "duplicate" | "remove" => {
let id = input.id.as_deref().ok_or_else(|| bad(&command, "id is required"))?;
crate::render_lights::parse_id(id).map_err(|e| bad(&command, e))?;
let index = lights.iter().position(|light| light.id == id).ok_or_else(|| bad(&command, "light no longer exists"))?;
if operation == "remove" {
if input.set.is_some() {
return Err(bad(&command, "remove does not accept set"));
}
lights.retain(|light| light.id != id);
} else if operation == "duplicate" {
if input.set.is_some() {
return Err(bad(&command, "duplicate does not accept set"));
}
let light = lights.get(index).cloned().ok_or_else(|| bad(&command, "missing light"))?;
lights.push(light);
selected = lights.len().checked_sub(1);
} else {
let patch = input.set.ok_or_else(|| bad(&command, "set is required"))?;
let light = lights.get_mut(index).ok_or_else(|| bad(&command, "missing light"))?;
if matches!(setup.job, Some(solidcraft_doc::render_settings::RenderJob::CpuRaytrace { .. }))
&& light.geometry.kind() == "directional"
&& patch.softness.is_some()
{
return Err(bad(&command, "CPU directional lights have hard shadows; softness is not supported"));
}
crate::render_lights::patch(light, &patch, target, setup.camera.distance).map_err(|e| bad(&command, e))?;
selected = Some(index);
}
}
_ => return Err(bad(&command, "unknown light operation")),
}
let id = selected.and_then(|index| {
lights.get(index).map(|light| {
let kind = light.geometry.kind();
let slot = lights
.iter()
.take(index)
.filter(|prior| {
if matches!(kind, "point" | "spot") { matches!(prior.geometry.kind(), "point" | "spot") } else { prior.geometry.kind() == kind }
})
.count();
format!("{kind}:{slot}")
})
});
crate::render_lights::write(&mut setup, &lights).map_err(|e| bad(&command, e))?;
let mut settings = (*s.doc.render).clone();
settings.setups.insert(input.name.clone(), setup.clone());
settings.validate().map_err(|e| bad(&command, e.to_string()))?;
if *s.doc.render != settings {
s.doc_mut().render = std::sync::Arc::new(settings);
}
serde_json::to_value(EditResult { id, name: input.name, setup: serde_json::to_value(setup).map_err(|e| bad(&command, e.to_string()))? })
.map_err(|e| bad(&command, e.to_string()))
}
pub(super) fn set(s: &mut Session, p: &Value) -> Result<Value> {
change(s, p, "set")
}
pub(super) fn add(s: &mut Session, p: &Value) -> Result<Value> {
change(s, p, "add")
}
pub(super) fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
change(s, p, "duplicate")
}
pub(super) fn remove(s: &mut Session, p: &Value) -> Result<Value> {
change(s, p, "remove")
}
//! Read-only light inspection. No host, geometry evaluation, camera or design mutation.
use serde_json::Value;
use solidcraft_doc::render_settings::{RenderSettings, RenderSetup};
use crate::params::bad;
use crate::schema::render_lights::{EditParams, EditResult, Geometry, Light};
use crate::schema::render_lights::{ListParams, ListResult};
use crate::{Result, Session};
pub(super) fn list(s: &mut Session, params: &Value) -> Result<Value> {
let cmd = "render.lights.list";
if let Some(settings) = params.get("settings") {
super::validate_saved_raster_lights(settings, cmd)?;
}
if !params.is_object() {
return Err(bad(cmd, "expected an object"));
}
let request: ListParams = serde_json::from_value(params.clone()).map_err(|e| bad(cmd, e.to_string()))?;
if params.get("setup").is_some_and(Value::is_null) || params.get("settings").is_some_and(Value::is_null) {
return Err(bad(cmd, "setup and settings cannot be null"));
}
let setup = match (request.setup, request.settings) {
(Some(_), Some(_)) => return Err(bad(cmd, "choose setup or settings, not both")),
(Some(name), None) => {
RenderSettings::validate_name(&name).map_err(|e| bad(cmd, e.to_string()))?;
let setup = s.doc.render.setups.get(&name).ok_or_else(|| bad(cmd, "no saved render setup with that name"))?;
setup.validate().map_err(|e| bad(cmd, e.to_string()))?;
setup.clone()
}
(None, Some(settings)) => {
if !settings.is_object() {
return Err(bad(cmd, "settings must be an object"));
}
serde_json::from_value(settings).map_err(|e| bad(cmd, e.to_string()))?
}
(None, None) => RenderSetup::default(),
};
setup.validate().map_err(|e| bad(cmd, e.to_string()))?;
serde_json::to_value(ListResult { lights: crate::render_lights::inspect(&setup).map_err(|e| bad(cmd, e))? }).map_err(|e| bad(cmd, e.to_string()))
}
fn change(s: &mut Session, params: &Value, operation: &str) -> Result<Value> {
let command = format!("render.lights.{operation}");
if let Some(settings) = params.get("settings") {
super::validate_saved_raster_lights(settings, &command)?;
}
if !params.is_object() {
return Err(bad(&command, "expected an object"));
}
let input: EditParams = serde_json::from_value(params.clone()).map_err(|e| bad(&command, e.to_string()))?;
RenderSettings::validate_name(&input.name).map_err(|e| bad(&command, e.to_string()))?;
if input.expected_revision.is_some_and(|revision| revision != s.revision) {
return Err(bad(&command, "light settings changed; refresh before editing"));
}
let mut setup = match input.settings {
Some(value) if value.is_object() => serde_json::from_value::<RenderSetup>(value).map_err(|e| bad(&command, e.to_string()))?,
Some(_) => return Err(bad(&command, "settings must be an object")),
None => {
if let Some(setup) = s.doc.render.setups.get(&input.name) {
setup.validate().map_err(|e| bad(&command, e.to_string()))?;
}
s.doc.render.setups.get(&input.name).cloned().unwrap_or_default()
}
};
let mut lights = crate::render_lights::inspect(&setup).map_err(|e| bad(&command, e))?;
if matches!(&setup.job, Some(solidcraft_doc::render_settings::RenderJob::Raster { .. })) {
if matches!(operation, "add" | "duplicate") && lights.len() == 8 {
return Err(bad(&command, "at most eight saved Raster directional lights"));
}
if let Some(patch) = &input.set {
crate::render_lights::validate_raster_patch(patch).map_err(|e| bad(&command, e))?;
}
}
let target = [setup.camera.target.x, setup.camera.target.y, setup.camera.target.z];
let mut selected = None;
match operation {
"add" => {
if input.id.is_some() {
return Err(bad(&command, "add does not accept an id"));
}
let mut patch = input.set.unwrap_or_default();
if patch.kind.is_none() {
patch.kind = Some("area".into());
}
let [x, y, z] = target;
let mut light = Light {
id: String::new(),
name: String::new(),
name_key: String::new(),
number: 0,
implicit: false,
intensity: 1.0,
colour: [1.0; 3],
geometry: Geometry::Area { position_mm: [x, y - 100.0, z + 100.0], edge_u_mm: [10.0, 0.0, 0.0], edge_v_mm: [0.0, -10.0, 0.0] },
};
crate::render_lights::patch(&mut light, &patch, target, setup.camera.distance).map_err(|e| bad(&command, e))?;
lights.push(light);
selected = lights.len().checked_sub(1);
}
"set" | "duplicate" | "remove" => {
let id = input.id.as_deref().ok_or_else(|| bad(&command, "id is required"))?;
crate::render_lights::parse_id(id).map_err(|e| bad(&command, e))?;
let index = lights.iter().position(|light| light.id == id).ok_or_else(|| bad(&command, "light no longer exists"))?;
if operation == "remove" {
if input.set.is_some() {
return Err(bad(&command, "remove does not accept set"));
}
lights.retain(|light| light.id != id);
} else if operation == "duplicate" {
if input.set.is_some() {
return Err(bad(&command, "duplicate does not accept set"));
}
let light = lights.get(index).cloned().ok_or_else(|| bad(&command, "missing light"))?;
lights.push(light);
selected = lights.len().checked_sub(1);
} else {
let patch = input.set.ok_or_else(|| bad(&command, "set is required"))?;
let light = lights.get_mut(index).ok_or_else(|| bad(&command, "missing light"))?;
if matches!(setup.job, Some(solidcraft_doc::render_settings::RenderJob::CpuRaytrace { .. }))
&& light.geometry.kind() == "directional"
&& patch.softness.is_some()
{
return Err(bad(&command, "CPU directional lights have hard shadows; softness is not supported"));
}
crate::render_lights::patch(light, &patch, target, setup.camera.distance).map_err(|e| bad(&command, e))?;
selected = Some(index);
}
}
_ => return Err(bad(&command, "unknown light operation")),
}
let id = selected.and_then(|index| {
lights.get(index).map(|light| {
let kind = light.geometry.kind();
let slot = lights
.iter()
.take(index)
.filter(|prior| {
if matches!(kind, "point" | "spot") { matches!(prior.geometry.kind(), "point" | "spot") } else { prior.geometry.kind() == kind }
})
.count();
format!("{kind}:{slot}")
})
});
crate::render_lights::write(&mut setup, &lights).map_err(|e| bad(&command, e))?;
let mut settings = (*s.doc.render).clone();
settings.setups.insert(input.name.clone(), setup.clone());
settings.validate().map_err(|e| bad(&command, e.to_string()))?;
if *s.doc.render != settings {
s.doc_mut().render = std::sync::Arc::new(settings);
}
serde_json::to_value(EditResult { id, name: input.name, setup: serde_json::to_value(setup).map_err(|e| bad(&command, e.to_string()))? })
.map_err(|e| bad(&command, e.to_string()))
}
pub(super) fn set(s: &mut Session, p: &Value) -> Result<Value> {
change(s, p, "set")
}
pub(super) fn add(s: &mut Session, p: &Value) -> Result<Value> {
change(s, p, "add")
}
pub(super) fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
change(s, p, "duplicate")
}
pub(super) fn remove(s: &mut Session, p: &Value) -> Result<Value> {
change(s, p, "remove")
}
+88 -1
View File
@@ -1,6 +1,93 @@
use super::*;
use solidcraft_doc::render_settings::RenderJob;
#[test]
fn offline_raster_directional_edit_persists_with_one_undo_and_exact_reopen() {
let mut s = Session::default();
save(&mut s, json!({"width":16,"height":16,"job":{"method":"raster"}}));
let before = s.doc.clone();
let undo = s.undo.len();
let reply = s
.execute(
"render.lights.set",
&json!({"name":"Studio","id":"directional:0","expected_revision":s.revision,
"set":{"direction":[3,-4,0],"colour":[0.25,0.5,1],"intensity_ev":1}}),
)
.unwrap();
assert_eq!(reply["setup"]["job"]["method"], "raster");
let light = &reply["setup"]["job"]["lights"][0];
assert_eq!(light["direction"][0].as_f64().unwrap(), 3.0);
assert_eq!(light["direction"][1].as_f64().unwrap(), -4.0);
assert_eq!(light["intensity"].as_f64().unwrap(), 2.0);
assert_eq!(reply["setup"]["job"]["lights"].as_array().unwrap().len(), 3);
assert_eq!(s.undo.len(), undo + 1);
let after = s.doc.clone();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc, before);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(s.doc, after);
let loaded = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
assert_eq!(loaded.render, s.doc.render);
let listed = s.execute("render.lights.list", &json!({"setup":"Studio"})).unwrap();
assert_eq!(listed["lights"][0]["implicit"], false);
assert_eq!(listed["lights"][0]["direction"][0].as_f64().unwrap(), 3.0);
}
#[test]
fn saved_raster_remove_all_and_visibility_preserve_explicit_darkness_and_authored_values() {
let mut s = Session::default();
save(&mut s, json!({"job":{"method":"raster","lights":[{"direction":[0,0,1],"color":[0.25,0.5,1],"intensity":2}]}}));
let authored = s.doc.render.setups["Studio"].clone();
let mut filtered = authored.clone();
let visibility = crate::schema::render_lights::Visibility { solo: None, muted: ["directional:0".into()].into_iter().collect() };
assert_eq!(crate::render_lights::filter(&mut filtered, &visibility).unwrap(), None);
let Some(RenderJob::Raster { lights: Some(lights), .. }) = &filtered.job else { panic!("lost rig") };
assert_eq!(lights[0].intensity, 0.0);
assert_eq!(s.doc.render.setups["Studio"], authored);
s.execute("render.lights.remove", &json!({"name":"Studio","id":"directional:0"})).unwrap();
let Some(RenderJob::Raster { lights: Some(lights), .. }) = &s.doc.render.setups["Studio"].job else { panic!("empty rig became legacy") };
assert!(lights.is_empty());
let loaded = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap();
assert_eq!(loaded.render, s.doc.render);
assert_eq!(s.execute("render.lights.list", &json!({"setup":"Studio"})).unwrap()["lights"], json!([]));
}
#[test]
fn saved_raster_unsupported_families_and_controls_refuse_atomically() {
let mut s = Session::default();
save(&mut s, json!({"job":{"method":"raster"}}));
let before = s.doc.clone();
let state = (s.revision, s.undo.len(), s.redo.len());
for patch in [
json!({"kind":"point"}),
json!({"kind":"spot"}),
json!({"kind":"area"}),
json!({"softness":0}),
json!({"intensity_ev":7}),
json!({"distance_mm":10}),
json!({"radiance":[1,1,1]}),
json!({"direction":[0,0,1],"azimuth_deg":0}),
] {
assert!(s.execute("render.lights.set", &json!({"name":"Studio","id":"directional:0","set":patch})).is_err());
assert!(Arc::ptr_eq(&before, &s.doc));
assert_eq!(state, (s.revision, s.undo.len(), s.redo.len()));
}
}
#[test]
fn saved_raster_array_is_bounded_before_settings_merge_and_inline_light_edit() {
let mut s = Session::default();
save(&mut s, json!({"job":{"method":"raster"}}));
let before = s.doc.clone();
let light = json!({"direction":[0,0,1],"color":[1,1,1],"intensity":1});
let invalid = json!({"job":{"method":"raster","lights":vec![light;9]}});
assert!(s.execute("render.settings.set", &json!({"name":"Studio","set":invalid})).is_err());
assert!(s.execute("render.lights.set", &json!({"name":"Studio","id":"directional:0","settings":invalid,"set":{"intensity_ev":1}})).is_err());
assert!(s.execute("render.lights.list", &json!({"settings":invalid})).is_err());
assert!(Arc::ptr_eq(&before, &s.doc));
// The small refusal fixture is not an OOM test; source order proves pre-copy admission.
}
fn save(s: &mut Session, patch: Value) {
s.execute("render.settings.set", &json!({"name":"Studio", "set":patch})).unwrap();
}
@@ -725,7 +812,7 @@ fn invalid_saved_jobs_never_partially_replace_a_setup() {
json!({"job":{"method":"gpu_path","options":{"samples":4097}}}),
json!({"job":{"method":"cpu_raytrace","options":{"emissions":[]}}}),
json!({"job":{"method":"gpu_path","options":{"environment":{"kind":"hdr","path":"not-opened.hdr"}}}}),
json!({"job":{"method":"raster"}}),
json!({"job":{"method":"raster"},"exposure_ev":33}),
json!({"job":{"method":"gpu_path"},"environment_intensity":2}),
json!({"job":{"options":{"samples":4}}}),
] {
+127 -1
View File
@@ -1,7 +1,10 @@
//! Planned Render interactions, with explicit non-mutating refusals.
use super::CommandSpec;
use crate::schema::render_lights::{MuteParams, SoloParams};
use crate::schema::{TypedContract, output_schema_for, render_ui::CheckReport};
use crate::schema::{
TypedContract, output_schema_for,
render_ui::{CheckReport, InteractiveParams},
};
use crate::{Result, Session, params::bad};
use serde::Deserialize;
use serde_json::{Value, json};
@@ -48,8 +51,16 @@ macro_rules! waiting {
}
const CHECK_REPORT: TypedContract<CheckReport> = TypedContract::new();
const INTERACTIVE: TypedContract<InteractiveParams> = TypedContract::new();
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("render.interactive", "Interactive Render", crate::checked!("render.interactive", interactive))
.icon("render_image")
.contract(&INTERACTIVE)
.output(output_schema_for::<InteractiveParams>)
.noundo()
.doc_only()
.params("action: on|off|pause|resume. Validates and echoes an explicit CPU Interactive preview request for the UI adapter. Headless calls never start, pause or cancel a render job, open UI, or change the design, final render settings or exportable images."),
CommandSpec::new("render.preflight.copy", "Copy Render Check Report", crate::checked!("render.preflight.copy", copy_check))
.icon("copy")
.contract(&CHECK_REPORT)
@@ -210,3 +221,118 @@ fn output_sections_copy_report_validates_without_design_history_or_job_changes()
assert!(std::sync::Arc::ptr_eq(&doc, &s.doc));
assert_eq!(before, (s.revision, s.undo.len(), s.redo.len(), s.evaluations, s.is_dirty()));
}
/// The UI adapter owns provisional preview actions; headless execution only echoes.
fn interactive(_: &mut Session, params: &Value) -> Result<Value> {
let command = "render.interactive";
if !params.is_object() {
return Err(bad(command, "expected an object"));
}
let request: InteractiveParams = serde_json::from_value(params.clone()).map_err(|error| bad(command, error.to_string()))?;
serde_json::to_value(request).map_err(|error| bad(command, error.to_string()))
}
#[cfg(test)]
#[test]
fn interactive_contract_describes_exact_actions_and_a_nonmutating_echo() {
use crate::schema::{input_schema_for, render_ui::Action};
let command = super::find_command("render.interactive").unwrap();
assert!(!command.undoable);
assert!(!command.reads_model);
assert!(command.planned.is_none());
assert_eq!(command.icon, "render_image");
let contract = command.contract.unwrap();
assert_eq!(contract.allowed_keys().iter().map(String::as_str).collect::<Vec<_>>(), ["action"]);
assert!(command.output.is_some());
let action_schema = serde_json::to_value(input_schema_for::<Action>()).unwrap();
assert_eq!(action_schema["enum"], json!(["on", "off", "pause", "resume"]));
let request_schema = serde_json::to_value(input_schema_for::<InteractiveParams>()).unwrap();
assert_eq!(request_schema["additionalProperties"], false);
assert_eq!(request_schema["required"], json!(["action"]));
let reply_schema = serde_json::to_value(output_schema_for::<InteractiveParams>()).unwrap();
assert_eq!(reply_schema["required"], json!(["action"]));
assert_eq!(reply_schema["properties"].as_object().unwrap().keys().map(String::as_str).collect::<Vec<_>>(), ["action"]);
}
#[cfg(test)]
#[test]
fn interactive_valid_and_hostile_requests_preserve_headless_document_history_and_host() {
use crate::schema::ParamCheck;
let mut session = Session::default();
session.execute("render.settings.set", &json!({"name":"Original","set":{"width":800}})).unwrap();
session.execute("render.settings.set", &json!({"name":"Original","set":{"width":900}})).unwrap();
session.execute("edit.undo", &json!({})).unwrap();
let document = session.doc.clone();
let before = (session.revision, session.undo.len(), session.redo.len(), session.evaluations, session.is_dirty());
assert!(session.render_job_host().is_none());
for level in [ParamCheck::Off, ParamCheck::Warn, ParamCheck::Strict] {
session.param_check = level;
for action in ["on", "off", "pause", "resume"] {
let params = json!({"action":action});
assert_eq!(session.execute("render.interactive", &params).unwrap(), params);
}
for hostile in [
json!(null),
json!(true),
json!(0),
json!("on"),
json!([]),
json!(["on"]),
json!({}),
json!({"action":null}),
json!({"action":true}),
json!({"action":1e300}),
json!({"action":["on"]}),
json!({"action":{"on":true}}),
json!({"action":"start"}),
json!({"action":"On"}),
json!({"action":" on"}),
json!({"action":"on\n"}),
json!({"action":"x".repeat(65536)}),
json!({"action":"on","slot":0}),
json!({"action":"off","job":{"session":1,"serial":1,"generation":1}}),
json!({"action":"pause","replace":true}),
json!({"action":"resume","settings":{}}),
json!({"action":"on","path":"must-not-be-read.hdr"}),
] {
assert!(session.execute("render.interactive", &hostile).is_err(), "{level:?}: {hostile}");
}
assert!(std::sync::Arc::ptr_eq(&document, &session.doc));
assert_eq!(before, (session.revision, session.undo.len(), session.redo.len(), session.evaluations, session.is_dirty()));
assert!(session.render_job_host().is_none());
}
}
#[cfg(test)]
#[test]
fn interactive_echo_preserves_a_queued_final_job_and_its_resource_lease() {
use solidcraft_render::job::{CpuHost, JobId};
let host = CpuHost::cooperative();
let mut session = Session::default();
session.set_render_job_host(Some(host.clone())).unwrap();
// Queue a tiny raster job without pumping the cooperative host or opening a device.
let started = session.execute("render.start", &json!({"settings":{"width":16,"height":16}})).unwrap();
let first_id: JobId = serde_json::from_value(started["job"].clone()).unwrap();
let first = session.render_job_handle(first_id).unwrap();
let status = serde_json::to_value(first.status().unwrap()).unwrap();
let admitted = host.admitted_bytes();
assert!(admitted > 0);
let document = session.doc.clone();
let before = (session.revision, session.undo.len(), session.redo.len(), session.evaluations, session.is_dirty());
for action in ["on", "pause", "resume", "off"] {
let params = json!({"action":action});
assert_eq!(session.execute("render.interactive", &params).unwrap(), params);
assert_eq!(serde_json::to_value(first.status().unwrap()).unwrap(), status);
assert_eq!(host.admitted_bytes(), admitted);
assert!(session.render_job_host().unwrap().same_host(&host));
assert!(std::sync::Arc::ptr_eq(&document, &session.doc));
assert_eq!(before, (session.revision, session.undo.len(), session.redo.len(), session.evaluations, session.is_dirty()));
}
assert!(session.execute("render.interactive", &json!({"action":"off","job":first_id})).is_err());
assert_eq!(serde_json::to_value(first.status().unwrap()).unwrap(), status);
assert_eq!(host.admitted_bytes(), admitted);
let next = session.execute("render.start", &json!({"settings":{"width":16,"height":16}})).unwrap();
let next_id: JobId = serde_json::from_value(next["job"].clone()).unwrap();
assert_eq!(next_id.session, first_id.session);
assert_eq!(next_id.serial, first_id.serial + 1, "echo actions must not allocate or replace final job identities");
}
+1954 -1953
View File
File diff suppressed because it is too large Load Diff
+400
View File
@@ -0,0 +1,400 @@
//! Transient CPU Interactive capture, separate from final render ownership/export.
use std::sync::{Arc, Mutex, Weak};
use std::time::Duration;
use serde_json::Value;
use solidcraft_render::job::{
self, ErrorCode, JobError, JobHandle,
interactive::{CaptureTicket, Interactive, State, VisualRevision},
};
use crate::{Result, Session, render_jobs::job_error};
pub(crate) type Controller = Mutex<Interactive>;
/// A poisoned lifecycle is never resumed. Cleanup only its owned work, then fail.
pub(crate) fn with_controller<T>(controller: &Controller, f: impl FnOnce(&mut Interactive) -> job::Result<T>) -> job::Result<T> {
match controller.lock() {
Ok(mut state) => f(&mut state),
Err(poisoned) => {
let _ = poisoned.into_inner().disable();
Err(JobError::new(ErrorCode::Internal, "Interactive lifecycle lock poisoned"))
}
}
}
/// One transient controller for the current document/service registration.
/// Only Session::interactive_render allocates its dedicated preview namespace.
pub struct InteractiveRender {
document_uid: u64,
preview_session: u64,
controller: Arc<Controller>,
}
impl InteractiveRender {
pub(crate) fn new(document_uid: u64, preview_session: u64, final_active: bool) -> job::Result<Self> {
if document_uid == 0 || preview_session == 0 {
return Err(JobError::new(ErrorCode::LimitExceeded, "Interactive identity exhausted"));
}
let mut state = Interactive::new(preview_session);
state.set_final_active(final_active, Duration::ZERO)?;
Ok(Self { document_uid, preview_session, controller: Arc::new(Mutex::new(state)) })
}
pub(crate) fn weak(&self) -> Weak<Controller> {
Arc::downgrade(&self.controller)
}
fn with<T>(&self, f: impl FnOnce(&mut Interactive) -> job::Result<T>) -> Result<T> {
with_controller(&self.controller, f).map_err(job_error)
}
/// Cheap ownership metadata only: no geometry, files, host discovery or work.
pub fn is_current(&self, session: &Session) -> bool {
self.document_uid == session.uid && session.render_jobs.is_interactive_current(&self.weak())
}
pub fn document_uid(&self) -> u64 {
self.document_uid
}
pub fn enable(&self, revision: VisualRevision, now: Duration) -> Result<()> {
self.with(|state| state.enable(revision, now))
}
pub fn edit(&self, revision: VisualRevision, now: Duration) -> Result<()> {
self.with(|state| state.edit(revision, now))
}
pub fn disable(&self) -> Result<()> {
self.with(Interactive::disable)
}
pub fn pause(&self) -> Result<()> {
self.with(Interactive::pause)
}
pub fn resume(&self, now: Duration) -> Result<()> {
self.with(|state| state.resume(now))
}
pub fn set_final_active(&self, active: bool, now: Duration) -> Result<()> {
self.with(|state| state.set_final_active(active, now))
}
pub fn capture(&self, now: Duration) -> Result<Option<CaptureTicket>> {
self.with(|state| state.capture(now))
}
pub fn state(&self) -> Result<State> {
self.with(|state| state.state())
}
pub fn current(&self) -> Result<Option<JobHandle>> {
self.with(|state| Ok(state.current().cloned()))
}
/// Capture failures live in the controller; worker failures live in its job.
pub fn error(&self) -> Result<Option<String>> {
self.with(|state| {
if let Some(error) = state.error() {
return Ok(Some(error.to_string()));
}
let status = state.current().map(JobHandle::status).transpose()?;
Ok(status.and_then(|status| status.error).map(|error| error.to_string()))
})
}
pub fn capture_failed(&self, ticket: CaptureTicket, message: String) -> Result<()> {
self.with(|state| {
state.capture_failed(ticket, JobError::new(ErrorCode::InvalidScene, message));
Ok(())
})
}
fn reject_snapshot(&self, ticket: CaptureTicket, error: JobError) -> Result<Snapshot> {
self.with(|state| {
state.capture_failed(ticket, error.clone());
Ok(())
})?;
Err(job_error(error))
}
/// Freeze one complete UI intent and document. The caller supplies the same
/// immutable numerical intent that produced this visual revision/ticket.
/// A name, CAD revision or reused Value alone is not visual provenance.
pub fn snapshot(&self, session: &Session, ticket: CaptureTicket, params: &Value) -> Result<Snapshot> {
if !self.is_current(session) || ticket.job_id().session != self.preview_session || ticket.revision().scene_generation != session.revision {
return self.reject_snapshot(
ticket,
JobError::new(ErrorCode::WrongOwner, "Interactive capture belongs to another document or service revision"),
);
}
if session.model.is_stale() {
return self.reject_snapshot(ticket, JobError::new(ErrorCode::InvalidScene, "finish the modelling gesture before Interactive capture"));
}
if !self.with(|state| Ok(state.accepts_capture(ticket)))? {
return self.reject_snapshot(ticket, JobError::new(ErrorCode::WrongOwner, "Interactive capture ticket is no longer current"));
}
Ok(Snapshot {
session: session.scratch(),
params: params.clone(),
ticket,
controller: self.weak(),
scope: solidcraft_io::vfs::current_scope(),
})
}
}
impl Drop for InteractiveRender {
fn drop(&mut self) {
let _ = self.disable();
}
}
/// Immutable capture envelope; no engine worker or queue is created here.
/// The caller maintains one app-wide capture worker, including across tab swaps.
pub struct Snapshot {
session: Session,
params: Value,
ticket: CaptureTicket,
controller: Weak<Controller>,
scope: Option<solidcraft_io::vfs::PathScope>,
}
/// Internal output of the ordinary authoritative CPU preparation boundary.
pub(crate) struct Prepared {
pub(crate) scene: solidcraft_render::ProductScene,
pub(crate) request: job::ImageRequest,
pub(crate) options: job::JobOptions,
pub(crate) environment: Option<solidcraft_render::lighting::environment_input::EnvironmentInput>,
}
/// This guard only receives a record just created by this capture, never a
/// caller's retained/manual/foreign handle. Dropping a JobHandle alone is not a
/// cancellation request: native queue/running work keeps its own clone.
struct Unadopted {
job: JobHandle,
adopted: bool,
}
impl Drop for Unadopted {
fn drop(&mut self) {
if !self.adopted {
let _ = self.job.cancel();
}
}
}
/// Adopt a newly submitted local record. Even an equal-ID duplicate is cancelled
/// when rejected: it is a different new record, not a copy of the accepted job.
pub(crate) fn adopt_created(controller: &Controller, ticket: CaptureTicket, job: JobHandle) -> job::Result<bool> {
if job.id().session != ticket.job_id().session {
return Err(JobError::new(ErrorCode::WrongOwner, "created Interactive record has another identity"));
}
let mut pending = Unadopted { job, adopted: false };
let accepted = with_controller(controller, |state| state.attach(ticket, pending.job.clone()))?;
pending.adopted = accepted;
Ok(accepted)
}
impl Snapshot {
/// Correlate the caller's receiver and discard old errors after tab changes.
pub fn ticket(&self) -> CaptureTicket {
self.ticket
}
pub fn document_uid(&self) -> u64 {
self.session.uid
}
/// Invoke beside the window, never while holding a lifecycle lock. Both
/// preparation and admission may be long; edits/finals can invalidate either.
pub fn run(mut self) -> Result<bool> {
let Some(controller) = self.controller.upgrade() else { return Ok(false) };
if !with_controller(&controller, |state| Ok(state.accepts_capture(self.ticket))).map_err(job_error)? {
return Ok(false);
}
let result = (|| {
let prepared = solidcraft_io::vfs::scoped(self.scope.take(), || crate::cmd::prepare_interactive(&self.session, &self.params))?;
if !with_controller(&controller, |state| Ok(state.accepts_capture(self.ticket))).map_err(job_error)? {
return Ok(false);
}
let host = self
.session
.render_job_host()
.ok_or_else(|| job_error(JobError::new(ErrorCode::HostUnavailable, "CPU image host is unavailable")))?;
let job = host
.submit_cpu_protected_partial_with_environment_and_options(
self.ticket.job_id(),
self.ticket.revision().scene_generation,
prepared.scene,
prepared.request,
prepared.options,
prepared.environment,
)
.map_err(job_error)?;
adopt_created(&controller, self.ticket, job).map_err(job_error)
})();
if let Err(error) = &result {
let _ = with_controller(&controller, |state| {
state.capture_failed(self.ticket, JobError::new(ErrorCode::InvalidScene, error.to_string()));
Ok(())
});
}
result
}
}
/// Opaque visual-source stamp: no placed world, mesh, strong document or B-rep
/// ownership. Construct only after the previous stamp stops matching; retain it
/// and call is_current on each frame instead of cloning session metadata again.
pub struct SceneIdentity {
uid: u64,
revision: u64,
document: Weak<solidcraft_doc::Document>,
model: Weak<solidcraft_doc::ModelState>,
pending_moves: std::collections::BTreeMap<u64, solidcraft_doc::Mat>,
active_sketch: Option<u64>,
active_component: u64,
active_occurrence_path: Option<Vec<u64>>,
visibility: solidcraft_doc::Visibility,
}
impl SceneIdentity {
/// Exact metadata comparison. model.state only clones the existing evaluated
/// Arc; it neither evaluates nor places bodies nor obtains a display mesh.
/// Weak allocation identities cannot alias a later allocation while retained.
pub fn is_current(&self, session: &Session) -> bool {
self.uid == session.uid
&& self.revision == session.revision
&& Weak::ptr_eq(&self.document, &Arc::downgrade(&session.doc))
&& Weak::ptr_eq(&self.model, &Arc::downgrade(&session.model.state()))
&& self.pending_moves == session.pending_moves
&& self.active_sketch == session.active_sketch
&& self.active_component == session.active_component
&& self.active_occurrence_path == session.active_occurrence_path
&& self.visibility == session.visibility
}
}
impl Session {
/// Source identity without computing cold assembly placements or meshes.
/// Stale modelling gestures must still be rejected separately before capture.
/// View camera/draft/output edits belong to the caller's visual intent stamp.
pub fn interactive_scene_identity(&self) -> SceneIdentity {
SceneIdentity {
uid: self.uid,
revision: self.revision,
document: Arc::downgrade(&self.doc),
model: Arc::downgrade(&self.model.state()),
pending_moves: self.pending_moves.clone(),
active_sketch: self.active_sketch,
active_component: self.active_component,
active_occurrence_path: self.active_occurrence_path.clone(),
visibility: self.visibility.clone(),
}
}
}
impl Session {
/// Existing prepared world only, for Interactive reflection-binding proof.
/// None is not a capture-availability decision: SceneIdentity remains valid
/// for cold assemblies and the capture worker prepares their real world.
pub fn interactive_cached_world_state(&self) -> Option<Arc<solidcraft_doc::ModelState>> {
self.cached_world_state()
}
}
// Exact identity checks must not prepare cold geometry on the window thread.
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod scene_identity_tests {
use super::*;
use serde_json::json;
#[test]
fn cold_assembly_stamp_neither_places_nor_meshes() {
let mut session = Session::default();
session.execute("solid.box", &json!({"length":10,"width":20,"height":5,"body_name":"Source"})).unwrap();
// A root body plus an empty placed component is a valid cold assembly.
let doc = session.doc_mut();
doc.components.push(solidcraft_doc::Component { id: 100, name: "Empty component".into(), ..Default::default() });
doc.occurrences.push(solidcraft_doc::Occurrence {
id: 101,
component: 100,
parent: 0,
name: "Empty placement".into(),
transform: solidcraft_doc::IDENTITY,
grounded: false,
});
assert!(session.world_cache.lock().unwrap().is_none());
let state = session.model.state();
assert!(state.body("Source").unwrap().cached_mesh().is_none());
let stamp = session.interactive_scene_identity();
for _ in 0..10 {
assert!(stamp.is_current(&session));
}
assert!(session.world_cache.lock().unwrap().is_none());
assert!(state.body("Source").unwrap().cached_mesh().is_none());
}
#[test]
fn exact_context_and_visibility_changes_invalidate_without_revision() {
let mut session = Session::default();
let revision = session.revision;
let stamp = session.interactive_scene_identity();
session.pending_moves.insert(7, solidcraft_doc::IDENTITY);
assert!(!stamp.is_current(&session));
session.pending_moves.clear();
assert!(stamp.is_current(&session));
session.active_component = 7;
assert!(!stamp.is_current(&session));
session.active_component = 0;
session.active_occurrence_path = Some(vec![7, 8]);
assert!(!stamp.is_current(&session));
session.active_occurrence_path = None;
session.active_sketch = Some(9);
assert!(!stamp.is_current(&session));
session.active_sketch = None;
session.visibility.hidden_occurrences.push(vec![7, 8]);
assert!(!stamp.is_current(&session));
session.visibility.hidden_occurrences.clear();
session.visibility.hidden_folders.push("c0/bodies".into());
assert!(!stamp.is_current(&session));
assert_eq!(session.revision, revision);
}
#[test]
fn stamp_has_no_strong_document_or_model_retention() {
let session = Session::default();
let model = session.model.state();
let doc_count = Arc::strong_count(&session.doc);
let model_count = Arc::strong_count(&model);
let doc = Arc::downgrade(&session.doc);
let weak_model = Arc::downgrade(&model);
let stamp = session.interactive_scene_identity();
assert_eq!(Arc::strong_count(&session.doc), doc_count);
assert_eq!(Arc::strong_count(&model), model_count);
assert!(stamp.is_current(&session));
drop(model);
drop(session);
assert!(doc.upgrade().is_none());
assert!(weak_model.upgrade().is_none());
// Retained Weak allocation identities cannot be recycled for a new object.
let replacement = Session { uid: stamp.uid, revision: stamp.revision, ..Default::default() };
assert!(!stamp.is_current(&replacement));
}
#[test]
fn same_document_and_revision_do_not_hide_a_replaced_evaluated_model() {
let mut session = Session::default();
let stamp = session.interactive_scene_identity();
session.model = Session::default().model;
assert!(!stamp.is_current(&session));
let refreshed = session.interactive_scene_identity();
assert!(refreshed.is_current(&session));
session.revision += 1;
assert!(!refreshed.is_current(&session));
}
}
+489 -9
View File
@@ -1,13 +1,14 @@
//! Per-document, transient render ownership. Captured scenes remain immutable when CAD edits
//! continue; callers get the captured revision and must never present it as a newer scene.
use crate::render_interactive::{Controller, InteractiveRender, with_controller};
use solidcraft_render::{
ExecutorDescriptor,
job::{CpuHost, ErrorCode, HostKind, Integrator, JobError, JobHandle, JobId, JobStatus, Phase, RenderFrame},
};
use std::collections::VecDeque;
use std::sync::{
Arc,
Arc, Weak,
atomic::{AtomicU64, Ordering},
};
@@ -51,6 +52,8 @@ pub(crate) struct Jobs {
/// One explicitly displayed completed result, independent of a newer completion.
/// The existing frame Arc keeps its original host reservation accounted for.
pinned: Option<JobHandle>,
/// Preview lifetime only; it never joins final live/history/export ownership.
interactive: Weak<Controller>,
}
impl Default for Jobs {
@@ -66,12 +69,14 @@ impl Default for Jobs {
history: VecDeque::new(),
last_good: None,
pinned: None,
interactive: Weak::new(),
}
}
}
impl Drop for Jobs {
fn drop(&mut self) {
let _ = self.revoke_interactive();
for job in &self.live {
let _ = job.cancel();
}
@@ -79,6 +84,56 @@ impl Drop for Jobs {
}
impl Jobs {
pub(crate) fn is_interactive_current(&self, controller: &Weak<Controller>) -> bool {
Weak::ptr_eq(&self.interactive, controller)
}
fn revoke_interactive(&mut self) -> Result<(), JobError> {
let previous = std::mem::take(&mut self.interactive);
match previous.upgrade() {
Some(controller) => with_controller(&controller, |state| state.disable()),
None => Ok(()),
}
}
fn suspend_interactive(&self) -> Result<(), JobError> {
match self.interactive.upgrade() {
Some(controller) => with_controller(&controller, |state| state.set_final_active(true, std::time::Duration::ZERO)),
None => Ok(()),
}
}
fn final_active(&self) -> Result<bool, JobError> {
for job in &self.live {
if !job.status()?.progress.phase.terminal() {
return Ok(true);
}
}
Ok(false)
}
fn create_interactive(&mut self, document_uid: u64) -> Result<InteractiveRender, JobError> {
let host = self.host.as_ref().ok_or_else(|| JobError::new(ErrorCode::HostUnavailable, "no CPU image host is installed"))?;
if !host.is_available() || host.kind() != HostKind::NativeWorker {
return Err(JobError::new(ErrorCode::HostUnavailable, "Interactive CPU requires an available native CPU host"));
}
host.validate_integrator(Integrator::Raytrace)?;
let final_active = self.final_active()?;
if document_uid == 0 || self.owner == 0 {
return Err(JobError::new(ErrorCode::LimitExceeded, "render session identity exhausted"));
}
// Use the same allocator as final owners; never reuse the document uid or
// scratch owner as a preview namespace. Zero reports allocator exhaustion.
let preview_session = owner();
if preview_session == 0 {
return Err(JobError::new(ErrorCode::LimitExceeded, "Interactive owner identity exhausted"));
}
let next = InteractiveRender::new(document_uid, preview_session, final_active)?;
self.revoke_interactive()?;
self.interactive = next.weak();
Ok(next)
}
pub(crate) fn host_for(&self, executor: &solidcraft_render::ExecutorId) -> Option<&CpuHost> {
if executor.as_str() == "cpu-raytrace" {
return self
@@ -128,6 +183,23 @@ impl Jobs {
replace: bool,
execution: Execution,
) -> Result<JobStatus, JobError> {
self.start_with_environment_and_feedback(revision, scene, request, replace, execution, false)
}
pub(crate) fn start_with_environment_and_feedback(
&mut self,
revision: u64,
scene: solidcraft_render::ProductScene,
request: solidcraft_render::job::ImageRequest,
replace: bool,
execution: Execution,
progressive_feedback: bool,
) -> Result<JobStatus, JobError> {
if progressive_feedback
&& (request.integrator != Integrator::Raytrace || request.raytrace.as_ref().and_then(|options| options.progressive).is_none())
{
return Err(JobError::new(ErrorCode::InvalidRequest, "progressive_feedback requires CPU progressive raytracing"));
}
let Execution { executor, options, environment } = execution;
self.collect()?;
let host = self.host_for(&executor).ok_or_else(|| JobError::new(ErrorCode::HostUnavailable, "resolved image host is not installed"))?.clone();
@@ -154,14 +226,13 @@ impl Jobs {
}
let serial = self.serial.checked_add(1).ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "render job identity exhausted"))?;
let generation = self.generation.checked_add(1).ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "render generation exhausted"))?;
let handle = host.submit_with_environment_and_options(
JobId { session: self.owner, serial, generation },
revision,
scene,
request,
options,
environment,
)?;
self.suspend_interactive()?;
let id = JobId { session: self.owner, serial, generation };
let handle = if progressive_feedback {
host.submit_cpu_protected_partial_with_environment_and_options(id, revision, scene, request, options, environment)?
} else {
host.submit_with_environment_and_options(id, revision, scene, request, options, environment)?
};
let status = handle.status()?;
self.serial = serial;
self.generation = generation;
@@ -174,6 +245,7 @@ impl Jobs {
source_id: JobId,
presentation: solidcraft_render::job::presentation::Presentation,
) -> Result<JobStatus, JobError> {
presentation.validate()?;
self.collect()?;
let source = self.handle(source_id)?;
let frame = source.frame()?.ok_or_else(|| JobError::new(ErrorCode::NotCompleted, "regrade requires a completed frame"))?;
@@ -190,6 +262,10 @@ impl Jobs {
}
let serial = self.serial.checked_add(1).ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "render job identity exhausted"))?;
let generation = self.generation.checked_add(1).ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "render generation exhausted"))?;
if self.owner == 0 {
return Err(JobError::new(ErrorCode::LimitExceeded, "render session identity exhausted"));
}
self.suspend_interactive()?;
let handle = host.submit_regrade(JobId { session: self.owner, serial, generation }, &source, presentation)?;
let status = handle.status()?;
self.serial = serial;
@@ -244,6 +320,18 @@ impl Jobs {
}
impl crate::Session {
/// Explicit activation support. The caller owns one app-wide capture worker.
/// This registers a lifetime only; it never captures or starts an image.
pub fn interactive_render(&mut self) -> crate::Result<InteractiveRender> {
self.render_jobs.create_interactive(self.uid).map_err(job_error)
}
/// Bounded final-job metadata for current/background document scheduling.
/// Retained completed images do not block Interactive; unknown status fails.
pub fn render_final_active(&self) -> crate::Result<bool> {
self.render_jobs.final_active().map_err(job_error)
}
/// A host is installed by the application, never by document data or a command.
pub fn set_render_job_host(&mut self, host: Option<CpuHost>) -> crate::Result<()> {
if self.render_jobs.host.as_ref().zip(host.as_ref()).is_some_and(|(a, b)| a.same_host(b)) {
@@ -378,3 +466,395 @@ mod displayed_result_tests {
assert!(s.execute("render.result.pin", &json!({"job":reply["job"]})).is_err(), "a pending result is never exportable");
}
}
// Original context ownership and real capture integration cases.
#[cfg(all(test, feature = "raytrace-cpu", not(target_arch = "wasm32")))]
mod interactive_context_tests {
use super::*;
use crate::render_interactive::{InteractiveRender, Snapshot, adopt_created};
use serde_json::{Value, json};
use solidcraft_render::job::interactive::{State, VisualRevision};
use std::time::Duration;
fn native() -> crate::Result<(crate::Session, CpuHost)> {
let host = CpuHost::for_platform().map_err(job_error)?;
let mut session = crate::Session::default();
session.set_render_job_host(Some(host.clone()))?;
Ok((session, host))
}
fn wire(background: [u8; 3]) -> Value {
json!({"integrator":"raytrace","settings":{"width":16,"height":16,"background":background},"raytrace":{"lights":[],"shadows":false}})
}
fn capture(context: &InteractiveRender, session: &crate::Session, request: &Value) -> crate::Result<Snapshot> {
context.enable(VisualRevision { scene_generation: session.revision, visual_revision: 1 }, Duration::ZERO)?;
let ticket = context
.capture(Duration::from_millis(200))?
.ok_or_else(|| crate::EngineError::Other("interactive fixture did not produce a capture ticket".into()))?;
context.snapshot(session, ticket, request)
}
fn terminal(job: &JobHandle) -> crate::Result<Phase> {
// Completion watchdog follows existing native job tests; no speed assertion.
Ok(job.wait_timeout(Duration::from_secs(60)).map_err(job_error)?.progress.phase)
}
fn created(session: &crate::Session, ticket: solidcraft_render::job::interactive::CaptureTicket, request: &Value) -> crate::Result<JobHandle> {
let p = crate::cmd::prepare_interactive(session, request)?;
session
.render_job_host()
.ok_or_else(|| crate::EngineError::Other("interactive fixture has no render job host".into()))?
.submit_with_environment_and_options(ticket.job_id(), ticket.revision().scene_generation, p.scene, p.request, p.options, p.environment)
.map_err(job_error)
}
#[test]
fn registration_is_explicit_and_preview_frames_never_join_final_registry() {
let (mut session, _) = native().unwrap();
let document = session.doc.clone();
let history = (session.revision, session.undo.len(), session.redo.len());
let context = session.interactive_render().unwrap();
assert_eq!(context.state().unwrap(), State::Off);
assert!(context.is_current(&session));
assert!(context.capture(Duration::from_secs(1)).unwrap().is_none());
let snapshot = capture(&context, &session, &wire([31, 67, 101])).unwrap();
let ticket = snapshot.ticket();
assert_ne!(ticket.job_id().session, session.render_jobs.owner);
assert_eq!(snapshot.document_uid(), session.uid);
assert!(snapshot.run().unwrap());
let preview = context.current().unwrap().unwrap();
assert_eq!(terminal(&preview).unwrap(), Phase::Completed);
assert!(preview.frame().unwrap().is_some());
assert!(session.render_job_handle(preview.id()).is_err());
assert!(session.execute("render.status", &json!({"job":preview.id()})).is_err());
assert!(session.render_jobs.live.is_empty());
assert!(session.render_jobs.last_good.is_none());
assert!(session.render_jobs.pinned.is_none());
assert_eq!(history, (session.revision, session.undo.len(), session.redo.len()));
assert!(Arc::ptr_eq(&document, &session.doc));
}
#[test]
fn direct_final_start_preempts_only_interactive_and_finishes() {
let (mut session, _) = native().unwrap();
let context = session.interactive_render().unwrap();
assert!(capture(&context, &session, &wire([11, 29, 47])).unwrap().run().unwrap());
let old = context.current().unwrap().unwrap();
let reply = session.execute("render.start", &wire([83, 107, 139])).unwrap();
let final_id: JobId = serde_json::from_value(reply["job"].clone()).unwrap();
let final_job = session.render_job_handle(final_id).unwrap();
assert_ne!(final_id.session, old.id().session);
assert_eq!(context.state().unwrap(), State::Paused);
assert!(context.current().unwrap().is_none());
assert!(context.capture(Duration::from_secs(100)).unwrap().is_none());
assert_eq!(terminal(&final_job).unwrap(), Phase::Completed);
assert!(matches!(terminal(&old).unwrap(), Phase::Cancelled | Phase::Completed));
assert!(!session.render_final_active().unwrap());
let digest = final_job.frame().unwrap().unwrap().info.png_sha256.clone();
context.disable().unwrap();
drop(context);
assert_eq!(session.render_job_handle(final_id).unwrap().frame().unwrap().unwrap().info.png_sha256, digest);
}
#[test]
fn replacement_context_revokes_old_capture_without_meshing() {
let (mut session, host) = native().unwrap();
session.execute("solid.box", &json!({"length":10,"width":20,"height":5,"body_name":"Source"})).unwrap();
let world = session.world_state();
assert!(world.body("Source").unwrap().cached_mesh().is_none());
let old = session.interactive_render().unwrap();
let snapshot = capture(&old, &session, &wire([1, 2, 3])).unwrap();
let old_owner = snapshot.ticket().job_id().session;
let fresh = session.interactive_render().unwrap();
assert!(!old.is_current(&session));
assert_eq!(old.state().unwrap(), State::Off);
assert_eq!(fresh.state().unwrap(), State::Off);
assert!(!snapshot.run().unwrap());
assert!(world.body("Source").unwrap().cached_mesh().is_none());
assert_eq!(host.admitted_bytes(), 0);
let next = capture(&fresh, &session, &wire([4, 5, 6])).unwrap();
assert_ne!(next.ticket().job_id().session, old_owner);
}
#[test]
fn host_a_b_a_cannot_revive_old_registration_in_the_same_document() {
let (mut session, host_a) = native().unwrap();
let uid = session.uid;
let old = session.interactive_render().unwrap();
let snapshot = capture(&old, &session, &wire([1, 2, 3])).unwrap();
let host_b = CpuHost::for_platform().unwrap();
session.set_render_job_host(Some(host_b)).unwrap();
assert_eq!(session.uid, uid);
assert!(!old.is_current(&session));
assert_eq!(old.state().unwrap(), State::Off);
assert!(!snapshot.run().unwrap());
session.set_render_job_host(Some(host_a.clone())).unwrap();
assert!(!old.is_current(&session));
old.enable(VisualRevision { scene_generation: session.revision, visual_revision: 2 }, Duration::ZERO).unwrap();
let stale = old.capture(Duration::from_millis(200)).unwrap().unwrap();
assert!(old.snapshot(&session, stale, &wire([7, 8, 9])).is_err());
assert_eq!(host_a.admitted_bytes(), 0);
assert!(session.interactive_render().unwrap().is_current(&session));
}
#[test]
fn new_design_and_equal_revision_foreign_session_refuse_old_tickets() {
let (mut session, host) = native().unwrap();
let context = session.interactive_render().unwrap();
let snapshot = capture(&context, &session, &wire([1, 2, 3])).unwrap();
let ticket = snapshot.ticket();
let uid = session.uid;
let mut foreign = crate::Session::new((*session.doc).clone());
foreign.revision = session.revision;
foreign.set_render_job_host(Some(host.clone())).unwrap();
assert_ne!(foreign.uid, uid);
assert!(!context.is_current(&foreign));
assert!(context.snapshot(&foreign, ticket, &wire([4, 5, 6])).is_err());
session.execute("file.new", &json!({})).unwrap();
assert_ne!(session.uid, uid);
assert!(!context.is_current(&session));
assert_eq!(context.state().unwrap(), State::Off);
assert!(!snapshot.run().unwrap());
assert_eq!(host.admitted_bytes(), 0);
}
#[test]
fn scratch_drop_does_not_revoke_source_and_exhaustion_is_local() {
let (mut session, _) = native().unwrap();
let context = session.interactive_render().unwrap();
drop(session.scratch());
assert!(context.is_current(&session));
assert_eq!(context.state().unwrap(), State::Off);
assert!(InteractiveRender::new(session.uid, 0, false).is_err());
let original_owner = session.render_jobs.owner;
session.render_jobs.owner = 0;
assert!(session.interactive_render().is_err());
assert!(context.is_current(&session));
session.render_jobs.owner = original_owner;
}
#[test]
fn failed_adoption_with_poisoned_controller_cancels_unpumped_cooperative_record() {
let (mut session, _) = native().unwrap();
let context = session.interactive_render().unwrap();
let snapshot = capture(&context, &session, &wire([1, 2, 3])).unwrap();
let ticket = snapshot.ticket();
let p = crate::cmd::prepare_interactive(&session, &wire([1, 2, 3])).unwrap();
let mut request = p.request;
request.integrator = Integrator::Raster;
request.raytrace = None;
// A real cooperative queue cannot complete without an explicit pump.
// Adoption checks owner, then the poisoned lifecycle before integrator.
let cooperative = CpuHost::cooperative();
let job = cooperative.submit(ticket.job_id(), ticket.revision().scene_generation, p.scene, request).unwrap();
assert_eq!(job.status().unwrap().progress.phase, Phase::Queued);
let controller = session.render_jobs.interactive.upgrade().unwrap();
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _guard = controller.lock().unwrap();
panic!("original poison fixture");
}));
assert!(adopt_created(&controller, ticket, job.clone()).is_err());
assert_eq!(job.status().unwrap().progress.phase, Phase::Cancelled);
assert!(context.current().is_err(), "a poisoned lifecycle is not resumed");
// No pump, sleeps, synthetic executor or large scene is needed. Removing
// Unadopted::drop cancellation leaves Queued and fails this assertion.
// Only controller poison is tested; record.data poison is best-effort.
}
#[test]
fn rejected_fresh_equal_id_record_preserves_retained_completed_frame() {
let (mut session, _) = native().unwrap();
let context = session.interactive_render().unwrap();
let snapshot = capture(&context, &session, &wire([17, 31, 59])).unwrap();
let ticket = snapshot.ticket();
assert!(snapshot.run().unwrap());
let accepted = context.current().unwrap().unwrap();
assert_eq!(terminal(&accepted).unwrap(), Phase::Completed);
let frame = accepted.frame().unwrap().unwrap();
let digest = frame.info.png_sha256.clone();
let duplicate = created(&session, ticket, &wire([83, 107, 139])).unwrap();
assert_eq!(duplicate.id(), accepted.id());
let controller = session.render_jobs.interactive.upgrade().unwrap();
assert!(!adopt_created(&controller, ticket, duplicate.clone()).unwrap());
let phase = duplicate.status().unwrap().progress.phase;
assert!(phase.terminal() || phase == Phase::Cancelling);
assert!(matches!(terminal(&duplicate).unwrap(), Phase::Cancelled | Phase::Completed));
let retained = context.current().unwrap().unwrap().frame().unwrap().unwrap();
assert!(Arc::ptr_eq(&retained, &frame));
assert_eq!(retained.info.png_sha256, digest);
// This native fixture proves accepted-record retention only. Completed
// duplicate work does not prove a cancellation request; core queued
// distinct-record acceptance covers that separate invariant.
assert_eq!(accepted.status().unwrap().request.background, [17, 31, 59]);
}
#[test]
fn dropping_context_before_worker_start_keeps_the_source_mesh_cold() {
let (mut session, host) = native().unwrap();
session.execute("solid.box", &json!({"length":10,"width":20,"height":5,"body_name":"Source"})).unwrap();
let world = session.world_state();
assert!(world.body("Source").unwrap().cached_mesh().is_none());
let context = session.interactive_render().unwrap();
let snapshot = capture(&context, &session, &wire([1, 2, 3])).unwrap();
drop(context);
assert!(!snapshot.run().unwrap());
assert!(world.body("Source").unwrap().cached_mesh().is_none());
assert_eq!(host.admitted_bytes(), 0);
}
#[test]
fn final_cpu_feedback_preserves_full_request_owner_and_exact_final_pixels() {
let (mut session, host) = native().unwrap();
assert_eq!(host.kind(), HostKind::NativeWorker);
let before = session.doc.to_json();
let history = (session.revision, session.undo.len(), session.redo.len());
let request = json!({
"integrator":"raytrace", "settings":{"width":13,"height":7,"background":[17,89,203]},
"raytrace":{"lights":[],"shadows":false,"primary_samples":1,"progressive":{"samples_per_pixel":9,"seed":83}}
});
let mut baseline: Option<Vec<u8>> = None;
for feedback in [false, true] {
let mut params = request.clone();
params["progressive_feedback"] = json!(feedback);
let checked = session.execute("render.preflight", &params).unwrap();
if feedback {
let captured: solidcraft_render::job::ImageRequest = serde_json::from_value(checked["request"].clone()).unwrap();
let scene = solidcraft_render::ProductScene { meshes: Vec::new(), bounds: solidcraft_geom::Aabb3::EMPTY };
let admitted = host
.admit_cpu_protected_partial_with_environment_and_options(&scene, &captured, solidcraft_render::job::JobOptions::default(), None)
.unwrap();
assert_eq!(checked["admission"], serde_json::to_value(admitted).unwrap(), "Check uses the same authoritative protected admission");
}
let started = session.execute("render.start", &params).unwrap();
let job: JobId = serde_json::from_value(started["job"].clone()).unwrap();
assert_eq!(job.session, session.render_jobs.owner);
let handle = session.render_job_handle(job).unwrap();
assert_eq!(serde_json::to_value(handle.status().unwrap().request).unwrap(), checked["request"]);
assert_eq!(terminal(&handle).unwrap(), Phase::Completed);
let status = handle.status().unwrap();
assert_eq!((status.request.width, status.request.height), (13, 7));
let options = status.request.raytrace.as_ref().unwrap().progressive.unwrap();
assert_eq!((options.samples_per_pixel, options.seed), (9, 83));
let frame = handle.frame().unwrap().unwrap();
let image = image::load_from_memory(frame.png()).unwrap().to_rgba8();
assert_eq!(image.dimensions(), (13, 7));
assert!(image.pixels().all(|pixel| pixel.0 == [17, 89, 203, 255]), "independent constant-background miss oracle");
if let Some(bytes) = &baseline {
assert_eq!(frame.png(), bytes.as_slice());
} else {
baseline = Some(frame.png().to_vec());
}
let report = handle.protected_preview_report().unwrap();
assert_eq!(report.is_some(), feedback);
if let Some(report) = report {
assert_eq!(report.quota_limit, 64);
assert!(report.ordinary_used <= report.ordinary_limit);
assert!(report.presentation_used <= report.presentation_limit);
assert_eq!(report.quota_exhausted, report.accepted_snapshots == report.quota_limit);
}
assert!(!handle.request_partial_preview().unwrap(), "terminal demand cannot restart work");
let foreign = JobId { session: job.session + 1, ..job };
assert!(session.render_job_handle(foreign).is_err());
assert!(session.execute("render.result.pin", &json!({"job":foreign})).is_err());
session.execute("render.result.pin", &json!({"job":job})).unwrap();
}
assert_eq!(before, session.doc.to_json());
assert_eq!(history, (session.revision, session.undo.len(), session.redo.len()));
}
#[test]
fn final_cpu_feedback_saved_normalization_and_legacy_omission_are_explicit() {
let (mut session, _) = native().unwrap();
let typed = json!({"settings":{"width":13,"height":7,"background":[17,89,203],
"job":{"method":"cpu_raytrace","options":{"primary_samples":1,"lights":[],"shadows":false,
"progressive":{"samples_per_pixel":9,"seed":83}}}}});
for feedback in [None, Some(false), Some(true)] {
let mut params = typed.clone();
if let Some(feedback) = feedback {
params["progressive_feedback"] = json!(feedback);
}
let checked = session.execute("render.preflight", &params).unwrap();
let started = session.execute("render.start", &params).unwrap();
let job: JobId = serde_json::from_value(started["job"].clone()).unwrap();
let handle = session.render_job_handle(job).unwrap();
assert_eq!(terminal(&handle).unwrap(), Phase::Completed);
assert_eq!(serde_json::to_value(handle.status().unwrap().request).unwrap(), checked["request"]);
assert_eq!(handle.protected_preview_report().unwrap().is_some(), feedback == Some(true));
}
for mut invalid in
[json!({"settings":{"width":13,"height":7}}), wire([17, 89, 203]), json!({"progressive":true,"settings":{"width":13,"height":7}})]
{
invalid["progressive_feedback"] = json!(true);
for command in ["render.preflight", "render.start"] {
let error = session.execute(command, &invalid).unwrap_err().to_string();
assert!(error.contains("progressive_feedback"), "{error}");
}
}
let mut malformed = typed;
malformed["progressive_feedback"] = json!("true");
assert!(session.execute("render.preflight", &malformed).is_err());
}
#[test]
fn final_cpu_protected_admission_accounts_captured_hdr_once_and_runs_real_host() {
use solidcraft_render::job::{CpuProgressiveOptions, ImageRequest, JobOptions, RaytraceOptions};
let (session, host) = native().unwrap();
let request = ImageRequest {
raster_punctual_lights: None,
raster_area_lights: None,
raster_exposure_ev: 0.0,
raster_lights: None,
integrator: Integrator::Raytrace,
width: 13,
height: 7,
background: [17, 89, 203],
raytrace: Some(RaytraceOptions {
lights: Some(Vec::new()),
shadows: false,
primary_samples: 1,
progressive: Some(CpuProgressiveOptions { samples_per_pixel: 9, seed: 83 }),
..Default::default()
}),
studio_weights: None,
environment: None,
textures: None,
camera: Default::default(),
transparent_background: false,
};
let scene = solidcraft_render::ProductScene { meshes: Vec::new(), bounds: solidcraft_geom::Aabb3::EMPTY };
// Original equirectangular 2x1 RGBE source: two distinct texels, eight payload bytes.
let mut bytes = b"#?RADIANCE\nFORMAT=32-bit_rle_rgbe\nPRIMARIES=0.64 0.33 0.30 0.60 0.15 0.06 0.3127 0.3290\n\n-Y 1 +X 2\n".to_vec();
bytes.extend_from_slice(&[96, 64, 32, 129, 32, 64, 96, 129]);
let encoded_capacity = bytes.capacity() as u64;
// Independent allocation contract: 2 RGB f32 texels = 24 bytes, one
// 2-pixel RGBE scanline = 8 bytes, plus the fixed 8192-byte task envelope.
let expected_hdr_bytes = encoded_capacity + 24 + 8 + 8192;
let source = solidcraft_render::lighting::HdrSource::new(bytes).unwrap();
assert_eq!((source.info().width, source.info().height), (2, 1));
assert_eq!(source.retained_encoded_bytes(), encoded_capacity);
assert_eq!(source.info().decoded_rgb_bytes, 24);
let environment = solidcraft_render::lighting::environment_input::EnvironmentInput::new(source, 0.25, 0.5, 1).unwrap();
let plain = host.admit_cpu_protected_partial_with_environment_and_options(&scene, &request, JobOptions::default(), None).unwrap();
let lit = host.admit_cpu_protected_partial_with_environment_and_options(&scene, &request, JobOptions::default(), Some(&environment)).unwrap();
assert_eq!(environment.required_bytes().unwrap(), expected_hdr_bytes);
assert_eq!(lit.preparation_bytes - plain.preparation_bytes, expected_hdr_bytes);
assert_eq!(
lit.estimated_total_bytes - plain.estimated_total_bytes,
expected_hdr_bytes + std::mem::size_of::<solidcraft_render::lighting::environment_input::EnvironmentInfo>() as u64
);
let job = host
.submit_cpu_protected_partial_with_environment_and_options(
JobId { session: session.render_jobs.owner, serial: 1, generation: 1 },
session.revision,
scene,
request,
JobOptions::default(),
Some(environment),
)
.unwrap();
assert_eq!(terminal(&job).unwrap(), Phase::Completed);
assert!(job.status().unwrap().environment.is_some());
assert!(job.protected_preview_report().unwrap().is_some());
assert_eq!(job.frame().unwrap().unwrap().info.width, 13);
}
}
+73 -4
View File
@@ -238,9 +238,66 @@ pub fn patch(light: &mut Light, edit: &crate::schema::render_lights::LightPatch,
Ok(())
}
/// Refuse controls that cannot affect the Raster directional transport.
pub fn validate_raster_patch(edit: &crate::schema::render_lights::LightPatch) -> Result<(), String> {
if edit.kind.as_deref().is_some_and(|kind| kind != "directional")
|| edit.softness.is_some()
|| edit.distance_mm.is_some()
|| edit.size_mm.is_some()
|| edit.aim_at.is_some()
|| edit.width_mm.is_some()
|| edit.height_mm.is_some()
|| edit.position_mm.is_some()
|| edit.edge_u_mm.is_some()
|| edit.edge_v_mm.is_some()
|| edit.radiance.is_some()
|| edit.reference_distance_mm.is_some()
|| edit.minimum_distance_mm.is_some()
{
return Err("Raster accepts hard directional colour, intensity and direction edits only".into());
}
if edit.intensity_ev.is_some_and(|ev| !ev.is_finite() || !(-20.0..=6.0).contains(&ev)) {
return Err("Raster source intensity EV must be finite and between -20 and 6".into());
}
if edit.direction.is_some() && (edit.azimuth_deg.is_some() || edit.elevation_deg.is_some())
|| edit.colour.is_some() && edit.temperature_k.is_some()
{
return Err("choose one Raster direction or colour authority per edit".into());
}
Ok(())
}
/// Materializes a canonical rig in a cloned setup. Caller validates and publishes
/// atomically. Unsupported backend families are refused, never silently dropped.
pub fn write(setup: &mut RenderSetup, lights: &[Light]) -> Result<(), String> {
if matches!(&setup.job, Some(RenderJob::Raster { .. })) {
setup.validate().map_err(|e| e.to_string())?;
if lights.len() > 8 {
return Err("at most eight Raster directional lights".into());
}
if lights.iter().any(|light| !matches!(light.geometry, Geometry::Directional { angular_radius_rad: None, .. })) {
return Err("Raster supports hard directional lights only; finite suns, point, spot and area lights are unsupported".into());
}
let mut values = Vec::new();
if !lights.is_empty() {
values.try_reserve_exact(8).map_err(|_| "could not reserve saved Raster lights")?;
}
if values.capacity() > 8 {
return Err("saved Raster allocation exceeds eight slots".into());
}
for light in lights {
let Geometry::Directional { direction, angular_radius_rad: None } = light.geometry else {
return Err("unsupported Raster light geometry".into());
};
values.push(DirectionalLightOptions { direction, color: light.colour, intensity: light.intensity });
}
let values = Some(values);
solidcraft_render::job::validate_raster_lights(&values).map_err(|e| e.to_string())?;
if let Some(RenderJob::Raster { lights, .. }) = &mut setup.job {
*lights = values;
}
return Ok(());
}
if lights.len() > 20 {
return Err("at most twenty analytic lights".into());
}
@@ -343,7 +400,7 @@ pub fn write(setup: &mut RenderSetup, lights: &[Light]) -> Result<(), String> {
lighting.area_lights = AreaLights::new(&areas).map_err(|e| e.to_string())?;
options.lighting = Some(lighting);
}
_ => return Err("choose CPU or GPU ray tracing before editing lights".into()),
_ => return Err("choose a typed Raster, CPU raytrace or GPU path job before editing lights".into()),
}
setup.validate().map_err(|e| e.to_string())
}
@@ -358,7 +415,7 @@ pub fn filter(setup: &mut RenderSetup, visibility: &crate::schema::render_lights
if lights.is_empty() {
return Ok(None);
}
if matches!(setup.job, None | Some(RenderJob::Raster { .. }))
if matches!(&setup.job, None | Some(RenderJob::Raster { lights: None, .. }))
|| matches!(&setup.job,Some(RenderJob::GpuPath{options,..}) if options.lighting.is_none())
{
let mut weights = [0.0; 3];
@@ -457,8 +514,20 @@ pub fn inspect(setup: &RenderSetup) -> Result<Vec<Light>, String> {
defaults(&mut lights);
}
}
// Raster and the legacy setup use the fixed direct-light product preview.
Some(RenderJob::Raster { .. }) | None => defaults(&mut lights),
Some(RenderJob::Raster { lights: Some(values), .. }) => {
for (index, light) in values.iter().enumerate() {
push(
&mut lights,
index,
false,
light.intensity,
light.color,
Geometry::Directional { direction: light.direction, angular_radius_rad: None },
);
}
}
// Only unspecified Raster and legacy setups use the fixed studio.
Some(RenderJob::Raster { lights: None, .. }) | None => defaults(&mut lights),
}
Ok(lights)
}
+18 -1
View File
@@ -1,4 +1,4 @@
//! Clipboard requests for the Render workspace; headless calls only echo validated text.
//! Typed Render workspace UI requests; headless calls only echo validated inputs.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
@@ -9,3 +9,20 @@ pub struct CheckReport {
#[schemars(length(min = 1, max = 65536))]
pub text: String,
}
/// Explicit provisional-preview action; no implicit toggle or default activation.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum Action {
On,
Off,
Pause,
Resume,
}
/// A validated UI action echoed unchanged by the headless command handler.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct InteractiveParams {
pub action: Action,
}
+602 -601
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -157,6 +157,10 @@ fn request(size: u32) -> ImageRequest {
let mut camera = Camera { distance: 4.0, ..Default::default() };
camera.set_view(StandardView::Top);
ImageRequest {
raster_punctual_lights: None,
raster_area_lights: None,
raster_exposure_ev: 0.0,
raster_lights: None,
studio_weights: None,
integrator: Default::default(),
raytrace: None,
+1040 -1031
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -21,6 +21,10 @@ fn request(size: u32) -> ImageRequest {
let mut camera = Camera { distance: 4.0, ..Default::default() };
camera.set_view(StandardView::Top);
ImageRequest {
raster_punctual_lights: None,
raster_area_lights: None,
raster_exposure_ev: 0.0,
raster_lights: None,
studio_weights: None,
integrator: Default::default(),
raytrace: None,
@@ -430,6 +434,10 @@ fn glass_transport_evidence() {
let bounds = meshes.iter().fold(Aabb3::EMPTY, |a, m| a.union(&m.mesh.bounds()));
let camera = Camera { target: Vec3::new(0.0, 0.0, 1.0), yaw: -0.45, pitch: 0.5, distance: 13.0, fov: 0.65, ..Default::default() };
let request = ImageRequest {
raster_punctual_lights: None,
raster_area_lights: None,
raster_exposure_ev: 0.0,
raster_lights: None,
studio_weights: None,
integrator: Default::default(),
raytrace: None,
@@ -3326,3 +3334,22 @@ fn real_device_loss_retires_the_executor_and_the_sessions_cached_availability()
let backend = session.render_backend();
assert!(backend.executors.iter().any(|d| d.id.as_str() == "gpu-path" && matches!(d.availability, Availability::Unavailable { .. })));
}
#[test]
fn direct_gpu_admission_refuses_explicit_raster_rigs_even_when_empty() {
let mut input = request(16);
input.raster_lights = Some(Vec::new());
let error = admission(&sphere(white()), &input, JobOptions::default()).unwrap_err();
assert_eq!(error.code, ErrorCode::InvalidRequest);
assert!(error.message.contains("Raster lights"));
}
#[test]
fn direct_gpu_admission_refuses_raster_only_exposure_without_opening_a_device() {
for ev in [-1.0, 1.0, f64::NAN] {
let mut input = request(16);
input.raster_exposure_ev = ev;
let error = admission(&sphere(white()), &input, JobOptions::default()).unwrap_err();
assert_eq!(error.code, ErrorCode::InvalidRequest);
assert!(error.message.contains("Raster exposure"));
}
}
+93 -92
View File
@@ -1,92 +1,93 @@
//! Original GPU translation of the L1 opaque surface and fixed studio reference.
//! Appended to the existing viewport shader so camera/output conventions are shared.
pub const VERTEX_SIZE: usize = 48;
pub const SOURCE: &str = r#"
struct PbrOut {
@builtin(position) pos: vec4<f32>,
@location(0) n: vec3<f32>,
@location(1) base: vec3<f32>,
@location(2) wp: vec3<f32>,
@location(3) parameters: vec3<f32>,
};
fn pbr_unit(v: vec3<f32>) -> vec3<f32> {
let scale = max(max(abs(v.x), abs(v.y)), abs(v.z));
if (scale == 0.0) { return vec3<f32>(0.0, 0.0, 1.0); }
let s = v / scale;
return s / length(s);
}
fn pbr_encode(c: vec3<f32>) -> vec3<f32> {
return select(c * 12.92, 1.055 * pow(c, vec3<f32>(1.0 / 2.4)) - vec3<f32>(0.055), c > vec3<f32>(0.0031308));
}
@vertex
fn vs_pbr(@location(0) p: vec3<f32>, @location(1) n: vec3<f32>, @location(2) base: vec3<f32>, @location(3) parameters: vec3<f32>) -> PbrOut {
var o: PbrOut;
o.pos = u.vp * vec4<f32>(p, 1.0);
o.n = n;
o.base = base;
o.wp = p;
o.parameters = parameters;
return o;
}
fn pbr_fresnel(f0: vec3<f32>, cosine: f32, metallic: f32, ior: f32) -> vec3<f32> {
let t = 1.0 - cosine;
// Match the shared surface: an index-matched dielectric has no interface.
let grazing = select(1.0, metallic, ior == 1.0);
return f0 + (vec3<f32>(grazing) - f0) * (t * t * t * t * t);
}
fn pbr_brdf(base: vec3<f32>, parameters: vec3<f32>, n: vec3<f32>, v: vec3<f32>, l: vec3<f32>) -> vec3<f32> {
let nv = clamp(dot(n, v), -1.0, 1.0);
let nl = clamp(dot(n, l), -1.0, 1.0);
if (nv <= 0.0 || nl <= 0.0) { return vec3<f32>(0.0); }
let h = pbr_unit(v + l);
let nh = clamp(dot(n, h), 0.0, 1.0);
let vh = clamp(dot(v, h), 0.0, 1.0);
let alpha = parameters.y * parameters.y;
let a2 = alpha * alpha;
let denominator = (1.0 - nh * nh) + nh * nh * a2;
let distribution = a2 / (3.141592653589793 * denominator * denominator);
let gv = 2.0 / (nv + sqrt(a2 + (1.0 - a2) * nv * nv));
let gl = 2.0 / (nl + sqrt(a2 + (1.0 - a2) * nl * nl));
let ratio = (parameters.z - 1.0) / (parameters.z + 1.0);
let f0 = mix(vec3<f32>(ratio * ratio), base, parameters.x);
let specular = pbr_fresnel(f0, vh, parameters.x, parameters.z) * (distribution * 0.25 * gv * gl);
let diffuse = base * ((1.0 - parameters.x) / 3.141592653589793);
return specular + (vec3<f32>(1.0) - pbr_fresnel(f0, nv, parameters.x, parameters.z)) * (vec3<f32>(1.0) - pbr_fresnel(f0, nl, parameters.x, parameters.z)) * diffuse;
}
@fragment
fn fs_pbr(i: PbrOut) -> @location(0) vec4<f32> {
let n = pbr_unit(i.n);
var view = u.back.xyz;
if (u.eye.w > 0.5) { view = u.eye.xyz - i.wp; }
let v = pbr_unit(view);
let lights = array<vec4<f32>, 3>(vec4<f32>(0.6, 0.0, 0.8, 3.0), vec4<f32>(-0.8, 0.0, 0.6, 1.0), vec4<f32>(0.0, -0.6, 0.8, 1.5));
var radiance = vec3<f32>(0.0);
for (var k = 0u; k < 3u && u.product_lights_meta.x == 0.0; k += 1u) {
let light = lights[k];
radiance += pbr_brdf(i.base, i.parameters, n, v, light.xyz) * (light.w * max(dot(n, light.xyz), 0.0));
}
for (var k = 0u; k < min(u32(u.product_lights_meta.y),21u) && u.product_lights_meta.x > 0.0; k += 1u) {
let source=u.product_lights[k*4u];
let colour=u.product_lights[k*4u+1u];
let axis=u.product_lights[k*4u+2u];
let falloff=u.product_lights[k*4u+3u];
if source.w == 4.0 {
radiance += i.base*colour.xyz*(1.0-i.parameters.x)/3.141592653589793;
continue;
}
let sample=product_light_sample(k,i.wp);
radiance += pbr_brdf(i.base,i.parameters,n,v,sample[0])*sample[1]*max(dot(n,sample[0]),0.0);
}
let mapped = radiance / (vec3<f32>(1.0) + radiance);
if (u.back.w > 0.5) { return vec4<f32>(mapped, 1.0); }
return vec4<f32>(pbr_encode(mapped), 1.0);
}
"#;
//! Original GPU translation of the L1 opaque surface and fixed studio reference.
//! Appended to the existing viewport shader so camera/output conventions are shared.
pub const VERTEX_SIZE: usize = 48;
pub const SOURCE: &str = r#"
struct PbrOut {
@builtin(position) pos: vec4<f32>,
@location(0) n: vec3<f32>,
@location(1) base: vec3<f32>,
@location(2) wp: vec3<f32>,
@location(3) parameters: vec3<f32>,
};
fn pbr_unit(v: vec3<f32>) -> vec3<f32> {
let scale = max(max(abs(v.x), abs(v.y)), abs(v.z));
if (scale == 0.0) { return vec3<f32>(0.0, 0.0, 1.0); }
let s = v / scale;
return s / length(s);
}
fn pbr_encode(c: vec3<f32>) -> vec3<f32> {
return select(c * 12.92, 1.055 * pow(c, vec3<f32>(1.0 / 2.4)) - vec3<f32>(0.055), c > vec3<f32>(0.0031308));
}
@vertex
fn vs_pbr(@location(0) p: vec3<f32>, @location(1) n: vec3<f32>, @location(2) base: vec3<f32>, @location(3) parameters: vec3<f32>) -> PbrOut {
var o: PbrOut;
o.pos = u.vp * vec4<f32>(p, 1.0);
o.n = n;
o.base = base;
o.wp = p;
o.parameters = parameters;
return o;
}
fn pbr_fresnel(f0: vec3<f32>, cosine: f32, metallic: f32, ior: f32) -> vec3<f32> {
let t = 1.0 - cosine;
// Match the shared surface: an index-matched dielectric has no interface.
let grazing = select(1.0, metallic, ior == 1.0);
return f0 + (vec3<f32>(grazing) - f0) * (t * t * t * t * t);
}
fn pbr_brdf(base: vec3<f32>, parameters: vec3<f32>, n: vec3<f32>, v: vec3<f32>, l: vec3<f32>) -> vec3<f32> {
let nv = clamp(dot(n, v), -1.0, 1.0);
let nl = clamp(dot(n, l), -1.0, 1.0);
if (nv <= 0.0 || nl <= 0.0) { return vec3<f32>(0.0); }
let h = pbr_unit(v + l);
let nh = clamp(dot(n, h), 0.0, 1.0);
let vh = clamp(dot(v, h), 0.0, 1.0);
let alpha = parameters.y * parameters.y;
let a2 = alpha * alpha;
let denominator = (1.0 - nh * nh) + nh * nh * a2;
let distribution = a2 / (3.141592653589793 * denominator * denominator);
let gv = 2.0 / (nv + sqrt(a2 + (1.0 - a2) * nv * nv));
let gl = 2.0 / (nl + sqrt(a2 + (1.0 - a2) * nl * nl));
let ratio = (parameters.z - 1.0) / (parameters.z + 1.0);
let f0 = mix(vec3<f32>(ratio * ratio), base, parameters.x);
let specular = pbr_fresnel(f0, vh, parameters.x, parameters.z) * (distribution * 0.25 * gv * gl);
let diffuse = base * ((1.0 - parameters.x) / 3.141592653589793);
return specular + (vec3<f32>(1.0) - pbr_fresnel(f0, nv, parameters.x, parameters.z)) * (vec3<f32>(1.0) - pbr_fresnel(f0, nl, parameters.x, parameters.z)) * diffuse;
}
@fragment
fn fs_pbr(i: PbrOut) -> @location(0) vec4<f32> {
let n = pbr_unit(i.n);
var view = u.back.xyz;
if (u.eye.w > 0.5) { view = u.eye.xyz - i.wp; }
let v = pbr_unit(view);
let lights = array<vec4<f32>, 3>(vec4<f32>(0.6, 0.0, 0.8, 3.0), vec4<f32>(-0.8, 0.0, 0.6, 1.0), vec4<f32>(0.0, -0.6, 0.8, 1.5));
var radiance = vec3<f32>(0.0);
for (var k = 0u; k < 3u && u.product_lights_meta.x == 0.0; k += 1u) {
let light = lights[k];
radiance += pbr_brdf(i.base, i.parameters, n, v, light.xyz) * (light.w * max(dot(n, light.xyz), 0.0));
}
for (var k = 0u; k < min(u32(u.product_lights_meta.y),21u) && u.product_lights_meta.x > 0.0; k += 1u) {
let source=u.product_lights[k*4u];
let colour=u.product_lights[k*4u+1u];
let axis=u.product_lights[k*4u+2u];
let falloff=u.product_lights[k*4u+3u];
if source.w == 4.0 {
radiance += i.base*colour.xyz*(1.0-i.parameters.x)/3.141592653589793;
continue;
}
let sample=product_light_sample(k,i.wp);
radiance += pbr_brdf(i.base,i.parameters,n,v,sample[0])*sample[1]*max(dot(n,sample[0]),0.0);
}
if (u.product_lights_meta.z != 0.0) { radiance = product_exposed_radiance(radiance); }
let mapped = radiance / (vec3<f32>(1.0) + radiance);
if (u.back.w > 0.5) { return vec4<f32>(mapped, 1.0); }
return vec4<f32>(pbr_encode(mapped), 1.0);
}
"#;
+286 -2
View File
@@ -957,10 +957,31 @@ pub const AO_SAMPLES: u32 = 32;
/// nearby parts (#4167).
pub const AO_SAMPLES_MOVING: u32 = 8;
/// Validated transient product-preview exposure, never a second document field.
/// RenderSetup::exposure_ev remains the document authority; Design ignores it.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct ProductExposureEv(f32);
impl ProductExposureEv {
pub fn from_ev(ev: f64) -> Option<Self> {
if !ev.is_finite() || !(-32.0..=32.0).contains(&ev) {
return None;
}
let ev = ev as f32;
Some(Self(if ev == 0.0 { 0.0 } else { ev }))
}
pub fn ev(self) -> f32 {
self.0
}
}
/// Per-frame parameters.
pub struct Frame {
/// Bounded unshadowed product-light preview. None retains the fixed studio.
pub product_lights: Option<Vec<[f32; 16]>>,
/// Product-only post-shading exposure; zero preserves the existing transfer.
pub product_exposure_ev: ProductExposureEv,
/// Product preview uses its own batches, retaining the Design viewport's uploads.
pub product: bool,
/// Product preview only: receives a GPU error raised by its geometry upload.
@@ -3249,6 +3270,10 @@ fn frame_key(cb: &Frame) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
cb.product.hash(&mut h);
cb.product_lights.as_ref().map(|lights| lights.iter().take(21).map(|light| light.map(f32::to_bits)).collect::<Vec<_>>()).hash(&mut h);
let exposure = product_exposure_ev(cb);
if exposure != 0.0 {
exposure.to_bits().hash(&mut h);
}
cb.key.hash(&mut h);
cb.sketch_key.hash(&mut h);
cb.hl_key.hash(&mut h);
@@ -4110,6 +4135,10 @@ fn load_depth<T>(first: bool, clear: T) -> wgpu::LoadOp<T> {
if first { wgpu::LoadOp::Clear(clear) } else { wgpu::LoadOp::Load }
}
fn product_exposure_ev(frame: &Frame) -> f32 {
if frame.product { frame.product_exposure_ev.ev() } else { 0.0 }
}
/// Uniform bytes for a frame.
pub fn uniform_bytes(cb: &Frame, w: f32, h: f32, linear: bool, reversed: bool) -> Vec<u8> {
let mut v: Vec<f32> = gpu_view_proj(cb.view_proj, reversed).iter().flatten().copied().collect();
@@ -4134,7 +4163,7 @@ pub fn uniform_bytes(cb: &Frame, w: f32, h: f32, linear: bool, reversed: bool) -
v.extend([
if cb.product_lights.is_some() { 1.0 } else { 0.0 },
cb.product_lights.as_ref().map_or(0, |lights| lights.len().min(21)) as f32,
0.0,
product_exposure_ev(cb),
0.0,
]);
for index in 0..21 {
@@ -4360,6 +4389,7 @@ mod tests {
let slot = || Arc::new(Mutex::new(None));
super::Frame {
product_lights: None,
product_exposure_ev: Default::default(),
product: false,
upload_error: None,
key: 1,
@@ -4533,6 +4563,48 @@ mod tests {
}
}
}
#[test]
fn product_exposure_type_rejects_hostile_values_and_canonicalises_zero() {
use super::ProductExposureEv;
for ev in [-32.0, -3.5, 0.0, 2.25, 32.0] {
assert_eq!(ProductExposureEv::from_ev(ev).unwrap().ev(), ev as f32);
}
for ev in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -32.0001, 32.0001, f64::MAX] {
assert!(ProductExposureEv::from_ev(ev).is_none());
}
assert_eq!(ProductExposureEv::default().ev().to_bits(), 0);
assert_eq!(ProductExposureEv::from_ev(-0.0).unwrap().ev().to_bits(), 0);
assert_eq!(ProductExposureEv::from_ev(-1e-300).unwrap().ev().to_bits(), 0);
}
#[test]
fn product_exposure_has_its_own_uniform_lane_and_only_invalidates_product_pictures() {
use super::{ProductExposureEv, frame_key, uniform_bytes};
let mut frame = cached_frame();
let design_key = frame_key(&frame);
let design_bytes = uniform_bytes(&frame, 64.0, 64.0, false, false);
frame.product_exposure_ev = ProductExposureEv::from_ev(3.0).unwrap();
assert_eq!(frame_key(&frame), design_key);
assert_eq!(uniform_bytes(&frame, 64.0, 64.0, false, false), design_bytes, "Design ignores product presentation");
frame.product = true;
frame.product_exposure_ev = Default::default();
let zero_key = frame_key(&frame);
let zero = uniform_bytes(&frame, 64.0, 64.0, false, false);
assert_eq!(zero.len(), 1776, "the existing shader layout is unchanged");
for ev in [-3.0, 2.5] {
frame.product_exposure_ev = ProductExposureEv::from_ev(ev).unwrap();
let exposed = uniform_bytes(&frame, 64.0, 64.0, false, false);
assert_ne!(frame_key(&frame), zero_key, "changing EV must repaint a cached product picture");
// Existing U is 416 bytes before product_lights_meta. Its z lane is byte424.
assert_eq!(f32::from_le_bytes(exposed[424..428].try_into().unwrap()), ev as f32);
assert_eq!(&exposed[..424], &zero[..424]);
assert_eq!(&exposed[428..], &zero[428..], "light records, tints and geometry uniforms must not change");
}
frame.product_exposure_ev = ProductExposureEv::from_ev(-0.0).unwrap();
assert_eq!(frame_key(&frame), zero_key);
assert_eq!(uniform_bytes(&frame, 64.0, 64.0, false, false), zero);
}
}
#[cfg(test)]
@@ -4747,6 +4819,7 @@ mod glass_tests {
let none = || Arc::new(Mutex::new(None));
let frame = Frame {
product_lights: None,
product_exposure_ev: Default::default(),
product: false,
upload_error: None,
key: 1,
@@ -4848,9 +4921,19 @@ mod glass_tests {
/// Render `frame` through `r` into a fresh 64 × 64 target and read the pixels back.
fn render_frame(r: &mut Renderer, device: &wgpu::Device, queue: &wgpu::Queue, frame: &Frame, with_depth: bool) -> Vec<u8> {
render_frame_format(r, device, queue, frame, with_depth, wgpu::TextureFormat::Rgba8Unorm)
}
fn render_frame_format(
r: &mut Renderer,
device: &wgpu::Device,
queue: &wgpu::Queue,
frame: &Frame,
with_depth: bool,
format: wgpu::TextureFormat,
) -> Vec<u8> {
const SIZE: u32 = 64;
let depth = crate::depth_format(device, true);
let format = wgpu::TextureFormat::Rgba8Unorm;
let texture = |format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
label: None,
@@ -4922,6 +5005,7 @@ mod glass_tests {
let none = || Arc::new(Mutex::new(None));
Frame {
product_lights: None,
product_exposure_ev: Default::default(),
product: false,
upload_error: None,
key: 1,
@@ -5239,4 +5323,204 @@ mod glass_tests {
let sorted = sort_back_to_front(&sc.glass, [0.0, 0.0, -1.0]);
assert_eq!(f32::from_le_bytes(sorted[8..12].try_into().unwrap()), 5.0);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn real_product_exposure_repaints_both_attachment_formats_and_restores_zero_exactly() {
let (_, device, queue) = match crate::open_headless() {
Ok(device) => device,
Err(error) if std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none() => {
eprintln!("PRODUCT_EXPOSURE_GPU supported=false reason={error}");
return;
}
Err(error) => panic!("required GPU adapter unavailable: {error}"),
};
for format in [wgpu::TextureFormat::Rgba8Unorm, wgpu::TextureFormat::Rgba8UnormSrgb] {
for pbr in [false, true] {
for explicit in [false, true] {
let mut scene = GpuScene::default();
let color = scene.color([96, 112, 128, 255]);
let surface =
solidcraft_render::surface::OpaqueSurface::new(solidcraft_geom::Vec3::new(0.12, 0.18, 0.24), 0.0, 0.6, 1.5).unwrap();
for p in [[-5.0, -5.0, 0.0], [5.0, -5.0, 0.0], [5.0, 5.0, 0.0], [-5.0, -5.0, 0.0], [5.0, 5.0, 0.0], [-5.0, 5.0, 0.0]] {
if pbr {
scene.pbr_tri(p, [0.0, 0.0, 1.0], surface);
} else {
scene.tri(p, [0.0, 0.0, 1.0], color);
}
}
let depth = crate::depth_format(&device, true);
let mut renderer = Renderer::new(&device, GpuTarget { format, depth: Some(depth), samples: 1, tess: false, oit: false }, None);
let mut frame = test_frame(scene, 2.0);
frame.product = true;
if explicit {
// Original ambient record in the real bounded preview-light wire.
frame.product_lights = Some(vec![[0.0, 0.0, 0.0, 4.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]]);
}
let zero = render_frame_format(&mut renderer, &device, &queue, &frame, true, format);
assert_eq!(zero.len(), 64 * 64 * 4);
assert!(renderer.ss_ready, "test the actual cached/offscreen path");
let repeated = render_frame_format(&mut renderer, &device, &queue, &frame, true, format);
assert_eq!(repeated, zero);
frame.product_exposure_ev = ProductExposureEv::from_ev(1.0).unwrap();
let bright = render_frame_format(&mut renderer, &device, &queue, &frame, true, format);
frame.product_exposure_ev = ProductExposureEv::from_ev(-1.0).unwrap();
let dark = render_frame_format(&mut renderer, &device, &queue, &frame, true, format);
let center = (32 * 64 + 32) * 4;
assert!((0..3).any(|channel| bright[center + channel] > zero[center + channel]), "{format:?} pbr={pbr} explicit={explicit}");
assert!((0..3).all(|channel| dark[center + channel] < zero[center + channel]), "{format:?} pbr={pbr} explicit={explicit}");
for image in [&bright, &dark] {
assert_eq!(&image[..4], &zero[..4], "the white backplate is unchanged");
assert!(image.chunks_exact(4).zip(zero.chunks_exact(4)).all(|(a, b)| a[3] == b[3]), "alpha/coverage must not be exposed");
}
frame.product_exposure_ev = Default::default();
assert_eq!(render_frame_format(&mut renderer, &device, &queue, &frame, true, format), zero);
}
}
}
eprintln!("PRODUCT_EXPOSURE_GPU supported=true formats=2 materials=2 lights=2");
}
/// Original Lambert oracle: one unit-cosine source of PI radiance cancels
/// diffuse / PI. Decode the authored byte, expose in linear light, Reinhard,
/// then encode once. Never derive expected pixels from the viewport shader.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn explicit_product_dark_channels_follow_one_srgb_transfer_in_both_targets() {
let (_, device, queue) = match crate::open_headless() {
Ok(device) => device,
Err(error) if std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none() => {
eprintln!("U17_PRODUCT_SRGB supported=false reason={error}");
return;
}
Err(error) => panic!("required GPU adapter unavailable: {error}"),
};
for format in [wgpu::TextureFormat::Rgba8Unorm, wgpu::TextureFormat::Rgba8UnormSrgb] {
for ssaa in [1.0, 2.0] {
for pbr in [false, true] {
for ambient in [false, true] {
for rgb in [[0u8, 1, 10], [11, 128, 255]] {
let mut scene = GpuScene::default();
let color = scene.color([rgb[0], rgb[1], rgb[2], 255]);
let linear = rgb.map(solidcraft_geom::srgb::decode8);
// Index matched, nonmetallic: the PBR directional
// control is Lambert too, with no Fresnel/specular.
let surface = solidcraft_render::surface::OpaqueSurface::new(
solidcraft_geom::Vec3::new(linear[0], linear[1], linear[2]),
0.0,
0.6,
1.0,
)
.unwrap();
for p in [[-5.0, -5.0, 0.0], [5.0, -5.0, 0.0], [5.0, 5.0, 0.0], [-5.0, -5.0, 0.0], [5.0, 5.0, 0.0], [-5.0, 5.0, 0.0]] {
if pbr {
scene.pbr_tri(p, [0.0, 0.0, 1.0], surface);
} else {
scene.tri(p, [0.0, 0.0, 1.0], color);
}
}
let depth = crate::depth_format(&device, true);
let mut renderer =
Renderer::new(&device, GpuTarget { format, depth: Some(depth), samples: 1, tess: false, oit: false }, None);
let mut frame = test_frame(scene, ssaa);
frame.product = true;
let pi = std::f32::consts::PI;
frame.product_lights =
Some(vec![[0.0, 0.0, 1.0, if ambient { 4.0 } else { 0.0 }, pi, pi, pi, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]]);
for ev in [-8.0, 0.0, 1.0] {
frame.product_exposure_ev = ProductExposureEv::from_ev(ev).unwrap();
let image = render_frame_format(&mut renderer, &device, &queue, &frame, true, format);
assert_eq!(image.len(), 64 * 64 * 4);
if ssaa == 2.0 {
assert!(renderer.ss_ready, "exercise cached supersampling");
}
// Away from every edge and the diagonal, with
// constant radiance over all supersample taps.
let at = (30 * 64 + 32) * 4;
for channel in 0..3 {
let radiance = linear[channel] * f64::from(pi) / std::f64::consts::PI * f64::from(ev).exp2();
let expected = solidcraft_geom::srgb::encode8(radiance / (1.0 + radiance));
assert!(
image[at + channel].abs_diff(expected) <= 1,
"{format:?} ssaa={ssaa} pbr={pbr} ambient={ambient} rgb={rgb:?} ev={ev} channel={channel}: got={} expected={expected}",
image[at + channel]
);
}
assert_eq!(image[at + 3], 255, "transfer never grades coverage");
assert_eq!(&image[..4], &[255; 4], "white backplate is unchanged");
}
}
}
}
}
}
eprintln!("U17_PRODUCT_SRGB supported=true formats=2 ssaa=2 materials=2 sources=2 palettes=2 exposures=3");
}
/// In this existing orthographic fixture, pixel(32,32) is exactly
/// (5/32,-5/32,0): clip edges are +/-1/2 at integer screen16 and48.
/// The source shares that representable point. No epsilon weakens delta0.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn raster_local_model_exact_coincidence_is_black_then_positive_source_repaints_both_formats() {
let (_, device, queue) = match crate::open_headless() {
Ok(device) => device,
Err(error) if std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none() => {
eprintln!("RASTER_LOCAL_MODEL_ZERO supported=false reason={error}");
return;
}
Err(error) => panic!("required GPU adapter unavailable: {error}"),
};
for format in [wgpu::TextureFormat::Rgba8Unorm, wgpu::TextureFormat::Rgba8UnormSrgb] {
for pbr in [false, true] {
let mut scene = GpuScene::default();
let rgb = [94, 128, 160];
let colour = scene.color([rgb[0], rgb[1], rgb[2], 255]);
let linear = rgb.map(solidcraft_geom::srgb::decode8);
let surface =
solidcraft_render::surface::OpaqueSurface::new(solidcraft_geom::Vec3::new(linear[0], linear[1], linear[2]), 0.0, 1.0, 1.0)
.unwrap();
for p in [[-5.0, -5.0, 0.0], [5.0, -5.0, 0.0], [5.0, 5.0, 0.0], [-5.0, -5.0, 0.0], [5.0, 5.0, 0.0], [-5.0, 5.0, 0.0]] {
if pbr {
scene.pbr_tri(p, [0.0, 0.0, 1.0], surface);
} else {
scene.tri(p, [0.0, 0.0, 1.0], colour);
}
}
let depth = crate::depth_format(&device, true);
let mut renderer = Renderer::new(&device, GpuTarget { format, depth: Some(depth), samples: 1, tess: false, oit: false }, None);
let mut frame = test_frame(scene, 1.0);
let design = render_frame_format(&mut renderer, &device, &queue, &frame, true, format);
frame.product = true;
let at = (32 * 64 + 32) * 4;
for kind in [1.0, 2.0, 3.0] {
let pi = std::f32::consts::PI;
let source = [0.15625, -0.15625, 0.0, kind, pi, pi, pi, 0.8, 0.0, 0.0, -1.0, 1.0, 1.0, 0.001, 0.0, 0.0];
frame.product_lights = Some(vec![source]);
let zero = render_frame_format(&mut renderer, &device, &queue, &frame, true, format);
assert_eq!(zero.len(), 64 * 64 * 4);
assert_eq!(&zero[at..at + 4], &[0, 0, 0, 255], "{format:?} pbr={pbr} kind={kind}");
frame.product_lights.as_mut().unwrap()[0][2] = 1.0;
let positive = render_frame_format(&mut renderer, &device, &queue, &frame, true, format);
// Unit distance/cosines, reference^2 or projected area1:
// source PI cancels Lambert /PI for every source family.
for channel in 0..3 {
let radiance = linear[channel] * f64::from(pi) / std::f64::consts::PI;
let expected = solidcraft_geom::srgb::encode8(radiance / (1.0 + radiance));
assert!(
positive[at + channel].abs_diff(expected) <= 1,
"{format:?} pbr={pbr} kind={kind} channel={channel}: {} != {expected}",
positive[at + channel]
);
}
assert_eq!(positive[at + 3], 255);
assert_eq!(&positive[..4], &[255; 4]);
}
frame.product = false;
frame.product_lights = None;
assert_eq!(render_frame_format(&mut renderer, &device, &queue, &frame, true, format), design, "legacy Design restored exactly");
}
}
eprintln!("RASTER_LOCAL_MODEL_ZERO supported=true formats=2 materials=2 sources=3");
}
}
+213 -212
View File
@@ -1,212 +1,213 @@
//! A startup probe of the installed viewport, including highlights, depth, resolve, and blit.
use super::*;
use crate::{GpuError, Result};
/// The startup check never changes a setting unless a working 1x replacement is proven.
#[derive(Debug, PartialEq, Eq)]
pub enum MsaaCheck {
Passed,
Fallback,
FailedAtOne,
}
impl Renderer {
/// Check a tiny scene through the installed pipelines and window format. On a blank
/// multisampled result, replace this renderer only if the same scene works at 1x.
/// Native only: browsers cannot synchronously poll a device. No result is cached on disk.
pub fn check_msaa(&mut self, device: &wgpu::Device, queue: &wgpu::Queue) -> Result<MsaaCheck> {
self.check_msaa_with(device, queue, 0)
}
pub(super) fn check_msaa_with(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, blank_samples: u32) -> Result<MsaaCheck> {
if self.probe_msaa(device, queue, blank_samples)? {
return Ok(MsaaCheck::Passed);
}
if self.target.samples == 1 {
return Ok(MsaaCheck::FailedAtOne);
}
let scopes = [wgpu::ErrorFilter::OutOfMemory, wgpu::ErrorFilter::Internal, wgpu::ErrorFilter::Validation].map(|f| device.push_error_scope(f));
let mut replacement = Self::with_window_target(device, GpuTarget { samples: 1, ..self.target }, self.window, None);
let mut error = None;
for scope in scopes.into_iter().rev() {
if let Some(e) = crate::popped(scope.pop()) {
error.get_or_insert(e);
}
}
if let Some(error) = error {
return Err(GpuError::Render(error.to_string()));
}
if !replacement.probe_msaa(device, queue, blank_samples)? {
return Ok(MsaaCheck::FailedAtOne);
}
replacement.tess = self.tess.take();
#[cfg(not(target_arch = "wasm32"))]
{
replacement.tess_later = self.tess_later.take();
}
*self = replacement;
Ok(MsaaCheck::Fallback)
}
fn probe_msaa(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, blank_samples: u32) -> Result<bool> {
if !matches!(
self.window.format,
wgpu::TextureFormat::Rgba8Unorm
| wgpu::TextureFormat::Rgba8UnormSrgb
| wgpu::TextureFormat::Bgra8Unorm
| wgpu::TextureFormat::Bgra8UnormSrgb
) {
return Err(GpuError::Unsupported("anti-aliasing check colour format".into()));
}
let scopes = [wgpu::ErrorFilter::OutOfMemory, wgpu::ErrorFilter::Internal, wgpu::ErrorFilter::Validation].map(|f| device.push_error_scope(f));
let result = self.probe_pixels(device, queue, blank_samples);
// Discard the probe's uploads and picture even on errors, before the real scene arrives.
self.model = Batches::default();
self.sketch = Batches::default();
self.highlight = Batches::default();
self.preview = Batches::default();
self.ss = None;
self.ss_key = None;
self.ss_ready = false;
self.cached = None;
let mut error = None;
for scope in scopes.into_iter().rev() {
if let Some(e) = crate::popped(scope.pop()) {
error.get_or_insert(e);
}
}
if let Some(error) = error {
return Err(GpuError::Render(error.to_string()));
}
result
}
fn probe_pixels(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, blank_samples: u32) -> Result<bool> {
const SIZE: u32 = 16;
let mut model = GpuScene::default();
let red = model.color([255, 0, 0, 255]);
for p in [[-0.9, -0.9, 0.0], [0.9, -0.9, 0.0], [0.0, 0.9, 0.0]] {
model.tri(p, [0.0, 0.0, 1.0], red);
}
let mut highlight = GpuScene::default();
let blue = highlight.color([0, 0, 255, 64]);
for p in [[-0.8, -0.8, 0.0], [0.8, -0.8, 0.0], [0.8, 0.8, 0.0], [-0.8, -0.8, 0.0], [0.8, 0.8, 0.0], [-0.8, 0.8, 0.0]] {
highlight.xray_tri(p, [0.0, 0.0, 1.0], blue);
}
let slot = |scene| Arc::new(Mutex::new(scene));
let frame = Frame {
product_lights: None,
product: false,
upload_error: None,
key: 1,
slot: slot(Some(model)),
sketch_key: 2,
sketch_slot: slot(None),
hl_key: 3,
hl_slot: slot(Some(highlight)),
pv_key: 4,
pv_slot: slot(None),
pv_hide: false,
view_proj: [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.5, 1.0]],
back: [0.0, 0.0, 1.0],
size_px: [SIZE as f32; 2],
clip: [0.0; 4],
cap: [0.0; 4],
images: Vec::new(),
points: Vec::new(),
analysis: [0.0; 8],
analysis_colors: [0.0; 36],
access: None,
outline: false,
eye: [0.0, 0.0, 100.0, 0.0],
ao_radius: 0.0,
ao_samples: crate::view::AO_SAMPLES,
ssaa: 1.0,
hover_tint: [0.0; 3],
selection_tint: [0.0; 3],
};
let size = wgpu::Extent3d { width: SIZE, height: SIZE, depth_or_array_layers: 1 };
let color = device.create_texture(&wgpu::TextureDescriptor {
label: Some("sc_msaa_check"),
size,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: self.window.format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = color.create_view(&Default::default());
let mut encoder = device.create_command_encoder(&Default::default());
self.prepare(device, queue, &mut encoder, &frame);
// Tests reproduce a driver returning a blank resolve, without changing production draws.
#[cfg(test)]
if (blank_samples == 1 || blank_samples == self.target.samples)
&& let Some(resolve) = self.ss.as_ref().map(|ss| ss.resolve.as_ref().unwrap_or(&ss.color))
{
let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: resolve,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), store: wgpu::StoreOp::Store },
})],
..Default::default()
});
}
#[cfg(not(test))]
let _ = blank_samples;
{
let mut pass = encoder
.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), store: wgpu::StoreOp::Store },
})],
..Default::default()
})
.forget_lifetime();
self.paint(&frame, &mut pass, [0.0, 0.0, SIZE as f32, SIZE as f32]);
}
// Three interior pixels in one row, padded to the copy alignment.
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("sc_msaa_check_read"),
size: 256,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
encoder.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo { origin: wgpu::Origin3d { x: 7, y: 8, z: 0 }, ..color.as_image_copy() },
wgpu::TexelCopyBufferInfo {
buffer: &read,
layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(256), rows_per_image: Some(1) },
},
wgpu::Extent3d { width: 3, height: 1, depth_or_array_layers: 1 },
);
queue.submit([encoder.finish()]);
let slice = read.slice(..);
let (tx, rx) = std::sync::mpsc::sync_channel(1);
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
// No submission index: a lost device must not turn a stale submission into a panic.
// Software adapters can take seconds on their first draw. Keep the wait bounded;
// a timeout remains an inconclusive check and preserves the selected sample count.
device
.poll(wgpu::PollType::Wait { submission_index: None, timeout: Some(std::time::Duration::from_secs(10)) })
.map_err(|e| GpuError::Render(format!("anti-aliasing check wait: {e}")))?;
rx.try_recv()
.map_err(|e| GpuError::Render(format!("anti-aliasing check callback: {e}")))?
.map_err(|e| GpuError::Render(format!("anti-aliasing check map: {e}")))?;
let bytes = slice.get_mapped_range().map_err(|e| GpuError::Render(e.to_string()))?;
let bgra = matches!(self.window.format, wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb);
Ok(bytes.get(..12).is_some_and(|pixels| {
pixels.as_chunks::<4>().0.iter().all(|p| {
let (red, green, blue) = if bgra { (p[2], p[1], p[0]) } else { (p[0], p[1], p[2]) };
red > 80 && red > green.saturating_add(40) && blue > 20
})
}))
}
}
//! A startup probe of the installed viewport, including highlights, depth, resolve, and blit.
use super::*;
use crate::{GpuError, Result};
/// The startup check never changes a setting unless a working 1x replacement is proven.
#[derive(Debug, PartialEq, Eq)]
pub enum MsaaCheck {
Passed,
Fallback,
FailedAtOne,
}
impl Renderer {
/// Check a tiny scene through the installed pipelines and window format. On a blank
/// multisampled result, replace this renderer only if the same scene works at 1x.
/// Native only: browsers cannot synchronously poll a device. No result is cached on disk.
pub fn check_msaa(&mut self, device: &wgpu::Device, queue: &wgpu::Queue) -> Result<MsaaCheck> {
self.check_msaa_with(device, queue, 0)
}
pub(super) fn check_msaa_with(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, blank_samples: u32) -> Result<MsaaCheck> {
if self.probe_msaa(device, queue, blank_samples)? {
return Ok(MsaaCheck::Passed);
}
if self.target.samples == 1 {
return Ok(MsaaCheck::FailedAtOne);
}
let scopes = [wgpu::ErrorFilter::OutOfMemory, wgpu::ErrorFilter::Internal, wgpu::ErrorFilter::Validation].map(|f| device.push_error_scope(f));
let mut replacement = Self::with_window_target(device, GpuTarget { samples: 1, ..self.target }, self.window, None);
let mut error = None;
for scope in scopes.into_iter().rev() {
if let Some(e) = crate::popped(scope.pop()) {
error.get_or_insert(e);
}
}
if let Some(error) = error {
return Err(GpuError::Render(error.to_string()));
}
if !replacement.probe_msaa(device, queue, blank_samples)? {
return Ok(MsaaCheck::FailedAtOne);
}
replacement.tess = self.tess.take();
#[cfg(not(target_arch = "wasm32"))]
{
replacement.tess_later = self.tess_later.take();
}
*self = replacement;
Ok(MsaaCheck::Fallback)
}
fn probe_msaa(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, blank_samples: u32) -> Result<bool> {
if !matches!(
self.window.format,
wgpu::TextureFormat::Rgba8Unorm
| wgpu::TextureFormat::Rgba8UnormSrgb
| wgpu::TextureFormat::Bgra8Unorm
| wgpu::TextureFormat::Bgra8UnormSrgb
) {
return Err(GpuError::Unsupported("anti-aliasing check colour format".into()));
}
let scopes = [wgpu::ErrorFilter::OutOfMemory, wgpu::ErrorFilter::Internal, wgpu::ErrorFilter::Validation].map(|f| device.push_error_scope(f));
let result = self.probe_pixels(device, queue, blank_samples);
// Discard the probe's uploads and picture even on errors, before the real scene arrives.
self.model = Batches::default();
self.sketch = Batches::default();
self.highlight = Batches::default();
self.preview = Batches::default();
self.ss = None;
self.ss_key = None;
self.ss_ready = false;
self.cached = None;
let mut error = None;
for scope in scopes.into_iter().rev() {
if let Some(e) = crate::popped(scope.pop()) {
error.get_or_insert(e);
}
}
if let Some(error) = error {
return Err(GpuError::Render(error.to_string()));
}
result
}
fn probe_pixels(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, blank_samples: u32) -> Result<bool> {
const SIZE: u32 = 16;
let mut model = GpuScene::default();
let red = model.color([255, 0, 0, 255]);
for p in [[-0.9, -0.9, 0.0], [0.9, -0.9, 0.0], [0.0, 0.9, 0.0]] {
model.tri(p, [0.0, 0.0, 1.0], red);
}
let mut highlight = GpuScene::default();
let blue = highlight.color([0, 0, 255, 64]);
for p in [[-0.8, -0.8, 0.0], [0.8, -0.8, 0.0], [0.8, 0.8, 0.0], [-0.8, -0.8, 0.0], [0.8, 0.8, 0.0], [-0.8, 0.8, 0.0]] {
highlight.xray_tri(p, [0.0, 0.0, 1.0], blue);
}
let slot = |scene| Arc::new(Mutex::new(scene));
let frame = Frame {
product_lights: None,
product_exposure_ev: Default::default(),
product: false,
upload_error: None,
key: 1,
slot: slot(Some(model)),
sketch_key: 2,
sketch_slot: slot(None),
hl_key: 3,
hl_slot: slot(Some(highlight)),
pv_key: 4,
pv_slot: slot(None),
pv_hide: false,
view_proj: [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.5, 1.0]],
back: [0.0, 0.0, 1.0],
size_px: [SIZE as f32; 2],
clip: [0.0; 4],
cap: [0.0; 4],
images: Vec::new(),
points: Vec::new(),
analysis: [0.0; 8],
analysis_colors: [0.0; 36],
access: None,
outline: false,
eye: [0.0, 0.0, 100.0, 0.0],
ao_radius: 0.0,
ao_samples: crate::view::AO_SAMPLES,
ssaa: 1.0,
hover_tint: [0.0; 3],
selection_tint: [0.0; 3],
};
let size = wgpu::Extent3d { width: SIZE, height: SIZE, depth_or_array_layers: 1 };
let color = device.create_texture(&wgpu::TextureDescriptor {
label: Some("sc_msaa_check"),
size,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: self.window.format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = color.create_view(&Default::default());
let mut encoder = device.create_command_encoder(&Default::default());
self.prepare(device, queue, &mut encoder, &frame);
// Tests reproduce a driver returning a blank resolve, without changing production draws.
#[cfg(test)]
if (blank_samples == 1 || blank_samples == self.target.samples)
&& let Some(resolve) = self.ss.as_ref().map(|ss| ss.resolve.as_ref().unwrap_or(&ss.color))
{
let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: resolve,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), store: wgpu::StoreOp::Store },
})],
..Default::default()
});
}
#[cfg(not(test))]
let _ = blank_samples;
{
let mut pass = encoder
.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), store: wgpu::StoreOp::Store },
})],
..Default::default()
})
.forget_lifetime();
self.paint(&frame, &mut pass, [0.0, 0.0, SIZE as f32, SIZE as f32]);
}
// Three interior pixels in one row, padded to the copy alignment.
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("sc_msaa_check_read"),
size: 256,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
encoder.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo { origin: wgpu::Origin3d { x: 7, y: 8, z: 0 }, ..color.as_image_copy() },
wgpu::TexelCopyBufferInfo {
buffer: &read,
layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(256), rows_per_image: Some(1) },
},
wgpu::Extent3d { width: 3, height: 1, depth_or_array_layers: 1 },
);
queue.submit([encoder.finish()]);
let slice = read.slice(..);
let (tx, rx) = std::sync::mpsc::sync_channel(1);
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
// No submission index: a lost device must not turn a stale submission into a panic.
// Software adapters can take seconds on their first draw. Keep the wait bounded;
// a timeout remains an inconclusive check and preserves the selected sample count.
device
.poll(wgpu::PollType::Wait { submission_index: None, timeout: Some(std::time::Duration::from_secs(10)) })
.map_err(|e| GpuError::Render(format!("anti-aliasing check wait: {e}")))?;
rx.try_recv()
.map_err(|e| GpuError::Render(format!("anti-aliasing check callback: {e}")))?
.map_err(|e| GpuError::Render(format!("anti-aliasing check map: {e}")))?;
let bytes = slice.get_mapped_range().map_err(|e| GpuError::Render(e.to_string()))?;
let bgra = matches!(self.window.format, wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb);
Ok(bytes.get(..12).is_some_and(|pixels| {
pixels.as_chunks::<4>().0.iter().all(|p| {
let (red, green, blue) = if bgra { (p[2], p[1], p[0]) } else { (p[0], p[1], p[2]) };
red > 80 && red > green.saturating_add(40) && blue > 20
})
}))
}
}
@@ -31,6 +31,10 @@ fn request(width: u32, height: u32, budget: CpuBudget) -> ImageRequest {
let mut camera = Camera { distance: 65.0, ..Default::default() };
camera.set_view(StandardView::Top);
ImageRequest {
raster_punctual_lights: None,
raster_area_lights: None,
raster_exposure_ev: 0.0,
raster_lights: None,
studio_weights: None,
environment: None,
textures: None,
+59 -4
View File
@@ -12,6 +12,23 @@ struct Pixel {
covered: u32,
}
/// Observed scanline frontier; metadata only, never a caller-supplied authority.
/// Every pixel before updated_prefix has one more sample than completed_passes.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct PartialFrontier {
pub completed_passes: u32,
pub updated_prefix: usize,
pub pixels: usize,
}
/// A partial view never invents a zero sample for a pixel not yet observed.
/// Sampled linear radiance includes zero contributions from primary misses.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) enum PartialPixel {
Unknown,
Sampled { linear_premultiplied: [f64; 3], covered: u32, count: u32 },
}
pub(crate) struct PrimaryFilm {
pixels: Vec<Pixel>,
count: usize,
@@ -112,13 +129,51 @@ impl PrimaryFilm {
/// A display consumer can only read a fully completed, immutable pass. The
/// returned radiance is premultiplied by coverage; background is kept separate.
pub(crate) fn pixel(&self, index: usize) -> Result<([f64; 3], u16, u16)> {
pub(crate) fn pixel(&self, index: usize) -> Result<([f64; 3], u32, u32)> {
if !self.ready() || self.next_pixel != 0 || self.samples == 0 {
return Err(invalid("primary film has no uniform complete pass"));
}
let pixel = self.pixels.get(index).ok_or_else(|| invalid("primary film resolve index out of range"))?;
let covered = u16::try_from(pixel.covered).map_err(|_| limit("primary coverage exceeds resolve representation"))?;
let samples = u16::try_from(self.samples).map_err(|_| limit("primary sample count exceeds resolve representation"))?;
Ok((pixel.sum.map(|sum| sum / f64::from(self.samples)), covered, samples))
Ok((pixel.sum.map(|sum| sum / f64::from(self.samples)), pixel.covered, self.samples))
}
/// Inspect only the real committed state. This value does not freeze the film
/// or authorize a snapshot. A caller resolving several pixels must keep the
/// film immutably borrowed at one completed-pixel frontier, and must charge
/// reads/copies and check cancellation against its existing job budget.
pub(crate) fn partial_frontier(&self) -> Result<PartialFrontier> {
if !self.ready()
|| self.next_pixel >= self.count
|| self.samples > self.sample_limit
|| (self.samples == self.sample_limit && self.next_pixel != 0)
{
return Err(invalid("primary film has no initialized partial frontier"));
}
Ok(PartialFrontier { completed_passes: self.samples, updated_prefix: self.next_pixel, pixels: self.count })
}
/// Bounded allocation-free read, separate from the strict complete-pass pixel().
/// Counts come from this film, never from a supplied frontier or sample count.
/// Unknown means count0/covered0; a sampled miss has count>0 and covered0.
/// No exposure, tone mapping, background, work lease or publication occurs here.
/// The caller owes cancellation checks and work charges for each read/copy;
/// this accessor creates no independent budget and never advances an ordinal.
pub(crate) fn partial_pixel(&self, index: usize) -> Result<PartialPixel> {
let frontier = self.partial_frontier()?;
let pixel = self.pixels.get(index).ok_or_else(|| invalid("primary film partial index out of range"))?;
let count = frontier
.completed_passes
.checked_add(u32::from(index < frontier.updated_prefix))
.filter(|count| *count <= self.sample_limit)
.ok_or_else(|| limit("primary partial sample count exceeded"))?;
if pixel.covered > count {
return Err(invalid("primary partial coverage exceeds its committed count"));
}
if count == 0 {
if pixel.covered != 0 || pixel.sum != [0.0; 3] {
return Err(invalid("unobserved primary pixel has accumulated data"));
}
return Ok(PartialPixel::Unknown);
}
Ok(PartialPixel::Sampled { linear_premultiplied: pixel.sum.map(|sum| sum / f64::from(count)), covered: pixel.covered, count })
}
}
+355
View File
@@ -275,3 +275,358 @@ fn primary_film_large_profile_keeps_every_sample_and_cancels_before_mutation() {
assert!(film.add(0, 512, [0.0; 3], true, &cancel, &mut || Ok(())).is_err());
assert_eq!(film.samples(), 512);
}
#[cfg(feature = "raytrace-cpu")]
mod partial_camera_passes {
use super::primary::{PartialFrontier, PartialPixel, PrimaryFilm};
use super::*;
use crate::job::{CpuBudget, CpuProgressiveOptions};
fn uninitialized(samples: u32, seed: u32) -> PrimaryFilm {
let options = CpuProgressiveOptions { samples_per_pixel: samples, seed };
let bytes = PrimaryFilm::required_bytes(4, 1, options, CpuBudget::Compact).unwrap();
PrimaryFilm::new(4, 1, options, bytes, &CancelToken::default(), CpuBudget::Compact).unwrap()
}
fn ready(samples: u32, seed: u32) -> PrimaryFilm {
let mut film = uninitialized(samples, seed);
while !film.ready() {
film.initialize_one(&CancelToken::default(), &mut || Ok(())).unwrap();
}
film
}
fn observe(film: &PrimaryFilm) -> (PartialFrontier, Vec<PartialPixel>) {
(film.partial_frontier().unwrap(), (0..4).map(|index| film.partial_pixel(index).unwrap()).collect())
}
fn sampled(mean: [f64; 3], covered: u32, count: u32) -> PartialPixel {
PartialPixel::Sampled { linear_premultiplied: mean, covered, count }
}
#[test]
fn partial_frontier_distinguishes_first_prefix_miss_and_unobserved_pixels() {
let mut film = ready(2, 7);
assert_eq!(observe(&film), (PartialFrontier { completed_passes: 0, updated_prefix: 0, pixels: 4 }, vec![PartialPixel::Unknown; 4],));
film.add(0, 0, [2.0, 0.0, 0.0], true, &CancelToken::default(), &mut || Ok(())).unwrap();
film.add(1, 0, [0.0; 3], false, &CancelToken::default(), &mut || Ok(())).unwrap();
assert_eq!(
observe(&film),
(
PartialFrontier { completed_passes: 0, updated_prefix: 2, pixels: 4 },
vec![sampled([2.0, 0.0, 0.0], 1, 1), sampled([0.0; 3], 0, 1), PartialPixel::Unknown, PartialPixel::Unknown],
)
);
assert!(film.pixel(0).is_err(), "legacy whole-pass reader still rejects the partial pass");
assert!(film.pixel(1).is_err(), "a sampled miss does not imply a uniform pass");
}
#[test]
fn partial_counts_and_means_follow_real_interior_and_full_pass_frontiers() {
let mut film = ready(2, 0);
let cancel = CancelToken::default();
for (index, value, covered) in [(0, [2.0, 0.0, 0.0], true), (1, [0.0; 3], false), (2, [0.0, 0.0, 4.0], true), (3, [0.0; 3], false)] {
film.add(index, 0, value, covered, &cancel, &mut || Ok(())).unwrap();
}
assert_eq!(film.partial_frontier().unwrap(), PartialFrontier { completed_passes: 1, updated_prefix: 0, pixels: 4 });
assert_eq!(film.partial_pixel(3).unwrap(), sampled([0.0; 3], 0, 1));
assert_eq!(film.pixel(0).unwrap(), ([2.0, 0.0, 0.0], 1, 1));
film.add(0, 1, [4.0, 0.0, 0.0], true, &cancel, &mut || Ok(())).unwrap();
film.add(1, 1, [0.0, 2.0, 0.0], true, &cancel, &mut || Ok(())).unwrap();
assert_eq!(
observe(&film),
(
PartialFrontier { completed_passes: 1, updated_prefix: 2, pixels: 4 },
vec![sampled([3.0, 0.0, 0.0], 2, 2), sampled([0.0, 1.0, 0.0], 1, 2), sampled([0.0, 0.0, 4.0], 1, 1), sampled([0.0; 3], 0, 1)],
)
);
assert!(film.pixel(0).is_err());
film.add(2, 1, [0.0, 0.0, 8.0], true, &cancel, &mut || Ok(())).unwrap();
assert_eq!(film.partial_frontier().unwrap().updated_prefix, 3);
film.add(3, 1, [0.0; 3], false, &cancel, &mut || Ok(())).unwrap();
assert_eq!(
observe(&film),
(
PartialFrontier { completed_passes: 2, updated_prefix: 0, pixels: 4 },
vec![sampled([3.0, 0.0, 0.0], 2, 2), sampled([0.0, 1.0, 0.0], 1, 2), sampled([0.0, 0.0, 6.0], 2, 2), sampled([0.0; 3], 0, 2)],
)
);
assert_eq!(film.pixel(2).unwrap(), ([0.0, 0.0, 6.0], 2, 2));
assert!(film.add(0, 2, [0.0; 3], false, &cancel, &mut || Ok(())).is_err());
assert_eq!(film.partial_frontier().unwrap().completed_passes, 2);
}
#[test]
fn partial_access_refuses_uninitialized_storage_and_invalid_indices() {
let mut film = uninitialized(2, 7);
assert!(film.partial_frontier().is_err());
assert!(film.partial_pixel(0).is_err());
film.initialize_one(&CancelToken::default(), &mut || Ok(())).unwrap();
assert!(film.partial_frontier().is_err(), "allocated prefix is not an initialized film");
assert!(film.partial_pixel(0).is_err());
while !film.ready() {
film.initialize_one(&CancelToken::default(), &mut || Ok(())).unwrap();
}
assert_eq!(film.partial_pixel(0).unwrap(), PartialPixel::Unknown);
assert!(film.partial_pixel(4).is_err());
assert!(film.partial_pixel(usize::MAX).is_err());
assert_eq!(film.partial_frontier().unwrap().updated_prefix, 0);
}
#[test]
fn failed_or_cancelled_updates_preserve_the_observed_partial_frontier() {
let mut film = ready(2, 7);
film.add(0, 0, [2.0, 0.0, 0.0], true, &CancelToken::default(), &mut || Ok(())).unwrap();
let before = observe(&film);
for (index, ordinal, value, covered) in
[(0, 0, [0.0; 3], false), (2, 0, [0.0; 3], false), (1, 1, [0.0; 3], false), (1, 0, [f64::NAN; 3], true), (1, 0, [1.0; 3], false)]
{
assert!(film.add(index, ordinal, value, covered, &CancelToken::default(), &mut || Ok(())).is_err());
assert_eq!(observe(&film), before);
}
assert_eq!(
film.add(1, 0, [4.0; 3], true, &CancelToken::default(), &mut || Err(limit("existing job exhausted"))).unwrap_err().code,
ErrorCode::LimitExceeded
);
assert_eq!(observe(&film), before);
let cancel = CancelToken::default();
let observer = cancel.clone();
assert_eq!(
film.add(1, 0, [4.0; 3], true, &cancel, &mut || {
observer.cancel();
Ok(())
})
.unwrap_err()
.code,
ErrorCode::Cancelled
);
assert_eq!(observe(&film), before);
film.add(1, 0, [4.0; 3], true, &CancelToken::default(), &mut || Ok(())).unwrap();
assert_eq!(film.partial_frontier().unwrap().updated_prefix, 2, "failed updates did not consume the next ordinal");
}
#[test]
fn reading_partial_pixels_preserves_default_sum_order_bits_and_charge_count() {
for seed in [0, 7, u32::MAX] {
let mut observed = ready(4, seed);
let mut control = ready(4, seed);
let mut observed_charges = 0;
let mut control_charges = 0;
for ordinal in 0..4 {
let red = if ordinal == 0 { 1e12 } else { 2.0_f64.powi(-15) };
for index in 0..4 {
let value = [red, red * 0.5, 2.0];
observed
.add(index, ordinal, value, true, &CancelToken::default(), &mut || {
observed_charges += 1;
Ok(())
})
.unwrap();
// These uncharged O(1) reads model only the accessor. A future
// snapshot caller still owes its own per-read/copy work charge.
let _ = observe(&observed);
control
.add(index, ordinal, value, true, &CancelToken::default(), &mut || {
control_charges += 1;
Ok(())
})
.unwrap();
}
}
assert_eq!((observed_charges, control_charges), (16, 16));
assert_eq!(observed.samples(), 4);
for index in 0..4 {
let (actual, covered, count) = observed.pixel(index).unwrap();
let (expected, expected_covered, expected_count) = control.pixel(index).unwrap();
assert_eq!(actual.map(f64::to_bits), expected.map(f64::to_bits));
assert_eq!(
actual.map(f64::to_bits),
[2.5e11_f64, 1.25e11_f64, 2.0_f64].map(f64::to_bits),
"ordered additions keep each sub-half-ULP contribution rounded at its original addition"
);
assert_eq!((covered, count), (expected_covered, expected_count));
assert_eq!(observed.partial_pixel(index).unwrap(), sampled(actual, 4, 4));
}
}
}
}
#[cfg(feature = "raytrace-cpu")]
mod workstation_primary_counts {
use super::primary::{PartialPixel, PrimaryFilm};
use super::*;
use crate::job::{CpuBudget, CpuProgressiveOptions};
fn primary(target: u32) -> PrimaryFilm {
let options = CpuProgressiveOptions { samples_per_pixel: target, seed: 7 };
let bytes = PrimaryFilm::required_bytes(1, 1, options, CpuBudget::Workstation).unwrap();
let mut film = PrimaryFilm::new(1, 1, options, bytes, &CancelToken::default(), CpuBudget::Workstation).unwrap();
film.initialize_one(&CancelToken::default(), &mut || Ok(())).unwrap();
film
}
#[test]
fn workstation_cap_is_explicit_and_other_profiles_and_defaults_stay_fixed() {
assert_eq!(crate::job::MAX_CPU_SAMPLES_PER_PIXEL, 1_048_576);
assert_eq!(CpuBudget::Workstation.limits().max_samples_per_pixel, 1_048_576);
assert_eq!(CpuBudget::Studio.limits().max_samples_per_pixel, 1024);
assert_eq!(CpuBudget::Compact.limits().max_samples_per_pixel, 64);
assert_eq!(CpuBudget::default(), CpuBudget::Compact);
assert_eq!(CpuProgressiveOptions::default(), CpuProgressiveOptions { samples_per_pixel: 16, seed: 0 });
for samples_per_pixel in [0, 1_048_577, u32::MAX] {
assert!(CpuBudget::Workstation.validate_samples(samples_per_pixel).is_err());
}
for (budget, cap) in [(CpuBudget::Compact, 64), (CpuBudget::Studio, 1024), (CpuBudget::Workstation, 1_048_576)] {
budget.validate_samples(cap).unwrap();
assert!(budget.validate_samples(cap + 1).is_err());
}
let large = CpuProgressiveOptions { samples_per_pixel: 1_048_576, seed: 7 };
assert!(large.validate().is_err(), "the no-profile compatibility check must remain Compact");
assert!(large.validate_image(1, 1).is_err());
}
#[test]
fn high_sample_intent_keeps_camera_resolution_work_and_memory_admission() {
let limits = CpuBudget::Workstation.limits();
assert_eq!(limits.max_camera_samples, 68_719_476_736);
assert_eq!(limits.max_dimension, 16_384);
assert_eq!(limits.max_pixels, 67_108_864);
assert_eq!(limits.max_progressive_pixels, 67_108_864);
assert_eq!(limits.max_job_bytes, 12 * 1024 * 1024 * 1024);
assert_eq!(limits.max_work_units, 16_000_000_000_000);
assert_eq!(crate::job::NATIVE_CPU_POOL_BYTES, 16 * 1024 * 1024 * 1024);
let million = Some(CpuProgressiveOptions { samples_per_pixel: 1_048_576, seed: 7 });
CpuBudget::Workstation.validate_image(256, 256, million).unwrap();
assert!(CpuBudget::Workstation.validate_image(257, 256, million).is_err());
assert!(CpuBudget::Workstation.validate_image(u32::MAX, u32::MAX, million).is_err());
// Pure admission boundaries, no large image or film allocation.
for (width, height, samples) in [(1920, 1080, 33_140), (3840, 2160, 8_285), (7680, 4320, 2_071), (8192, 8192, 1024)] {
CpuBudget::Workstation.validate_image(width, height, Some(CpuProgressiveOptions { samples_per_pixel: samples, seed: 7 })).unwrap();
assert!(
CpuBudget::Workstation
.validate_image(width, height, Some(CpuProgressiveOptions { samples_per_pixel: samples + 1, seed: 7 }))
.is_err()
);
}
for budget in [CpuBudget::Studio, CpuBudget::Workstation] {
assert_eq!(budget.validate_execution().is_err(), cfg!(target_arch = "wasm32") || usize::BITS < 64);
}
CpuBudget::Compact.validate_execution().unwrap();
}
#[test]
fn one_pixel_million_sample_intent_does_not_allocate_a_sample_history() {
let low = CpuProgressiveOptions { samples_per_pixel: 1, seed: 7 };
let high = CpuProgressiveOptions { samples_per_pixel: 1_048_576, seed: 7 };
let bytes = PrimaryFilm::required_bytes(1, 1, high, CpuBudget::Workstation).unwrap();
assert_eq!(PrimaryFilm::required_bytes(1, 1, low, CpuBudget::Workstation).unwrap(), bytes);
assert!(PrimaryFilm::new(1, 1, high, bytes - 1, &CancelToken::default(), CpuBudget::Workstation).is_err());
for samples_per_pixel in [0, 1_048_577, u32::MAX] {
assert!(PrimaryFilm::required_bytes(1, 1, CpuProgressiveOptions { samples_per_pixel, seed: 7 }, CpuBudget::Workstation).is_err());
}
assert!(PrimaryFilm::required_bytes(257, 256, high, CpuBudget::Workstation).is_err());
}
#[test]
fn primary_resolve_keeps_exact_u32_counts_at_65535_65536_and_the_workstation_cap() {
// Real film commits, one pixel only: no ray tracing, PNG, host or million-frame render.
let mut film = primary(1_048_576);
let cancel = CancelToken::default();
let mut charged = 0u64;
for ordinal in 0..1_048_576 {
film.add(0, ordinal, [1.0, 2.0, 4.0], true, &cancel, &mut || {
charged += 1;
Ok(())
})
.unwrap();
let count = ordinal + 1;
if [4096, 65_535, 65_536, 1_048_576].contains(&count) {
let (mean, covered, samples): ([f64; 3], u32, u32) = film.pixel(0).unwrap();
assert_eq!(mean, [1.0, 2.0, 4.0]);
assert_eq!((covered, samples), (count, count));
assert_eq!(film.samples(), count);
assert_eq!(charged, u64::from(count));
assert_eq!(film.partial_pixel(0).unwrap(), PartialPixel::Sampled { linear_premultiplied: mean, covered: count, count });
}
}
let before = film.pixel(0).unwrap();
let frontier = film.partial_frontier().unwrap();
for ordinal in [1_048_576, 1_048_577, u32::MAX] {
assert!(film.add(0, ordinal, [0.0; 3], false, &cancel, &mut || panic!("rejected ordinal charged work")).is_err());
assert_eq!(film.pixel(0).unwrap(), before);
assert_eq!(film.partial_frontier().unwrap(), frontier);
}
}
#[test]
fn high_counts_keep_misses_in_the_mean_and_the_real_coverage_denominator() {
let mut film = primary(65_537);
let cancel = CancelToken::default();
for ordinal in 0..65_537 {
let covered = ordinal % 2 == 0;
film.add(0, ordinal, if covered { [2.0, 4.0, 8.0] } else { [0.0; 3] }, covered, &cancel, &mut || Ok(())).unwrap();
let count = ordinal + 1;
if [65_535, 65_536, 65_537].contains(&count) {
let hits = count.div_ceil(2);
let expected = [2.0, 4.0, 8.0].map(|value| value * f64::from(hits) / f64::from(count));
assert_eq!(film.pixel(0).unwrap(), (expected, hits, count));
assert_eq!(film.partial_pixel(0).unwrap(), PartialPixel::Sampled { linear_premultiplied: expected, covered: hits, count });
}
}
}
#[test]
fn high_count_cancellation_and_work_failure_preserve_the_exact_committed_film() {
let mut film = primary(65_537);
for ordinal in 0..65_536 {
film.add(0, ordinal, [1.0, 2.0, 4.0], true, &CancelToken::default(), &mut || Ok(())).unwrap();
}
let before = film.pixel(0).unwrap();
let frontier = film.partial_frontier().unwrap();
let cancelled = CancelToken::default();
cancelled.cancel();
assert_eq!(
film.add(0, 65_536, [3.0; 3], true, &cancelled, &mut || panic!("cancelled input charged work")).unwrap_err().code,
ErrorCode::Cancelled
);
let cancel = CancelToken::default();
let observer = cancel.clone();
let mut charged = 0;
assert_eq!(
film.add(0, 65_536, [3.0; 3], true, &cancel, &mut || {
charged += 1;
observer.cancel();
Ok(())
})
.unwrap_err()
.code,
ErrorCode::Cancelled
);
assert_eq!(charged, 1);
assert!(film.add(0, 65_536, [3.0; 3], true, &CancelToken::default(), &mut || Err(limit("same cumulative job exhausted"))).is_err());
assert_eq!(film.pixel(0).unwrap(), before);
assert_eq!(film.partial_frontier().unwrap(), frontier);
film.add(0, 65_536, [1.0, 2.0, 4.0], true, &CancelToken::default(), &mut || Ok(())).unwrap();
assert_eq!(film.pixel(0).unwrap(), ([1.0, 2.0, 4.0], 65_537, 65_537));
}
#[test]
fn changing_only_the_target_keeps_old_4096_prefix_bits_and_ordinals() {
let mut old_target = primary(4096);
let mut high_target = primary(1_048_576);
let cancel = CancelToken::default();
for ordinal in 0..4096 {
let covered = ordinal % 3 != 0;
let sample = if covered { [0.25, 2.0, 8.0] } else { [0.0; 3] };
old_target.add(0, ordinal, sample, covered, &cancel, &mut || Ok(())).unwrap();
high_target.add(0, ordinal, sample, covered, &cancel, &mut || Ok(())).unwrap();
let (old_mean, old_covered, old_count) = old_target.pixel(0).unwrap();
let (high_mean, high_covered, high_count) = high_target.pixel(0).unwrap();
assert_eq!(old_mean.map(f64::to_bits), high_mean.map(f64::to_bits));
assert_eq!((old_covered, old_count), (high_covered, high_count));
assert_eq!(old_target.partial_frontier().unwrap(), high_target.partial_frontier().unwrap());
}
}
}
+63 -3
View File
@@ -40,6 +40,15 @@ impl Bloom {
}
u64::from(width).checked_mul(u64::from(height)).and_then(|n| n.checked_mul(24)).ok_or_else(|| invalid("bloom scratch overflow"))
}
pub(crate) fn scratch_bytes_with_limits(self, width: u32, height: u32, limits: crate::RenderLimits) -> Result<u64> {
self.validate()?;
let count = super::linear_exr::output_pixels_with_limits(width, height, limits)?;
if !self.active() {
return Ok(0);
}
super::linear_exr::checked_output_bytes(count, 12, 0, limits)?;
super::linear_exr::checked_stage_bytes(count, 24, 0, limits)
}
}
fn invalid(message: &str) -> JobError {
JobError::new(ErrorCode::InvalidRequest, message)
@@ -64,21 +73,37 @@ pub struct BloomTask {
}
impl BloomTask {
pub fn new(width: u32, height: u32, pixels: Vec<[f32; 4]>, options: Bloom, cancel: CancelToken) -> Result<Self> {
Self::new_with_limits(width, height, pixels, options, cancel, GPU_PATH_LIMITS)
}
pub(crate) fn new_with_limits(
width: u32,
height: u32,
pixels: Vec<[f32; 4]>,
options: Bloom,
cancel: CancelToken,
limits: crate::RenderLimits,
) -> Result<Self> {
options.validate()?;
cancel.check()?;
let count = u64::from(width).checked_mul(u64::from(height)).ok_or_else(|| invalid("bloom dimensions overflow"))?;
if width == 0
|| height == 0
|| u64::from(width) > GPU_PATH_LIMITS.max_dimension
|| u64::from(height) > GPU_PATH_LIMITS.max_dimension
|| count > GPU_PATH_LIMITS.max_pixels
|| super::linear_exr::output_pixels_with_limits(width, height, limits).is_err()
|| usize::try_from(count).ok() != Some(pixels.len())
|| !options.active()
{
return Err(invalid("bloom requires an admitted nonempty image and positive strength"));
}
options.scratch_bytes_with_limits(width, height, limits)?;
let retained = super::linear_exr::checked_output_bytes(pixels.capacity() as u64, 16, 0, limits)? as u64;
super::linear_exr::checked_stage_bytes(count, 24, retained, limits)?;
let horizontal = allocate(pixels.len())?;
let horizontal_bytes = super::linear_exr::checked_output_bytes(horizontal.capacity() as u64, 12, 0, limits)? as u64;
let retained = retained.checked_add(horizontal_bytes).ok_or_else(|| invalid("bloom retained byte overflow"))?;
super::linear_exr::checked_stage_bytes(count, 12, retained, limits)?;
let vertical = allocate(pixels.len())?;
super::linear_exr::checked_output_bytes(vertical.capacity() as u64, 12, 0, limits)?;
super::linear_exr::checked_stage_bytes(vertical.capacity() as u64, 12, retained, limits)?;
Ok(Self { pixels, horizontal, vertical, width: width as usize, height: height as usize, options, cancel, phase: 0, line: 0 })
}
pub fn total_lines(&self) -> u64 {
@@ -247,4 +272,39 @@ mod tests {
assert!(task.step(1).is_err());
assert!(task.finish().is_err());
}
#[test]
fn explicit_bloom_preserves_independent_three_box_kernel_and_quantum() {
let mut pixels = vec![[0.0, 0.0, 0.0, 1.0]; 121];
pixels[60][0] = 1.0;
let options = Bloom { strength: 1.0, threshold: 0.0, radius: 1 };
let mut work = BloomTask::new_with_limits(11, 11, pixels.clone(), options, CancelToken::default(), GPU_PATH_LIMITS).unwrap();
while !work.step(1).unwrap() {}
let (retained, glow) = work.finish().unwrap();
assert_eq!(retained, pixels);
let kernel = [1.0, 3.0, 6.0, 7.0, 6.0, 3.0, 1.0];
for y in 0..11 {
for x in 0..11 {
let expected = if (2..=8).contains(&x) && (2..=8).contains(&y) { kernel[x - 2] * kernel[y - 2] / 729.0 } else { 0.0 };
assert!((f64::from(glow[y * 11 + x][0]) - expected).abs() < 2e-7);
assert_eq!(&glow[y * 11 + x][1..], &[0.0, 0.0]);
}
}
assert_eq!(glow, run(pixels, 11, 11, 1, 32));
}
#[test]
fn wide_bloom_is_explicit_and_legacy_inactive_and_cancel_order_stay_intact() {
let wide = super::super::CpuBudget::Workstation.image_limits(false);
let options = Bloom { strength: 1.0, ..Default::default() };
assert!(BloomTask::new(8193, 1, vec![], options, CancelToken::default()).is_err());
let cancel = CancelToken::default();
let mut task = BloomTask::new_with_limits(8193, 1, vec![[0.0, 0.0, 0.0, 1.0]; 8193], options, cancel.clone(), wide).unwrap();
cancel.cancel();
assert_eq!(task.step(1).unwrap_err().code, ErrorCode::Cancelled);
assert_eq!(Bloom::default().scratch_bytes(0, u32::MAX).unwrap(), 0);
assert_eq!(options.scratch_bytes(8193, 1).unwrap(), 8193 * 24, "legacy estimate remains arithmetic-only");
assert_eq!(BloomTask::new(0, 1, vec![], options, cancel.clone()).err().unwrap().code, ErrorCode::Cancelled);
assert_eq!(BloomTask::new(0, 1, vec![], Bloom { strength: f64::NAN, ..options }, cancel).err().unwrap().code, ErrorCode::InvalidRequest);
}
}
+176 -176
View File
@@ -1,176 +1,176 @@
//! Explicit CPU resource envelopes. Larger profiles are opt-in saved intent, not
//! automatic quality changes or promises about available RAM, speed or convergence.
use serde::{Deserialize, Serialize};
use super::{CpuProgressiveOptions, ErrorCode, JobError, Result};
use crate::RenderLimits;
const MIB: u64 = 1024 * 1024;
const GIB: u64 = 1024 * MIB;
/// Native CPU reservations, including retained frames, share this finite ceiling.
pub const NATIVE_CPU_POOL_BYTES: u64 = 16 * GIB;
/// Hard implementation ceilings; individual requests retain their selected envelope.
pub const MAX_CPU_WORK: u64 = 16_000_000_000_000;
pub const MAX_CPU_TRIANGLES: usize = 8_000_000;
pub const MAX_CPU_BODIES: usize = 262_144;
/// Maximum admitted primary-film samples, shared with the display resolver.
pub const MAX_CPU_SAMPLES_PER_PIXEL: u32 = 4096;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CpuBudget {
#[default]
Compact,
Studio,
Workstation,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
pub struct CpuBudgetLimits {
pub max_dimension: u64,
pub max_pixels: u64,
pub max_progressive_pixels: u64,
pub max_samples_per_pixel: u32,
pub max_camera_samples: u64,
pub max_triangles: u64,
pub max_bodies: usize,
pub max_geometry_bytes: u64,
pub max_job_bytes: u64,
pub max_work_units: u64,
}
impl CpuBudget {
pub const ALL: [Self; 3] = [Self::Compact, Self::Studio, Self::Workstation];
pub fn is_compact(&self) -> bool {
*self == Self::Compact
}
pub const fn limits(self) -> CpuBudgetLimits {
match self {
Self::Compact => CpuBudgetLimits {
max_dimension: 4096,
max_pixels: 4_194_304,
max_progressive_pixels: 1_048_576,
max_samples_per_pixel: 64,
max_camera_samples: 16_777_216,
max_triangles: super::image::MAX_TRIANGLES,
max_bodies: 16_384,
max_geometry_bytes: super::image::MAX_GEOMETRY_BYTES,
max_job_bytes: super::image::MAX_JOB_BYTES,
max_work_units: 1_000_000_000,
},
Self::Studio => CpuBudgetLimits {
max_dimension: 8192,
max_pixels: 16_777_216,
max_progressive_pixels: 16_777_216,
max_samples_per_pixel: 1024,
max_camera_samples: 4_294_967_296,
max_triangles: 2_000_000,
max_bodies: 65_536,
max_geometry_bytes: GIB,
max_job_bytes: 3 * GIB,
max_work_units: 1_000_000_000_000,
},
Self::Workstation => CpuBudgetLimits {
max_dimension: 16_384,
max_pixels: 67_108_864,
max_progressive_pixels: 67_108_864,
max_samples_per_pixel: MAX_CPU_SAMPLES_PER_PIXEL,
max_camera_samples: 68_719_476_736,
max_triangles: MAX_CPU_TRIANGLES as u64,
max_bodies: MAX_CPU_BODIES,
max_geometry_bytes: 4 * GIB,
max_job_bytes: 12 * GIB,
max_work_units: MAX_CPU_WORK,
},
}
}
pub fn image_limits(self, progressive: bool) -> RenderLimits {
let limits = self.limits();
RenderLimits {
max_dimension: limits.max_dimension,
max_pixels: if progressive { limits.max_progressive_pixels } else { limits.max_pixels },
max_scratch_bytes: limits.max_job_bytes,
}
}
pub fn validate_samples(self, samples: u32) -> Result<()> {
let maximum = self.limits().max_samples_per_pixel;
if samples == 0 || samples > maximum {
return Err(JobError::new(ErrorCode::InvalidRequest, format!("CPU {self:?} samples_per_pixel must be 1..{maximum}")));
}
Ok(())
}
/// No geometry reads or allocations. This also validates offline saved intent.
pub fn validate_image(self, width: u32, height: u32, progressive: Option<CpuProgressiveOptions>) -> Result<()> {
let (w, h) = (u64::from(width), u64::from(height));
let limits = self.image_limits(progressive.is_some());
let pixels = w.checked_mul(h).ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "CPU pixel count overflow"))?;
if w == 0 || h == 0 || w > limits.max_dimension || h > limits.max_dimension || pixels > limits.max_pixels {
return Err(JobError::new(
ErrorCode::LimitExceeded,
format!(
"CPU {self:?} accepts dimensions 1..{} and at most {} pixels; select a larger resource profile explicitly",
limits.max_dimension, limits.max_pixels
),
));
}
if let Some(options) = progressive {
self.validate_samples(options.samples_per_pixel)?;
if pixels.checked_mul(u64::from(options.samples_per_pixel)).is_none_or(|n| n > self.limits().max_camera_samples) {
return Err(JobError::new(
ErrorCode::LimitExceeded,
format!("CPU {self:?} exceeds its {} camera-sample admission", self.limits().max_camera_samples),
));
}
}
Ok(())
}
/// A file may preserve large intent on every platform; only execution needs
/// the native 64-bit address space. No implicit downgrade to Compact occurs.
pub fn validate_execution(self) -> Result<()> {
if self != Self::Compact && (cfg!(target_arch = "wasm32") || usize::BITS < 64) {
return Err(JobError::new(ErrorCode::HostUnavailable, "large CPU profiles require a native 64-bit host"));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn large_intent_is_explicit_and_old_intent_remains_compact() {
assert_eq!(CpuBudget::default(), CpuBudget::Compact);
let p = Some(CpuProgressiveOptions { samples_per_pixel: 128, seed: 9 });
assert!(CpuBudget::Compact.validate_image(3840, 2160, p).is_err());
assert!(CpuBudget::Studio.validate_image(3840, 2160, p).is_ok());
assert!(CpuBudget::Studio.validate_image(7680, 4320, p).is_err());
assert!(CpuBudget::Workstation.validate_image(7680, 4320, p).is_ok());
assert_eq!(serde_json::from_str::<CpuBudget>("\"workstation\"").unwrap(), CpuBudget::Workstation);
assert!(serde_json::from_str::<CpuBudget>("\"unlimited\"").is_err());
}
#[test]
fn all_envelopes_have_checked_finite_bounds_and_no_automatic_resize() {
for budget in CpuBudget::ALL {
let limits = budget.limits();
assert!(limits.max_geometry_bytes < limits.max_job_bytes);
assert!(limits.max_job_bytes < NATIVE_CPU_POOL_BYTES);
assert!(limits.max_work_units <= MAX_CPU_WORK);
assert!(budget.validate_image(0, 1, None).is_err());
assert!(budget.validate_image(u32::MAX, u32::MAX, None).is_err());
assert!(budget.validate_samples(0).is_err());
assert!(budget.validate_samples(limits.max_samples_per_pixel + 1).is_err());
assert!(budget.validate_samples(limits.max_samples_per_pixel).is_ok());
}
let p = Some(CpuProgressiveOptions { samples_per_pixel: 4096, seed: 0 });
assert!(CpuBudget::Workstation.validate_image(8192, 8192, p).is_err());
assert!(CpuBudget::Workstation.validate_image(3840, 2160, p).is_ok());
}
}
//! Explicit CPU resource envelopes. Larger profiles are opt-in saved intent, not
//! automatic quality changes or promises about available RAM, speed or convergence.
use serde::{Deserialize, Serialize};
use super::{CpuProgressiveOptions, ErrorCode, JobError, Result};
use crate::RenderLimits;
const MIB: u64 = 1024 * 1024;
const GIB: u64 = 1024 * MIB;
/// Native CPU reservations, including retained frames, share this finite ceiling.
pub const NATIVE_CPU_POOL_BYTES: u64 = 16 * GIB;
/// Hard implementation ceilings; individual requests retain their selected envelope.
pub const MAX_CPU_WORK: u64 = 16_000_000_000_000;
pub const MAX_CPU_TRIANGLES: usize = 8_000_000;
pub const MAX_CPU_BODIES: usize = 262_144;
/// Maximum admitted primary-film samples, shared with the display resolver.
pub const MAX_CPU_SAMPLES_PER_PIXEL: u32 = 1_048_576;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CpuBudget {
#[default]
Compact,
Studio,
Workstation,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
pub struct CpuBudgetLimits {
pub max_dimension: u64,
pub max_pixels: u64,
pub max_progressive_pixels: u64,
pub max_samples_per_pixel: u32,
pub max_camera_samples: u64,
pub max_triangles: u64,
pub max_bodies: usize,
pub max_geometry_bytes: u64,
pub max_job_bytes: u64,
pub max_work_units: u64,
}
impl CpuBudget {
pub const ALL: [Self; 3] = [Self::Compact, Self::Studio, Self::Workstation];
pub fn is_compact(&self) -> bool {
*self == Self::Compact
}
pub const fn limits(self) -> CpuBudgetLimits {
match self {
Self::Compact => CpuBudgetLimits {
max_dimension: 4096,
max_pixels: 4_194_304,
max_progressive_pixels: 1_048_576,
max_samples_per_pixel: 64,
max_camera_samples: 16_777_216,
max_triangles: super::image::MAX_TRIANGLES,
max_bodies: 16_384,
max_geometry_bytes: super::image::MAX_GEOMETRY_BYTES,
max_job_bytes: super::image::MAX_JOB_BYTES,
max_work_units: 1_000_000_000,
},
Self::Studio => CpuBudgetLimits {
max_dimension: 8192,
max_pixels: 16_777_216,
max_progressive_pixels: 16_777_216,
max_samples_per_pixel: 1024,
max_camera_samples: 4_294_967_296,
max_triangles: 2_000_000,
max_bodies: 65_536,
max_geometry_bytes: GIB,
max_job_bytes: 3 * GIB,
max_work_units: 1_000_000_000_000,
},
Self::Workstation => CpuBudgetLimits {
max_dimension: 16_384,
max_pixels: 67_108_864,
max_progressive_pixels: 67_108_864,
max_samples_per_pixel: MAX_CPU_SAMPLES_PER_PIXEL,
max_camera_samples: 68_719_476_736,
max_triangles: MAX_CPU_TRIANGLES as u64,
max_bodies: MAX_CPU_BODIES,
max_geometry_bytes: 4 * GIB,
max_job_bytes: 12 * GIB,
max_work_units: MAX_CPU_WORK,
},
}
}
pub fn image_limits(self, progressive: bool) -> RenderLimits {
let limits = self.limits();
RenderLimits {
max_dimension: limits.max_dimension,
max_pixels: if progressive { limits.max_progressive_pixels } else { limits.max_pixels },
max_scratch_bytes: limits.max_job_bytes,
}
}
pub fn validate_samples(self, samples: u32) -> Result<()> {
let maximum = self.limits().max_samples_per_pixel;
if samples == 0 || samples > maximum {
return Err(JobError::new(ErrorCode::InvalidRequest, format!("CPU {self:?} samples_per_pixel must be 1..{maximum}")));
}
Ok(())
}
/// No geometry reads or allocations. This also validates offline saved intent.
pub fn validate_image(self, width: u32, height: u32, progressive: Option<CpuProgressiveOptions>) -> Result<()> {
let (w, h) = (u64::from(width), u64::from(height));
let limits = self.image_limits(progressive.is_some());
let pixels = w.checked_mul(h).ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "CPU pixel count overflow"))?;
if w == 0 || h == 0 || w > limits.max_dimension || h > limits.max_dimension || pixels > limits.max_pixels {
return Err(JobError::new(
ErrorCode::LimitExceeded,
format!(
"CPU {self:?} accepts dimensions 1..{} and at most {} pixels; select a larger resource profile explicitly",
limits.max_dimension, limits.max_pixels
),
));
}
if let Some(options) = progressive {
self.validate_samples(options.samples_per_pixel)?;
if pixels.checked_mul(u64::from(options.samples_per_pixel)).is_none_or(|n| n > self.limits().max_camera_samples) {
return Err(JobError::new(
ErrorCode::LimitExceeded,
format!("CPU {self:?} exceeds its {} camera-sample admission", self.limits().max_camera_samples),
));
}
}
Ok(())
}
/// A file may preserve large intent on every platform; only execution needs
/// the native 64-bit address space. No implicit downgrade to Compact occurs.
pub fn validate_execution(self) -> Result<()> {
if self != Self::Compact && (cfg!(target_arch = "wasm32") || usize::BITS < 64) {
return Err(JobError::new(ErrorCode::HostUnavailable, "large CPU profiles require a native 64-bit host"));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn large_intent_is_explicit_and_old_intent_remains_compact() {
assert_eq!(CpuBudget::default(), CpuBudget::Compact);
let p = Some(CpuProgressiveOptions { samples_per_pixel: 128, seed: 9 });
assert!(CpuBudget::Compact.validate_image(3840, 2160, p).is_err());
assert!(CpuBudget::Studio.validate_image(3840, 2160, p).is_ok());
assert!(CpuBudget::Studio.validate_image(7680, 4320, p).is_err());
assert!(CpuBudget::Workstation.validate_image(7680, 4320, p).is_ok());
assert_eq!(serde_json::from_str::<CpuBudget>("\"workstation\"").unwrap(), CpuBudget::Workstation);
assert!(serde_json::from_str::<CpuBudget>("\"unlimited\"").is_err());
}
#[test]
fn all_envelopes_have_checked_finite_bounds_and_no_automatic_resize() {
for budget in CpuBudget::ALL {
let limits = budget.limits();
assert!(limits.max_geometry_bytes < limits.max_job_bytes);
assert!(limits.max_job_bytes < NATIVE_CPU_POOL_BYTES);
assert!(limits.max_work_units <= MAX_CPU_WORK);
assert!(budget.validate_image(0, 1, None).is_err());
assert!(budget.validate_image(u32::MAX, u32::MAX, None).is_err());
assert!(budget.validate_samples(0).is_err());
assert!(budget.validate_samples(limits.max_samples_per_pixel + 1).is_err());
assert!(budget.validate_samples(limits.max_samples_per_pixel).is_ok());
}
let p = Some(CpuProgressiveOptions { samples_per_pixel: 4096, seed: 0 });
assert!(CpuBudget::Workstation.validate_image(8192, 8192, p).is_err());
assert!(CpuBudget::Workstation.validate_image(3840, 2160, p).is_ok());
}
}
File diff suppressed because it is too large Load Diff
@@ -47,6 +47,10 @@ fn fixture(raw: bool) -> (ImageHost, JobHandle, Arc<AtomicUsize>, Presentation)
let begins = Arc::new(AtomicUsize::new(0));
let host = ImageHost::cooperative_with(Arc::new(FixtureExecutor(begins.clone())));
let request = ImageRequest {
raster_punctual_lights: None,
raster_area_lights: None,
raster_exposure_ev: 0.0,
raster_lights: None,
studio_weights: None,
width: 16,
height: 16,
File diff suppressed because it is too large Load Diff
+81 -13
View File
@@ -49,6 +49,7 @@ fn add_bytes(sum: &mut u64, count: usize, size: usize, maximum: u64) -> Result<(
/// counted even when another owner already holds them. Allocator and codec internals are estimates.
/// Includes the CPU raster work estimate and its 384 MiB per-job check.
pub fn admit(scene: &ProductScene, request: &ImageRequest) -> Result<Admission> {
request.validate_raster_request()?;
if request.environment.is_some() || request.textures.is_some() {
return Err(JobError::new(ErrorCode::InvalidRequest, "captured GPU environments and textures require a registered image executor"));
}
@@ -79,7 +80,7 @@ pub fn admit(scene: &ProductScene, request: &ImageRequest) -> Result<Admission>
}
pub(crate) fn admit_base(scene: &ProductScene, request: &ImageRequest) -> Result<Admission> {
admit_raster(admit_source(scene, request, MAX_TRIANGLES)?)
admit_raster(admit_source(scene, request, MAX_TRIANGLES)?, request)
}
/// Common camera, dimension and source admission for registered executors, without any CPU
@@ -107,6 +108,9 @@ fn admit_source(scene: &ProductScene, request: &ImageRequest, max_triangles: u64
/// pixel/codec allowance, then let the ray tracer charge its actual BVH and film.
#[cfg(feature = "raytrace-cpu")]
pub(crate) fn admit_raytrace_source(scene: &ProductScene, request: &ImageRequest, budget: super::CpuBudget) -> Result<Admission> {
if request.has_explicit_raster_lights() || request.raster_exposure_ev != 0.0 {
return Err(invalid("Raster-only lighting and exposure cannot enter CPU ray tracing"));
}
budget.validate_execution()?;
budget.validate_image(request.width, request.height, request.raytrace.as_ref().and_then(|o| o.progressive))?;
let source = admit_source_with_budget(scene, request, budget.limits().max_triangles, budget)?;
@@ -162,6 +166,10 @@ fn admit_source_with_limits(
max_geometry_bytes: u64,
image_limits: crate::RenderLimits,
) -> Result<Source> {
request.validate_raster_request()?;
if request.has_explicit_raster_lights() && scene.has_transmission() {
return Err(invalid("explicit Raster lights do not implement physical transmission"));
}
if !request.camera.is_valid() {
return Err(JobError::new(ErrorCode::InvalidRequest, "invalid image camera"));
}
@@ -243,14 +251,16 @@ fn admit_source_with_limits(
}
/// The CPU raster scratch/preparation estimate and its 384 MiB per-job check.
fn admit_raster(source: Source) -> Result<Admission> {
fn admit_raster(source: Source, request: &ImageRequest) -> Result<Admission> {
let mut a = source.admission;
let task_light_bytes = request.raster_lights_task_bytes()?;
// Three bounded 16-byte lists, a conservative map-node allowance, and codec overhead.
let preparation = a
.triangles
.checked_mul(48)
.and_then(|n| a.face_appearances.checked_mul(160).and_then(|f| n.checked_add(f)))
.and_then(|n| n.checked_add(source.bodies * 128 + 262_144))
.and_then(|n| n.checked_add(task_light_bytes))
.ok_or_else(|| limit("work list size overflow"))?;
let total = a
.geometry_bytes
@@ -346,7 +356,10 @@ pub struct ImageTask {
sort_dst: usize,
pixels: Vec<[u8; 4]>,
depth: Vec<f32>,
// Weighted projected light work for the existing work ceiling.
pixel_tests: u64,
// Unweighted bounding-box pixel visits, matching pixel_done Running units.
pixel_progress_tests: u64,
pixel_done: u64,
draw: usize,
triangle: Option<Triangle>,
@@ -399,6 +412,7 @@ impl ImageTask {
pixels,
depth,
pixel_tests: 0,
pixel_progress_tests: 0,
pixel_done: 0,
draw: 0,
triangle: None,
@@ -417,7 +431,7 @@ impl ImageTask {
Stage::Clear | Stage::Opaque | Stage::Glass | Stage::Downsample => Progress {
phase: Phase::Running,
done: self.pixel_done,
total: self.pixel_tests + (self.sw * self.sh) as u64 + u64::from(self.request.width) * u64::from(self.request.height),
total: self.pixel_progress_tests + (self.sw * self.sh) as u64 + u64::from(self.request.width) * u64::from(self.request.height),
},
Stage::Encode => Progress {
phase: Phase::Encoding,
@@ -573,10 +587,12 @@ impl ImageTask {
if let Some(triangle) = self.project(draw)? {
let [x0, x1, y0, y1] = triangle.bounds;
let tests = ((x1 - x0 + 1) as u64).checked_mul((y1 - y0 + 1) as u64).ok_or_else(|| limit("pixel work overflow"))?;
self.pixel_tests = self.pixel_tests.checked_add(tests).ok_or_else(|| limit("pixel work overflow"))?;
let weighted_tests = tests.checked_mul(self.request.raster_light_work_factor()?).ok_or_else(|| limit("pixel work overflow"))?;
self.pixel_tests = self.pixel_tests.checked_add(weighted_tests).ok_or_else(|| limit("pixel work overflow"))?;
if self.pixel_tests > MAX_PIXEL_TESTS {
return Err(limit("projected product exceeds 128000000 pixel tests"));
}
self.pixel_progress_tests = self.pixel_progress_tests.checked_add(tests).ok_or_else(|| limit("pixel progress overflow"))?;
draw.depth = triangle.centre_depth;
if triangle.opacity >= 1.0 {
self.opaque.push(draw);
@@ -664,7 +680,7 @@ impl ImageTask {
}
let triangle = self.triangle.as_mut().ok_or_else(|| invalid("missing raster triangle"))?;
let eye = (self.request.camera.fov > 0.0).then(|| self.request.camera.eye());
raster_pixel(triangle, self.sw, self.back, eye, self.request.studio_weights, &mut self.pixels, &mut self.depth)?;
raster_pixel(triangle, self.sw, self.back, eye, &self.request, &mut self.pixels, &mut self.depth)?;
triangle.x += 1;
if triangle.x > triangle.bounds[1] {
triangle.x = triangle.bounds[0];
@@ -723,16 +739,18 @@ impl ImageTask {
}
}
#[allow(clippy::too_many_arguments)]
fn raster_pixel(
t: &Triangle,
width: usize,
back: Vec3,
eye: Option<Vec3>,
studio_weights: Option<[f64; 3]>,
request: &ImageRequest,
pixels: &mut [[u8; 4]],
depth: &mut [f32],
) -> Result<()> {
let studio_weights = request.studio_weights;
let raster_lights = request.raster_lights.as_deref();
let raster_exposure_ev = request.raster_exposure_ev;
let [a, b, c] = t.points;
let (x, y) = (t.x as f64 + 0.5, t.y as f64 + 0.5);
let w0 = ((b[0] - x) * (c[1] - y) - (b[1] - y) * (c[0] - x)) / t.area;
@@ -765,20 +783,59 @@ fn raster_pixel(
return Ok(());
}
let n = t.normals.iter().zip(weights).fold(Vec3::ZERO, |v, (n, w)| v + *n * (w / sum)).normalized().unwrap_or(Vec3::Z);
let colour = if let Some(surface) = t.surface {
let colour = if request.has_explicit_raster_lights() {
let lights = raster_lights.unwrap_or(&[]);
let surface = if let Some(surface) = t.surface {
surface
} else {
let [r, g, b] = t.colour;
crate::surface::OpaqueSurface::new(Vec3::new(r, g, b), 0.0, 1.0, 1.5).map_err(|e| invalid(&e.to_string()))?
};
let world = t.world.iter().zip(weights).fold(Vec3::ZERO, |p, (v, w)| p + *v * (w / sum));
let view = eye.map_or(back, |eye| eye - world);
let radiance = if request.has_local_raster_lights() {
crate::studio::shade_schematic(
surface,
n,
view,
world,
lights,
request.raster_punctual_lights.as_deref().unwrap_or(&[]),
request.raster_area_lights.as_deref().unwrap_or(&[]),
)
} else {
crate::studio::shade_directional(surface, n, view, lights)
}
.map_err(|e| invalid(&e.to_string()))?;
let mapped = crate::studio::tone_map(expose_raster(radiance, raster_exposure_ev)?).map_err(|e| invalid(&e.to_string()))?;
[mapped.x, mapped.y, mapped.z]
} else if let Some(surface) = t.surface {
let world = t.world.iter().zip(weights).fold(Vec3::ZERO, |p, (v, w)| p + *v * (w / sum));
let view = eye.map_or(back, |eye| eye - world);
let radiance = crate::studio::shade_weighted(surface, n, view, studio_weights.unwrap_or([1.0; 3])).map_err(|e| invalid(&e.to_string()))?;
let mapped = crate::studio::tone_map(radiance).map_err(|e| invalid(&e.to_string()))?;
let mapped = crate::studio::tone_map(expose_raster(radiance, raster_exposure_ev)?).map_err(|e| invalid(&e.to_string()))?;
[mapped.x, mapped.y, mapped.z]
} else if let Some(weights) = studio_weights {
} else if let Some(light_weights) = studio_weights {
let [r, g, b] = t.colour;
let surface = crate::surface::OpaqueSurface::new(Vec3::new(r, g, b), 0.0, 1.0, 1.5).map_err(|e| invalid(&e.to_string()))?;
let radiance = crate::studio::shade_weighted(surface, n, back, weights).map_err(|e| invalid(&e.to_string()))?;
let mapped = crate::studio::tone_map(radiance).map_err(|e| invalid(&e.to_string()))?;
let view = eye.map_or(back, |eye| {
let world = t.world.iter().zip(weights).fold(Vec3::ZERO, |p, (v, w)| p + *v * (w / sum));
eye - world
});
let radiance = crate::studio::shade_weighted(surface, n, view, light_weights).map_err(|e| invalid(&e.to_string()))?;
let mapped = crate::studio::tone_map(expose_raster(radiance, raster_exposure_ev)?).map_err(|e| invalid(&e.to_string()))?;
[mapped.x, mapped.y, mapped.z]
} else {
crate::raster::shade(n, back, t.colour)
// t.colour was already decoded. This historical schematic shade is
// clamped display-linear, not recoverable HDR radiance. Preserve it at
// zero; nonzero EV scales it without adding a new tone map or decode.
let legacy = crate::raster::shade(n, back, t.colour);
if raster_exposure_ev == 0.0 {
legacy
} else {
let exposed = expose_raster(Vec3::new(legacy[0], legacy[1], legacy[2]), raster_exposure_ev)?;
[exposed.x, exposed.y, exposed.z]
}
};
if colour.iter().any(|v| !v.is_finite()) {
return Err(invalid("non-finite shaded colour"));
@@ -798,6 +855,17 @@ fn raster_pixel(
Ok(())
}
fn expose_raster(value: Vec3, ev: f64) -> Result<Vec3> {
if ev == 0.0 {
return Ok(value);
}
let exposed = value * ev.exp2();
if !exposed.is_finite() || exposed.x < 0.0 || exposed.y < 0.0 || exposed.z < 0.0 {
return Err(invalid("invalid exposed Raster colour"));
}
Ok(exposed)
}
fn decode(v: u8) -> f64 {
let s = f64::from(v) / 255.0;
if s <= 0.04045 { s / 12.92 } else { ((s + 0.055) / 1.055).powf(2.4) }
+659 -1
View File
@@ -3,10 +3,105 @@ use crate::{Camera, ProductMesh, StandardView};
use solidcraft_geom::{Aabb3, Mesh};
use std::sync::Arc;
#[test]
fn raster_ev_scales_lambert_radiance_before_the_existing_single_tone_map() {
let mut part = square("matte", [0; 3], 1.0, Vec3::ZERO);
part.surface = Some(crate::surface::OpaqueSurface::new(Vec3::new(0.8, 0.5, 0.25), 0.0, 1.0, 1.0).unwrap());
let mut alpha = None;
for (ev, expected) in [(-1.0, [69, 77, 77, 255]), (0.0, [94, 104, 104, 255]), (1.0, [124, 135, 135, 255])] {
let mut input = request();
input.raster_exposure_ev = ev;
input.raster_lights =
Some(vec![crate::job::DirectionalLightOptions { direction: [0.0, -1.0, 0.0], color: [0.25, 0.5, 1.0], intensity: 2.0 }]);
let png = finish(ImageTask::new(scene(vec![part.clone()]), input, CancelToken::default()).unwrap());
let image = image::load_from_memory(&png).unwrap().to_rgba8();
// Original analytic oracle: [.4,.5,.5]/pi *2^EV -> Reinhard -> sRGB.
assert_eq!(image.get_pixel(32, 32).0, expected);
assert_eq!(image.get_pixel(0, 0).0, [0; 4]);
let coverage: Vec<u8> = image.pixels().map(|pixel| pixel[3]).collect();
if let Some(expected_alpha) = &alpha { assert_eq!(&coverage, expected_alpha) } else { alpha = Some(coverage) }
}
}
#[test]
fn raster_ev_covers_implicit_pbr_and_the_weighted_scalar_surrogate_with_original_oracles() {
for (ev, implicit_rgb, weighted_gray) in [(-1.0, [90, 73, 52], 6), (0.0, [120, 99, 73], 12), (1.0, [152, 130, 99], 20)] {
let mut input = request();
input.raster_exposure_ev = ev;
let mut part = square("matte", [0; 3], 1.0, Vec3::ZERO);
part.surface = Some(crate::surface::OpaqueSurface::new(Vec3::new(0.8, 0.5, 0.25), 0.0, 1.0, 1.0).unwrap());
let png = finish(ImageTask::new(scene(vec![part]), input.clone(), CancelToken::default()).unwrap());
let image = image::load_from_memory(&png).unwrap().to_rgba8();
// Front normal sees only studio Rim:1.5*.6=.9, so rho*.9/pi.
assert_eq!(image.get_pixel(32, 32).0, [implicit_rgb[0], implicit_rgb[1], implicit_rgb[2], 255]);
input.studio_weights = Some([0.0, 0.0, 1.0]);
let png = finish(ImageTask::new(scene(vec![square("scalar", [0; 3], 1.0, Vec3::ZERO)]), input, CancelToken::default()).unwrap());
let image = image::load_from_memory(&png).unwrap().to_rgba8();
// Black roughness1/IOR1.5 surrogate: .28125*F/pi, where the authored
// Schlick approximation F=.04+.96*(1-sqrt(.8))^5. Then2^EV/Reinhard/sRGB.
assert_eq!(image.get_pixel(32, 32).0, [weighted_gray, weighted_gray, weighted_gray, 255]);
}
}
#[test]
fn raster_ev_legacy_linear_surrogate_preserves_straight_alpha_and_background() {
for (ev, gray) in [(-1.0, 11), (0.0, 19), (1.0, 30)] {
let mut input = request();
input.raster_exposure_ev = ev;
let part = square("legacy", [0; 3], 0.5, Vec3::ZERO);
let png = finish(ImageTask::new(scene(vec![part.clone()]), input.clone(), CancelToken::default()).unwrap());
let image = image::load_from_memory(&png).unwrap().to_rgba8();
// Front-view scalar shade: spec=.25*(5/6)^20 on already decoded black;
// multiply by2^EV then piecewise sRGB. No Reinhard on this old branch.
assert_eq!(image.get_pixel(32, 32).0, [gray, gray, gray, 128]);
assert_eq!(image.get_pixel(0, 0).0, [0; 4]);
input.transparent_background = false;
let png = finish(ImageTask::new(scene(vec![part]), input, CancelToken::default()).unwrap());
assert_eq!(image::load_from_memory(&png).unwrap().to_rgba8().get_pixel(0, 0).0, [20, 40, 60, 255]);
}
}
#[test]
fn raster_ev_zero_wire_is_exact_and_nonfinite_bounds_or_wrong_integrator_refuse() {
let part = scene(vec![square("legacy", [160, 120, 80], 0.5, Vec3::ZERO)]);
let mut input = request();
let wire = serde_json::to_value(&input).unwrap();
assert!(wire.get("raster_exposure_ev").is_none());
let decoded: ImageRequest = serde_json::from_value(wire).unwrap();
assert_eq!(decoded.raster_exposure_ev, 0.0);
let old = finish(ImageTask::new(part.clone(), input.clone(), CancelToken::default()).unwrap());
assert_eq!(finish(ImageTask::new(part.clone(), decoded, CancelToken::default()).unwrap()), old);
for zero in [0.0, -0.0] {
input.raster_exposure_ev = zero;
assert_eq!(finish(ImageTask::new(part.clone(), input.clone(), CancelToken::default()).unwrap()), old);
}
for value in [-32.0, 32.0] {
input.raster_exposure_ev = value;
assert!(admit(&part, &input).is_ok());
}
for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -33.0, 33.0] {
input.raster_exposure_ev = value;
assert_eq!(admit(&part, &input).unwrap_err().code, ErrorCode::InvalidRequest);
}
input.raster_exposure_ev = 1.0;
input.integrator = crate::job::Integrator::Raytrace;
assert_eq!(admit(&part, &input).unwrap_err().code, ErrorCode::InvalidRequest);
assert!(crate::job::interactive::cpu_request(&input).is_err());
#[cfg(feature = "raytrace-cpu")]
{
input.integrator = crate::job::Integrator::Raster;
assert!(crate::raytrace::image::admit(&part, &input, &crate::job::RaytraceOptions::default()).is_err());
}
}
fn request() -> ImageRequest {
let mut camera = Camera { distance: 50.0, ..Default::default() };
camera.set_view(StandardView::Front);
ImageRequest {
raster_punctual_lights: None,
raster_area_lights: None,
raster_exposure_ev: 0.0,
raster_lights: None,
studio_weights: None,
environment: None,
textures: None,
@@ -192,7 +287,7 @@ fn registered_executor_geometry_admission_excludes_the_cpu_raster_estimate() {
let mut r = request();
r.width = 2048;
r.height = 2048;
let cpu = admit_raster(admit_source(&source, &r, 1_048_576).unwrap()).unwrap_err();
let cpu = admit_raster(admit_source(&source, &r, 1_048_576).unwrap(), &r).unwrap_err();
assert_eq!(cpu.code, ErrorCode::LimitExceeded);
assert!(cpu.message.contains("384 MiB"), "{}", cpu.message);
let geometry = admit_geometry(&source, &r, 1_048_576).unwrap();
@@ -202,3 +297,566 @@ fn registered_executor_geometry_admission_excludes_the_cpu_raster_estimate() {
// The CPU host itself keeps rejecting the source.
assert_eq!(admit(&source, &r).unwrap_err().code, ErrorCode::LimitExceeded);
}
// Original fixture: a plane at x=40, with exact +X normals. The perspective
// camera looks diagonally from (50/sqrt(2), -50/sqrt(2), 0). Its actual eye is
// on the -X side of this plane, although camera.back has a positive X component.
// The wide field of view keeps the off-axis plane in the image.
fn weighted_matte_view_scene(explicit: bool) -> ProductScene {
let color = [96, 160, 220];
let mut part = square("weighted matte plane", color, 1.0, Vec3::ZERO);
let mesh = Arc::make_mut(&mut part.mesh);
for p in &mut mesh.positions {
*p = Vec3::new(40.0, p.x + 10.0, p.z);
}
mesh.normals = vec![Vec3::X; 4];
if explicit {
let [r, g, b] = color.map(decode);
part.surface = Some(crate::surface::OpaqueSurface::new(Vec3::new(r, g, b), 0.0, 1.0, 1.5).unwrap());
}
scene(vec![part])
}
fn weighted_matte_view_request(perspective: bool) -> ImageRequest {
let mut input = request();
input.camera.yaw = -std::f64::consts::FRAC_PI_4;
input.camera.distance = if perspective { 50.0 } else { 100.0 };
input.camera.fov = if perspective { 2.4 } else { 0.0 };
input.transparent_background = false;
// Deliberately not equal: accidentally using light weights instead of
// perspective barycentric weights must not be an equivalent calculation.
input.studio_weights = Some([1.0, 0.35, 0.8]);
input
}
#[test]
fn weighted_scalar_matte_uses_the_same_perspective_view_as_its_explicit_surface() {
let input = weighted_matte_view_request(true);
assert!(input.camera.back().x > 0.0 && input.camera.eye().x < 40.0);
let scalar = finish(ImageTask::new(weighted_matte_view_scene(false), input.clone(), CancelToken::default()).unwrap());
let explicit = finish(ImageTask::new(weighted_matte_view_scene(true), input, CancelToken::default()).unwrap());
// Same admitted geometry, exact unit-axis normals, colour decoder, matte
// response, lights, camera, buffers and encoder. No tolerance or golden.
assert_eq!(scalar, explicit);
let image = image::load_from_memory(&scalar).unwrap().to_rgba8();
assert_eq!(image.get_pixel(0, 0).0, [20, 40, 60, 255]);
// Actual view is behind +X everywhere on the plane, so the existing
// one-sided BRDF returns zero. This also proves visible product coverage.
assert!(image.pixels().any(|pixel| pixel.0 == [0, 0, 0, 255]));
}
#[test]
fn weighted_scalar_matte_keeps_exact_orthographic_surface_equivalence() {
let input = weighted_matte_view_request(false);
let scalar = finish(ImageTask::new(weighted_matte_view_scene(false), input.clone(), CancelToken::default()).unwrap());
let explicit = finish(ImageTask::new(weighted_matte_view_scene(true), input, CancelToken::default()).unwrap());
assert_eq!(scalar, explicit);
let image = image::load_from_memory(&scalar).unwrap().to_rgba8();
assert_eq!(image.get_pixel(0, 0).0, [20, 40, 60, 255]);
assert!(image.pixels().any(|pixel| pixel.0 != [20, 40, 60, 255] && pixel.0 != [0, 0, 0, 255]));
}
#[test]
fn weighted_scalar_matte_mute_still_removes_only_product_light() {
let mut input = weighted_matte_view_request(false);
let lit = finish(ImageTask::new(weighted_matte_view_scene(false), input.clone(), CancelToken::default()).unwrap());
input.studio_weights = Some([0.0; 3]);
let muted = finish(ImageTask::new(weighted_matte_view_scene(false), input, CancelToken::default()).unwrap());
assert_ne!(lit, muted);
for bytes in [&lit, &muted] {
let image = image::load_from_memory(bytes).unwrap().to_rgba8();
assert_eq!(image.get_pixel(0, 0).0, [20, 40, 60, 255]);
}
let image = image::load_from_memory(&muted).unwrap().to_rgba8();
assert!(image.pixels().any(|pixel| pixel.0 == [0, 0, 0, 255]));
}
// Original analytic reciprocal-w oracle: the expected surface point is found
// by intersecting a closed-form camera ray with a plane, not by asking either
// raster shading branch to interpolate world coordinates for the other.
#[test]
fn weighted_scalar_perspective_uses_closed_form_ray_plane_world_with_unequal_clip_w() {
let vertices = [Vec3::new(-7.0 / 4.0, -8.0, 7.0 / 4.0), Vec3::new(1.0 / 2.0, -6.0, 7.0 / 2.0), Vec3::new(-3.0, -2.0, -1.0)];
// The reversed winding faces the eye. This smooth normal is in the
// geometric hemisphere and deliberately separates the three view oracles.
let normal = Vec3::new(3.0, 1.0, 0.0) / 10.0_f64.sqrt();
let part = ProductMesh {
name: "analytic reciprocal-w matte".into(),
mesh: Arc::new(Mesh {
positions: vertices.to_vec(),
normals: vec![normal; 3],
triangles: vec![[0, 2, 1]],
tri_face: vec![0],
..Default::default()
}),
color: [0; 3],
opacity: 1.0,
surface: None,
transmission: None,
faces: Vec::new(),
};
let mut input = request();
input.width = 4;
input.height = 4;
input.camera =
Camera { target: Vec3::ZERO, yaw: 0.0, pitch: 0.0, roll: 0.0, distance: 10.0, fov: std::f64::consts::FRAC_PI_2, up: crate::UpAxis::Z };
input.transparent_background = false;
let light_weights = [1.0, 0.35, 0.8];
input.studio_weights = Some(light_weights);
let mut task = ImageTask::new(scene(vec![part]), input, CancelToken::default()).unwrap();
assert_eq!((task.sw, task.sh), (8, 8));
let eye = Vec3::new(0.0, -10.0, 0.0);
let back = Vec3::new(0.0, -1.0, 0.0);
assert_eq!(task.request.camera.eye(), eye);
assert_eq!(task.back, back);
// Actual production projection of [A,C,B], not a hand-filled Triangle.
// With tan(fov/2)=1, clip w is depth from the eye: [2,8,4].
let projected = task.project(Draw::default()).unwrap().unwrap();
assert!(projected.surface.is_none());
let expected_points = [[0.5, 0.5], [2.5, 4.5], [4.5, 0.5]];
for ((actual, expected), (actual_inv_w, expected_inv_w)) in
projected.points.iter().zip(expected_points).zip(projected.inv_w.iter().zip([0.5, 0.125, 0.25]))
{
assert!((actual[0] - expected[0]).abs() < 1e-12);
assert!((actual[1] - expected[1]).abs() < 1e-12);
assert!((*actual_inv_w - expected_inv_w).abs() < 1e-12);
}
assert_eq!(projected.world, [vertices[0], vertices[2], vertices[1]]);
// Pixel sample (2.5,2.5) gives the analytic forward ray
// E + t*(-3/8,1,3/8). The triangle plane is 4x-y-4z=-6:
// plane(E)=10 and plane(ray)=-4, hence t=4 exactly.
let plane = Vec3::new(4.0, -1.0, -4.0);
assert_eq!((vertices[2] - vertices[0]).cross(vertices[1] - vertices[0]), plane * 4.0);
assert!(normal.dot(plane) > 0.0);
for vertex in vertices {
assert_eq!(plane.dot(vertex), -6.0);
}
let ray = Vec3::new(-3.0 / 8.0, 1.0, 3.0 / 8.0);
assert_eq!(plane.dot(eye), 10.0);
assert_eq!(plane.dot(ray), -4.0);
let expected_world = Vec3::new(-3.0 / 2.0, -6.0, 3.0 / 2.0);
assert_eq!(eye + ray * 4.0, expected_world);
let expected_view = Vec3::new(3.0 / 2.0, -4.0, -3.0 / 2.0);
assert_eq!(eye - expected_world, expected_view);
// Screen barycentrics [1/4,1/2,1/4] and clip w [2,8,4]
// imply world barycentrics [1/2,1/4,1/4]. An AFFINE world
// would instead be (-29/16,-9/2,13/16), a different camera ray.
let affine_world = Vec3::new(-29.0 / 16.0, -9.0 / 2.0, 13.0 / 16.0);
let affine_view = eye - affine_world;
assert!((normal.dot(expected_view) - 1.0 / (2.0 * 10.0_f64.sqrt())).abs() < 1e-14);
assert!((normal.dot(affine_view) + 1.0 / (16.0 * 10.0_f64.sqrt())).abs() < 1e-14);
assert!(normal.dot(back) < 0.0);
// Black base removes diffuse but retains dielectric Fresnel. Only the
// key light is above this normal. For roughness=1, D=1/pi and Smith
// visibility reduces to 1/((1+nv)*(1+nl)). F0=(1/5)^2.
let nv = 1.0 / 820.0_f64.sqrt();
let nl = 9.0 / (5.0 * 10.0_f64.sqrt());
let vh = ((1.0 - 3.0 / (5.0 * 82.0_f64.sqrt())) / 2.0).sqrt();
let fresnel = 1.0 / 25.0 + (24.0 / 25.0) * (1.0 - vh).powi(5);
let expected_radiance = 3.0 * nl * fresnel / (std::f64::consts::PI * (1.0 + nv) * (1.0 + nl));
let matte = crate::surface::OpaqueSurface::new(Vec3::ZERO, 0.0, 1.0, 1.5).unwrap();
let reference = crate::studio::shade_weighted(matte, normal, expected_view, light_weights).unwrap();
assert!((reference - Vec3::new(expected_radiance, expected_radiance, expected_radiance)).len() < 1e-14);
assert_eq!(crate::studio::shade_weighted(matte, normal, affine_view, light_weights).unwrap(), Vec3::ZERO);
assert_eq!(crate::studio::shade_weighted(matte, normal, back, light_weights).unwrap(), Vec3::ZERO);
// The original radical expression gives radiance0.0144120124528...
// Reinhard then sRGB yields31.6843707068... on the8-bit scale, hence32.
// Inspect the real production supersample before downsampling; no fake
// renderer, injected pixel, shared-branch PNG equality or golden is used.
for _ in 0..2048 {
if task.stage == Stage::Downsample {
break;
}
assert!(matches!(task.step(1).unwrap(), TaskPoll::Pending(_)));
}
assert!(task.stage == Stage::Downsample);
let sample = 2 * task.sw + 2;
assert!(task.depth[sample].is_finite(), "the analytic sample must be covered");
assert_eq!(task.pixels[sample], [32, 32, 32, 255]);
// Finish through the ordinary encoder as well; the unoccupied corner
// remains the original opaque background.
let png = finish(task);
let image = image::load_from_memory(&png).unwrap().to_rgba8();
assert_eq!((image.width(), image.height()), (4, 4));
assert_eq!(image.get_pixel(3, 3).0, [20, 40, 60, 255]);
}
#[test]
fn explicit_raster_linear_rgb_reaches_png_with_one_display_transfer() {
let mut part = square("matte", [0; 3], 1.0, Vec3::ZERO);
part.surface = Some(crate::surface::OpaqueSurface::new(Vec3::new(0.8, 0.5, 0.25), 0.0, 1.0, 1.0).unwrap());
let mut input = request();
// Orthographic Front is exactly normal to the square, so no perspective-view approximation.
assert_eq!(input.camera.fov, 0.0);
input.raster_lights = Some(vec![crate::job::DirectionalLightOptions { direction: [0.0, -1.0, 0.0], color: [0.25, 0.5, 1.0], intensity: 2.0 }]);
let png = finish(ImageTask::new(scene(vec![part]), input, CancelToken::default()).unwrap());
let image = image::load_from_memory(&png).unwrap().to_rgba8();
// Independently derived: [.4,.5,.5]/pi -> Reinhard -> piecewise sRGB -> nearest byte.
for (x, y) in [(32, 32), (31, 32), (32, 31)] {
assert_eq!(image.get_pixel(x, y).0, [94, 104, 104, 255]);
}
assert_eq!(image.get_pixel(0, 0).0, [0; 4]);
}
#[test]
fn explicit_raster_three_four_five_cosine_reaches_png() {
let mut part = square("matte", [0; 3], 1.0, Vec3::ZERO);
part.surface = Some(crate::surface::OpaqueSurface::new(Vec3::new(0.8, 0.5, 0.25), 0.0, 1.0, 1.0).unwrap());
let mut input = request();
input.raster_lights = Some(vec![crate::job::DirectionalLightOptions { direction: [3.0, -4.0, 0.0], color: [1.0; 3], intensity: 2.0 }]);
let png = finish(ImageTask::new(scene(vec![part]), input, CancelToken::default()).unwrap());
let image = image::load_from_memory(&png).unwrap().to_rgba8();
// Independently derived: [1.28,.8,.4]/pi -> Reinhard -> sRGB.
assert_eq!(image.get_pixel(32, 32).0, [146, 124, 94, 255]);
}
#[test]
fn explicit_empty_raster_rig_is_dark_while_absence_keeps_legacy() {
let part = scene(vec![square("part", [200, 180, 160], 1.0, Vec3::ZERO)]);
let mut input = request();
input.transparent_background = false;
let absent = finish(ImageTask::new(part.clone(), input.clone(), CancelToken::default()).unwrap());
let wire = serde_json::to_value(&input).unwrap();
assert!(wire.get("raster_lights").is_none());
let mut null = wire.clone();
null["raster_lights"] = serde_json::Value::Null;
let legacy: ImageRequest = serde_json::from_value(null).unwrap();
assert_eq!(finish(ImageTask::new(part.clone(), legacy, CancelToken::default()).unwrap()), absent);
input.raster_lights = Some(Vec::new());
let dark = finish(ImageTask::new(part, input, CancelToken::default()).unwrap());
assert_ne!(dark, absent);
let image = image::load_from_memory(&dark).unwrap().to_rgba8();
assert_eq!(image.get_pixel(32, 32).0, [0, 0, 0, 255]);
assert_eq!(image.get_pixel(0, 0).0, [20, 40, 60, 255]);
}
#[test]
fn explicit_raster_request_rejects_capacity_conflicts_and_foreign_integrators() {
let part = scene(vec![square("part", [200; 3], 1.0, Vec3::ZERO)]);
let light = crate::job::DirectionalLightOptions { direction: [0.0, -1.0, 0.0], color: [1.0; 3], intensity: 1.0 };
let mut input = request();
let mut oversized = Vec::with_capacity(9);
oversized.push(light);
input.raster_lights = Some(oversized);
assert_eq!(admit(&part, &input).unwrap_err().code, ErrorCode::InvalidRequest);
input.raster_lights = Some(Vec::new());
input.studio_weights = Some([1.0; 3]);
assert_eq!(admit(&part, &input).unwrap_err().code, ErrorCode::InvalidRequest);
input.studio_weights = None;
input.raytrace = Some(Default::default());
assert_eq!(admit(&part, &input).unwrap_err().code, ErrorCode::InvalidRequest);
input.raytrace = None;
input.integrator = crate::job::Integrator::Raytrace;
assert_eq!(admit(&part, &input).unwrap_err().code, ErrorCode::InvalidRequest);
}
#[test]
fn explicit_raster_scalar_surrogate_preserves_straight_alpha_without_claiming_glass() {
let part = square("scalar", [0; 3], 0.5, Vec3::ZERO);
let mut input = request();
input.raster_lights = Some(vec![crate::job::DirectionalLightOptions { direction: [0.0, -1.0, 0.0], color: [1.0; 3], intensity: 2.0 }]);
let png = finish(ImageTask::new(scene(vec![part.clone()]), input.clone(), CancelToken::default()).unwrap());
let image = image::load_from_memory(&png).unwrap().to_rgba8();
// Scalar surrogate rho=0, IOR=1.5, roughness=1: F0/4pi=.01/pi.
// Intensity2 gives .02/pi; Reinhard + sRGB independently rounds to19.
assert_eq!(image.get_pixel(32, 32).0, [19, 19, 19, 128]);
let mut physical = part;
physical.transmission = Some(crate::transmission::Transmission::new(Vec3::ZERO, None).unwrap());
assert_eq!(admit(&scene(vec![physical]), &input).unwrap_err().code, ErrorCode::InvalidRequest);
}
#[test]
fn direct_raster_task_accounts_empty_rig_capacity_without_changing_dark_or_legacy_paths() {
let part = scene(vec![square("part", [200; 3], 1.0, Vec3::ZERO)]);
let legacy = request();
let legacy_bytes = admit(&part, &legacy).unwrap().estimated_total_bytes;
assert!(ImageTask::new(part.clone(), legacy, CancelToken::default()).unwrap().request.raster_lights.is_none());
let fee = (8 * std::mem::size_of::<crate::job::DirectionalLightOptions>()) as u64;
let mut dark_png = None;
for capacity in [0, 8] {
let mut input = request();
input.raster_lights = Some(Vec::with_capacity(capacity));
let retained_capacity = input.raster_lights.as_ref().unwrap().capacity();
assert!(retained_capacity >= capacity && retained_capacity <= 8);
let actual_bytes = (retained_capacity * std::mem::size_of::<crate::job::DirectionalLightOptions>()) as u64;
let admitted = admit(&part, &input).unwrap();
assert_eq!(admitted.estimated_total_bytes, legacy_bytes + fee);
assert!(admitted.estimated_total_bytes - legacy_bytes >= actual_bytes);
// This is the public direct constructor, not host try_copy/Record fees.
let task = ImageTask::new(part.clone(), input, CancelToken::default()).unwrap();
assert_eq!(task.request.raster_lights.as_ref().unwrap().capacity(), retained_capacity);
assert_eq!(task.admission.estimated_total_bytes, legacy_bytes + fee);
let png = finish(task);
let image = image::load_from_memory(&png).unwrap().to_rgba8();
assert_eq!(image.get_pixel(32, 32).0, [0, 0, 0, 255]);
if let Some(expected) = &dark_png {
assert_eq!(&png, expected);
} else {
dark_png = Some(png);
}
}
}
fn raster_local_triangle() -> Triangle {
let surface = crate::surface::OpaqueSurface::new(Vec3::new(0.8, 0.5, 0.25), 0.0, 1.0, 1.0).unwrap();
Triangle {
points: [[-1.0, -1.0, 0.5], [3.0, -1.0, 0.5], [-1.0, 3.0, 0.5]],
world: [Vec3::new(-1.0, 0.0, 1.0), Vec3::new(3.0, 0.0, 1.0), Vec3::new(-1.0, 0.0, -3.0)],
normals: [-Vec3::Y; 3],
inv_w: [1.0; 3],
colour: [0.0; 3],
opacity: 1.0,
surface: Some(surface),
centre_depth: 0.0,
area: 16.0,
bounds: [0, 0, 0, 0],
x: 0,
y: 0,
}
}
fn raster_local_point() -> crate::job::PunctualLightOptions {
crate::job::PunctualLightOptions {
position_mm: [0.5, -10.0, -0.5],
color: [0.25, 0.5, 1.0],
intensity: 2.0,
reference_distance_mm: 10.0,
minimum_distance_mm: 0.001,
cone: None,
}
}
fn raster_local_area() -> crate::job::RectangleLightOptions {
crate::job::RectangleLightOptions {
center_mm: [0.5, -10.0, -0.5],
edge_u_mm: [2.0, 0.0, 0.0],
edge_v_mm: [0.0, 0.0, -2.0],
color: [0.25, 0.5, 1.0],
intensity: 2.0,
}
}
fn raster_local_pixel(triangle: &Triangle, input: &ImageRequest) -> [u8; 4] {
let mut pixels = [[0; 4]];
let mut depth = [f32::INFINITY];
raster_pixel(triangle, 1, -Vec3::Y, None, input, &mut pixels, &mut depth).unwrap();
pixels[0]
}
fn raster_local_expected(numerators: [f64; 3]) -> [u8; 4] {
let rgb = numerators.map(|n| {
let linear = n / std::f64::consts::PI;
solidcraft_geom::srgb::encode8(linear / (1.0 + linear))
});
[rgb[0], rgb[1], rgb[2], 255]
}
#[test]
fn raster_local_point_pixel_matches_independent_lambert_and_single_exposure_oracles() {
// Affine barycentrics (1/4,3/8,3/8) put this sample at(.5,0,-.5).
// Point is10mm along the normal, at its10mm reference: RGB*rho*I/pi.
let mut input = request();
input.raster_punctual_lights = Some(vec![raster_local_point()]);
for (ev, expected) in [(-1.0, [69, 77, 77, 255]), (0.0, [94, 104, 104, 255]), (1.0, [124, 135, 135, 255])] {
input.raster_exposure_ev = ev;
assert_eq!(raster_local_pixel(&raster_local_triangle(), &input), expected);
}
input.raster_exposure_ev = 0.0;
input.raster_punctual_lights.as_mut().unwrap()[0].position_mm[1] = -20.0;
assert_eq!(raster_local_pixel(&raster_local_triangle(), &input), raster_local_expected([0.1, 0.125, 0.125]));
}
#[test]
fn raster_local_spot_area_and_mixed_pixels_have_original_closed_form_contributions() {
let mut point = raster_local_point();
point.cone = Some(crate::job::SpotConeOptions {
emitted_axis: [3.0_f64.sqrt(), 1.0, 0.0],
inner_half_angle_radians: 0.8_f64.acos(),
outer_half_angle_radians: 0.2_f64.acos(),
});
let mut input = request();
input.raster_punctual_lights = Some(vec![point]);
// Axis cosine1/2 lies halfway between(4/5,1/5), so smoothstep=1/2.
assert_eq!(raster_local_pixel(&raster_local_triangle(), &input), raster_local_expected([0.2, 0.25, 0.25]));
input.raster_punctual_lights = None;
input.raster_area_lights = Some(vec![raster_local_area()]);
// Area4 * I2 / r^2(100) =.08 before rho*colour/pi.
assert_eq!(raster_local_pixel(&raster_local_triangle(), &input), raster_local_expected([0.016, 0.02, 0.02]));
input.raster_lights = Some(vec![crate::job::DirectionalLightOptions { direction: [0.0, -1.0, 0.0], color: [1.0; 3], intensity: 1.0 }]);
input.raster_punctual_lights = Some(vec![raster_local_point()]);
assert_eq!(raster_local_pixel(&raster_local_triangle(), &input), raster_local_expected([1.216, 1.02, 0.77]));
input.raster_lights = None;
input.raster_punctual_lights = None;
input.raster_area_lights.as_mut().unwrap()[0].edge_v_mm = [0.0, 0.0, 2.0];
assert_eq!(raster_local_pixel(&raster_local_triangle(), &input), [0, 0, 0, 255]);
}
#[test]
fn raster_local_pixel_uses_reciprocal_w_world_position_and_zero_coincidence_policy() {
let mut triangle = raster_local_triangle();
triangle.inv_w = [1.0, 2.0, 8.0];
// Weighted world=(-1/4,0,-2), not the affine(.5,0,-.5).
let mut point = raster_local_point();
point.position_mm = [-0.25, -1.0, -2.0];
point.reference_distance_mm = 1.0;
point.color = [1.0; 3];
let mut input = request();
input.raster_punctual_lights = Some(vec![point]);
assert_eq!(raster_local_pixel(&triangle, &input), raster_local_expected([1.6, 1.0, 0.5]));
input.raster_punctual_lights.as_mut().unwrap()[0].position_mm = [-0.25, 0.0, -2.0];
assert_eq!(raster_local_pixel(&triangle, &input), [0, 0, 0, 255]);
}
#[test]
fn raster_local_real_task_empty_rig_is_dark_and_null_keeps_exact_legacy_png() {
let part = scene(vec![square("opaque", [200, 180, 160], 1.0, Vec3::ZERO)]);
let mut input = request();
input.transparent_background = false;
let legacy = finish(ImageTask::new(part.clone(), input.clone(), CancelToken::default()).unwrap());
let mut wire = serde_json::to_value(&input).unwrap();
wire["raster_punctual_lights"] = serde_json::Value::Null;
wire["raster_area_lights"] = serde_json::Value::Null;
let decoded: ImageRequest = serde_json::from_value(wire).unwrap();
assert_eq!(finish(ImageTask::new(part.clone(), decoded, CancelToken::default()).unwrap()), legacy);
for family in [0, 1] {
let mut explicit = input.clone();
if family == 0 {
explicit.raster_punctual_lights = Some(Vec::new());
} else {
explicit.raster_area_lights = Some(Vec::new());
}
let png = finish(ImageTask::new(part.clone(), explicit, CancelToken::default()).unwrap());
let image = image::load_from_memory(&png).unwrap().to_rgba8();
assert_eq!(image.get_pixel(32, 32).0, [0, 0, 0, 255]);
assert_eq!(image.get_pixel(0, 0).0, [20, 40, 60, 255]);
assert_ne!(png, legacy);
}
}
#[test]
fn raster_local_real_task_admits_spare_capacity_and_refuses_physical_transmission() {
let part = scene(vec![square("opaque", [200; 3], 1.0, Vec3::ZERO)]);
let mut input = request();
let legacy_bytes = admit(&part, &input).unwrap().estimated_total_bytes;
input.raster_punctual_lights = Some(Vec::with_capacity(8));
input.raster_area_lights = Some(Vec::with_capacity(4));
let fee = (8 * std::mem::size_of::<crate::job::PunctualLightOptions>() + 4 * std::mem::size_of::<crate::job::RectangleLightOptions>()) as u64;
assert_eq!(admit(&part, &input).unwrap().estimated_total_bytes, legacy_bytes + fee);
let capacities = (input.raster_punctual_lights.as_ref().unwrap().capacity(), input.raster_area_lights.as_ref().unwrap().capacity());
let task = ImageTask::new(part, input, CancelToken::default()).unwrap();
assert_eq!(task.request.raster_punctual_lights.as_ref().unwrap().capacity(), capacities.0);
assert_eq!(task.request.raster_area_lights.as_ref().unwrap().capacity(), capacities.1);
assert_eq!(task.admission.estimated_total_bytes, legacy_bytes + fee);
let mut physical = square("physical", [200; 3], 1.0, Vec3::ZERO);
physical.transmission = Some(crate::transmission::Transmission::new(Vec3::ZERO, None).unwrap());
assert_eq!(admit(&scene(vec![physical]), &task.request).unwrap_err().code, ErrorCode::InvalidRequest);
}
#[test]
fn raster_local_light_work_remains_inside_original_cap_and_step_cancellation() {
let part = scene(vec![square("opaque", [200; 3], 1.0, Vec3::ZERO)]);
let mut legacy = request();
legacy.raster_lights = Some(vec![crate::job::DirectionalLightOptions { direction: [0.0, -1.0, 0.0], color: [1.0; 3], intensity: 1.0 }]);
let mut local = legacy.clone();
local.raster_punctual_lights = Some(vec![raster_local_point()]);
local.raster_area_lights = Some(vec![raster_local_area()]);
let cancel = CancelToken::default();
let mut task = ImageTask::new(part.clone(), local, cancel.clone()).unwrap();
let triangle = task.project(Draw { body: 0, triangle: 0, depth: 0.0 }).unwrap().unwrap();
let [x0, x1, y0, y1] = triangle.bounds;
let pixels = ((x1 - x0 + 1) * (y1 - y0 + 1)) as u64;
task.pixel_tests = MAX_PIXEL_TESTS - pixels * 3 + 1;
assert_eq!(task.prepare_step().unwrap_err().code, ErrorCode::LimitExceeded);
let mut old_task = ImageTask::new(part, legacy, CancelToken::default()).unwrap();
old_task.pixel_tests = MAX_PIXEL_TESTS - pixels * 3 + 1;
old_task.prepare_step().unwrap();
assert_eq!(old_task.pixel_tests, MAX_PIXEL_TESTS - pixels * 2 + 1, "directional-only budget is unchanged");
cancel.cancel();
assert_eq!(task.step(1).err().unwrap().code, ErrorCode::Cancelled);
}
#[test]
fn raster_local_running_progress_counts_pixel_visits_and_reaches_total_before_encoding() {
// Cost is1 for unchanged implicit/directional-only routes,3 for the minimal
// mixed rig and20 for8D+8 shared point/spot+4area. Progress counts actual
// clear/draw/downsample visits, never BRDF evaluations or light intensity.
for (factor, directional_count, punctual_count, area_count) in [(1u64, 0usize, 0usize, 0usize), (1, 1, 0, 0), (3, 1, 1, 1), (20, 8, 8, 4)] {
let mut input = request();
input.width = 8;
input.height = 8;
if directional_count > 0 {
input.raster_lights =
Some(vec![crate::job::DirectionalLightOptions { direction: [0.0, -1.0, 0.0], color: [1.0; 3], intensity: 1.0 }; directional_count]);
}
if punctual_count > 0 {
let mut points = vec![raster_local_point(); punctual_count];
points[punctual_count - 1].cone =
Some(crate::job::SpotConeOptions { emitted_axis: [0.0, 1.0, 0.0], inner_half_angle_radians: 0.2, outer_half_angle_radians: 0.5 });
input.raster_punctual_lights = Some(points);
}
if area_count > 0 {
input.raster_area_lights = Some(vec![raster_local_area(); area_count]);
}
let back = input.camera.back();
let part = scene(vec![
square("opaque", [160; 3], 1.0, Vec3::ZERO),
square("alpha far", [160; 3], 0.5, back * -5.0),
square("alpha nearer", [160; 3], 0.5, back * -3.0),
]);
let mut task = ImageTask::new(part, input, CancelToken::default()).unwrap();
for _ in 0..100_000 {
if task.stage == Stage::Clear {
break;
}
assert!(matches!(task.stage, Stage::Looks | Stage::Prepare | Stage::Sort));
assert!(matches!(task.step(1).unwrap(), TaskPoll::Pending(_)));
}
assert!(task.stage == Stage::Clear, "bounded fixture must finish preparation");
let mut bbox_visits = 0u64;
for draw in task.opaque.iter().chain(&task.glass) {
let triangle = task.project(*draw).unwrap().unwrap();
let [x0, x1, y0, y1] = triangle.bounds;
bbox_visits += ((x1 - x0 + 1) * (y1 - y0 + 1)) as u64;
}
assert!(bbox_visits > 0);
assert_eq!(task.pixel_tests, bbox_visits * factor, "weighted128M work guard remains authoritative");
let clear_visits = (task.sw * task.sh) as u64;
let output_visits = u64::from(task.request.width) * u64::from(task.request.height);
let expected_total = clear_visits + bbox_visits + output_visits;
let mut last_done = 0;
let mut saw_opaque = false;
let mut saw_downsample = false;
let mut saw_running_complete = false;
for _ in 0..100_000 {
if task.stage == Stage::Encode {
break;
}
let progress = task.progress();
assert_eq!(progress.phase, Phase::Running);
assert_eq!(progress.total, expected_total, "total must not scale with {factor} lights");
assert!(progress.done >= last_done && progress.done <= progress.total);
if !saw_opaque && task.stage == Stage::Opaque {
assert_eq!(progress.done, clear_visits, "clear units unchanged");
saw_opaque = true;
}
if !saw_downsample && task.stage == Stage::Downsample {
assert_eq!(progress.done, clear_visits + bbox_visits, "every projected visit completed before downsample");
saw_downsample = true;
}
if progress.done == progress.total {
saw_running_complete = true;
}
last_done = progress.done;
assert!(matches!(task.step(1).unwrap(), TaskPoll::Pending(_)));
}
assert!(task.stage == Stage::Encode);
assert!(saw_opaque && saw_downsample && saw_running_complete, "Running must reach its boundary before Encoding");
assert_eq!(last_done, expected_total);
assert_eq!(task.pixel_done, expected_total);
assert_eq!(task.progress().phase, Phase::Encoding);
let png = finish(task);
assert_eq!(image::load_from_memory(&png).unwrap().to_rgba8().dimensions(), (8, 8));
}
}
+371
View File
@@ -0,0 +1,371 @@
//! Transient CPU Interactive scheduling. The UI adapter owns visual revisions;
//! this controller owns cancellation, not document settings or final-image export.
use std::time::Duration;
use super::{CpuProgressiveOptions, ErrorCode, ImageRequest, Integrator, JobError, JobHandle, JobId, Phase, Result};
const SETTLE: Duration = Duration::from_millis(200);
const MAX_EDGE: u32 = 1024;
/// Prepare a separate low-resolution request. Never edits the final request,
/// changes renderer, removes optics/lights, or promises geometry admission.
pub fn cpu_request(final_request: &ImageRequest) -> Result<ImageRequest> {
final_request.validate_raster_request()?;
if final_request.integrator != Integrator::Raytrace || final_request.transparent_background {
return Err(JobError::new(ErrorCode::InvalidRequest, "Interactive CPU requires opaque ray tracing"));
}
let (width, height) = (final_request.width, final_request.height);
if width == 0 || height == 0 {
return Err(JobError::new(ErrorCode::InvalidRequest, "Interactive output dimensions must be positive"));
}
if let Some(options) = &final_request.raytrace {
options.validate()?;
}
let edge = width.max(height);
let fit = |side: u32| {
if edge <= MAX_EDGE { side } else { ((u64::from(side) * u64::from(MAX_EDGE) + u64::from(edge) / 2) / u64::from(edge)).max(1) as u32 }
};
let mut request = final_request.clone();
request.width = fit(width);
request.height = fit(height);
let options = request.raytrace.get_or_insert_default();
options.primary_samples = 1;
// Preserve explicit accumulation intent, including its deterministic seed.
// Fixed-image callers keep the original preview default. Incompatible
// transport remains an error rather than silently removing requested effects.
options.progressive.get_or_insert_with(CpuProgressiveOptions::default);
if let Some(reflections) = &mut options.reflections {
reflections.samples = 1;
}
options.validate()?;
options.budget.validate_image(request.width, request.height, options.progressive)?;
Ok(request)
}
/// Caller increments visual_revision for every camera/draft/appearance/light/HDR
/// or output edit, even if scene_generation did not change. Never use only a CAD
/// revision, a body name, or the selected setup name as the visual identity.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct VisualRevision {
pub scene_generation: u64,
pub visual_revision: u64,
}
/// Issued only after the edit group settles. Private fields prevent fabricating
/// a ticket for a newer request from a delayed capture.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CaptureTicket {
session: u64,
serial: u64,
revision: VisualRevision,
}
impl CaptureTicket {
pub fn revision(self) -> VisualRevision {
self.revision
}
/// Submit this identity to the preview host, outside the final-job registry.
pub fn job_id(self) -> JobId {
JobId { session: self.session, serial: self.serial, generation: self.serial }
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum State {
Off,
Updating,
Refining,
Paused,
TargetReached,
Error,
}
/// One controller per document session. No serialization, clocks, threads, scene
/// extraction or new renderer here. Supply monotonic elapsed time from the host;
/// this keeps both browser integration and deterministic event tests possible.
pub struct Interactive {
session: u64,
serial: u64,
revision: Option<VisualRevision>,
changed_at: Duration,
enabled: bool,
paused: bool,
final_active: bool,
ticket: Option<CaptureTicket>,
active: Option<JobHandle>,
error: Option<JobError>,
}
impl Interactive {
pub fn new(session: u64) -> Self {
Self {
session,
serial: 0,
revision: None,
changed_at: Duration::ZERO,
enabled: false,
paused: false,
final_active: false,
ticket: None,
active: None,
error: None,
}
}
fn cancel(&mut self) -> Result<()> {
// Invalidate first, including when a poisoned host refuses cancellation.
self.ticket = None;
if let Some(job) = self.active.take() {
job.cancel()?;
}
Ok(())
}
/// Explicit user activation. Merely entering Render must not call this.
pub fn enable(&mut self, revision: VisualRevision, now: Duration) -> Result<()> {
self.cancel()?;
self.enabled = true;
self.paused = false;
self.error = None;
self.revision = Some(revision);
self.changed_at = now;
Ok(())
}
/// Workspace/document departure uses disable; manually started final jobs
/// are deliberately outside this controller and are never cancelled here.
pub fn disable(&mut self) -> Result<()> {
self.enabled = false;
self.paused = false;
self.error = None;
self.cancel()
}
pub fn edit(&mut self, revision: VisualRevision, now: Duration) -> Result<()> {
if self.revision != Some(revision) {
self.revision = Some(revision);
self.changed_at = now;
self.error = None;
self.cancel()?;
}
Ok(())
}
/// A view may retain an identified immutable PNG before pausing, but must
/// label it paused/stale. It is never a current or exportable final image.
pub fn pause(&mut self) -> Result<()> {
self.paused = true;
self.cancel()
}
pub fn resume(&mut self, now: Duration) -> Result<()> {
self.cancel()?;
self.paused = false;
self.error = None;
self.changed_at = now;
Ok(())
}
pub fn set_final_active(&mut self, active: bool, now: Duration) -> Result<()> {
if self.final_active != active {
self.final_active = active;
self.changed_at = now;
self.cancel()?;
}
Ok(())
}
/// At most one unacknowledged capture; no queue of obsolete requests.
pub fn capture(&mut self, now: Duration) -> Result<Option<CaptureTicket>> {
if !self.enabled
|| self.paused
|| self.final_active
|| self.ticket.is_some()
|| self.error.is_some()
|| now.checked_sub(self.changed_at).is_none_or(|elapsed| elapsed < SETTLE)
{
return Ok(None);
}
let Some(revision) = self.revision else { return Ok(None) };
self.serial = self.serial.checked_add(1).ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "Interactive capture identity exhausted"))?;
let ticket = CaptureTicket { session: self.session, serial: self.serial, revision };
self.ticket = Some(ticket);
Ok(Some(ticket))
}
/// Recheck before expensive capture/admission, including after a worker delay.
/// A ticket is not permission to prepare an obsolete or already adopted job.
pub fn accepts_capture(&self, ticket: CaptureTicket) -> bool {
self.enabled && !self.paused && !self.final_active && self.error.is_none() && self.ticket == Some(ticket) && self.active.is_none()
}
/// Attach an admitted preview-only job after immutable capture. Delayed jobs
/// are cancelled, including a previous ticket for the same visual revision.
/// Use a separate engine job session/registry from final-image export.
pub fn attach(&mut self, ticket: CaptureTicket, job: JobHandle) -> Result<bool> {
// A misrouted callback must never cancel another document's work or a
// manual final job. Only our dedicated preview session is ours to stop.
if job.id().session != self.session {
self.capture_failed(ticket, JobError::new(ErrorCode::WrongOwner, "Interactive job belongs to another session"));
return Ok(false);
}
if self.ticket != Some(ticket) || ticket.session != self.session {
// Protect an admitted current capture even before acknowledgement.
// Once accepted, only the same record (not merely its JobId) is ours.
let current = match &self.active {
Some(active) => active.same_record(&job),
None => self.ticket.is_some_and(|ticket| ticket.job_id() == job.id()),
};
if !current {
job.cancel()?;
}
return Ok(false);
}
let status = job.status()?;
if status.job != ticket.job_id() || status.scene_generation != ticket.revision.scene_generation {
job.cancel()?;
self.capture_failed(ticket, JobError::new(ErrorCode::WrongOwner, "Interactive job capture identity mismatch"));
return Ok(false);
}
if status.request.integrator != Integrator::Raytrace || status.request.raytrace.as_ref().is_none_or(|options| options.progressive.is_none()) {
job.cancel()?;
self.capture_failed(ticket, JobError::new(ErrorCode::InvalidRequest, "Interactive requires a CPU progressive job"));
return Ok(false);
}
// A ticket is acknowledged exactly once, even if the same capture is
// delivered twice while an earlier attached job is still running.
if self.active.is_some() {
if self.active.as_ref().is_none_or(|active| !active.same_record(&job)) {
job.cancel()?;
}
return Ok(false);
}
self.active = Some(job);
Ok(true)
}
pub fn capture_failed(&mut self, ticket: CaptureTicket, error: JobError) {
if self.ticket == Some(ticket) && self.active.is_none() {
self.ticket = None;
self.error = Some(error);
}
}
/// Access only the current capture. The adapter reads checkpoint/final bytes
/// through this handle, displaying both as Interactive/provisional content.
pub fn current(&self) -> Option<&JobHandle> {
self.active.as_ref()
}
pub fn error(&self) -> Option<&JobError> {
self.error.as_ref()
}
pub fn state(&self) -> Result<State> {
if !self.enabled {
return Ok(State::Off);
}
if self.paused || self.final_active {
return Ok(State::Paused);
}
if self.error.is_some() {
return Ok(State::Error);
}
if let Some(job) = &self.active {
return Ok(match job.status()?.progress.phase {
Phase::Completed => State::TargetReached,
Phase::Cancelled | Phase::Failed => State::Error,
_ => State::Refining,
});
}
Ok(State::Updating)
}
}
impl Drop for Interactive {
fn drop(&mut self) {
let _ = self.cancel();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::job::{CpuBudget, RaytraceOptions};
fn request(options: RaytraceOptions) -> ImageRequest {
ImageRequest {
raster_punctual_lights: None,
raster_area_lights: None,
raster_exposure_ev: 0.0,
raster_lights: None,
studio_weights: None,
integrator: Integrator::Raytrace,
raytrace: Some(options),
textures: None,
environment: None,
camera: Default::default(),
width: 81,
height: 53,
background: [40, 80, 120],
transparent_background: false,
}
}
#[test]
fn explicit_progressive_target_and_seed_survive_interactive_conversion() {
for (budget, samples) in [(CpuBudget::Compact, 31), (CpuBudget::Studio, 997), (CpuBudget::Workstation, 1_048_576)] {
let mut source = request(RaytraceOptions {
budget,
progressive: Some(CpuProgressiveOptions { samples_per_pixel: samples, seed: u32::MAX }),
primary_samples: 1,
exposure_ev: -2.0,
shadows: false,
..Default::default()
});
// The high target still obeys P*SPP admission; no implicit resize/profile.
if budget == CpuBudget::Workstation {
source.width = 3840;
source.height = 1;
}
let before = serde_json::to_value(&source).unwrap();
let converted = cpu_request(&source).unwrap();
let mut expected = source.clone();
if budget == CpuBudget::Workstation {
expected.width = 1024;
}
assert_eq!(serde_json::to_value(converted).unwrap(), serde_json::to_value(expected).unwrap());
assert_eq!(serde_json::to_value(&source).unwrap(), before);
}
}
#[test]
fn fixed_interactive_keeps_original_default_and_invalid_explicit_intent_refuses() {
let source = request(RaytraceOptions { primary_samples: 16, ..Default::default() });
let converted = cpu_request(&source).unwrap();
let options = converted.raytrace.unwrap();
assert_eq!(options.progressive, Some(CpuProgressiveOptions { samples_per_pixel: 16, seed: 0 }));
assert_eq!(options.primary_samples, 1);
assert!(source.raytrace.as_ref().unwrap().progressive.is_none());
for (budget, samples) in [(CpuBudget::Compact, 65), (CpuBudget::Studio, 1025), (CpuBudget::Workstation, 1_048_577)] {
let invalid = request(RaytraceOptions {
budget,
primary_samples: 1,
progressive: Some(CpuProgressiveOptions { samples_per_pixel: samples, seed: 741 }),
..Default::default()
});
let before = serde_json::to_value(&invalid).unwrap();
assert!(cpu_request(&invalid).is_err());
assert_eq!(serde_json::to_value(&invalid).unwrap(), before);
}
let mut over_work = request(RaytraceOptions {
primary_samples: 1,
progressive: Some(CpuProgressiveOptions { samples_per_pixel: 64, seed: 741 }),
..Default::default()
});
over_work.width = 1024;
over_work.height = 1024;
assert!(cpu_request(&over_work).is_err(), "do not reduce the explicit target to fit the profile");
}
}
+4
View File
@@ -192,6 +192,10 @@ mod consumer {
let mut camera = Camera { distance: 50.0, ..Default::default() };
camera.set_view(StandardView::Top);
ImageRequest {
raster_punctual_lights: None,
raster_area_lights: None,
raster_exposure_ev: 0.0,
raster_lights: None,
studio_weights: None,
environment: None,
textures: None,
+126 -8
View File
@@ -14,7 +14,73 @@ pub fn encoded_limit(width: u32, height: u32) -> Result<u64> {
if w == 0 || h == 0 || w > 8192 || h > 8192 || w * h > 16_777_216 {
return Err(invalid("linear EXR requires 1..8192 sides and at most 16777216 pixels"));
}
Ok(w * h * 16 + h * 16 + 4096)
encoded_limit_with_limits(width, height, super::GPU_PATH_LIMITS)
}
/// Internal bounded policy, not proof of a host reservation. Future callers must
/// retain their request's lease and charge work before advancing output stages.
pub(crate) fn output_pixels_with_limits(width: u32, height: u32, limits: crate::RenderLimits) -> Result<u64> {
let maximum = super::CpuBudget::Workstation.image_limits(false);
if limits.max_dimension > maximum.max_dimension || limits.max_pixels > maximum.max_pixels || limits.max_scratch_bytes > maximum.max_scratch_bytes
{
return Err(invalid("output policy exceeds the bounded implementation envelope"));
}
let (w, h) = (u64::from(width), u64::from(height));
if limits.fit(w, h, None) != Some((w, h)) {
return Err(invalid("output dimensions exceed the explicit policy"));
}
let pixels = w.checked_mul(h).ok_or_else(|| invalid("output pixel count overflow"))?;
checked_output_bytes(pixels, 1, 0, limits)?;
Ok(pixels)
}
/// Checked combined live-byte estimate; this is not an individual Vec layout or a lease.
pub(crate) fn checked_stage_bytes(count: u64, element_bytes: u64, extra: u64, limits: crate::RenderLimits) -> Result<u64> {
let maximum = super::CpuBudget::Workstation.image_limits(false);
if limits.max_dimension > maximum.max_dimension || limits.max_pixels > maximum.max_pixels || limits.max_scratch_bytes > maximum.max_scratch_bytes
{
return Err(invalid("output policy exceeds the bounded implementation envelope"));
}
let bytes = count.checked_mul(element_bytes).and_then(|n| n.checked_add(extra)).ok_or_else(|| invalid("output byte layout overflow"))?;
if bytes > limits.max_scratch_bytes {
return Err(invalid("output byte layout exceeds its explicit policy"));
}
Ok(bytes)
}
/// Check an individual addressable byte layout separately from combined estimates.
pub(crate) fn checked_output_bytes(count: u64, element_bytes: u64, extra: u64, limits: crate::RenderLimits) -> Result<usize> {
let bytes = checked_stage_bytes(count, element_bytes, extra, limits)?;
let addressable = usize::try_from(bytes).map_err(|_| invalid("output byte layout exceeds address space"))?;
if std::alloc::Layout::array::<u8>(addressable).is_err() {
return Err(invalid("output byte layout exceeds address space"));
}
Ok(addressable)
}
pub(crate) fn encoded_limit_with_limits(width: u32, height: u32, limits: crate::RenderLimits) -> Result<u64> {
let pixels = output_pixels_with_limits(width, height, limits)?;
let row_bytes = u64::from(width).checked_mul(16).ok_or_else(|| invalid("EXR row overflow"))?;
i32::try_from(row_bytes).map_err(|_| invalid("EXR row exceeds scanline representation"))?;
i32::try_from(width).and_then(|_| i32::try_from(height)).map_err(|_| invalid("EXR window exceeds integer representation"))?;
let extra = u64::from(height).checked_mul(16).and_then(|n| n.checked_add(4096)).ok_or_else(|| invalid("EXR metadata overflow"))?;
Ok(checked_output_bytes(pixels, 16, extra, limits)? as u64)
}
pub(crate) fn scanline_layout(width: u32, height: u32, header_bytes: usize, limits: crate::RenderLimits) -> Result<(usize, usize)> {
let limit = encoded_limit_with_limits(width, height, limits)?;
let rows = usize::try_from(height).map_err(|_| invalid("EXR row count exceeds address space"))?;
let row = usize::try_from(width).ok().and_then(|n| n.checked_mul(16)).ok_or_else(|| invalid("EXR row layout overflow"))?;
let first = rows.checked_mul(8).and_then(|n| n.checked_add(header_bytes)).ok_or_else(|| invalid("EXR offset table overflow"))?;
let total = row
.checked_add(8)
.and_then(|n| n.checked_mul(rows))
.and_then(|n| n.checked_add(first))
.ok_or_else(|| invalid("EXR scanline layout overflow"))?;
if total as u64 > limit {
return Err(invalid("EXR output exceeds its admission"));
}
Ok((first, total))
}
fn attribute(out: &mut Vec<u8>, name: &[u8], kind: &[u8], value: &[u8]) -> Result<()> {
@@ -32,6 +98,12 @@ fn attribute(out: &mut Vec<u8>, name: &[u8], kind: &[u8], value: &[u8]) -> Resul
}
pub(super) fn header(width: u32, height: u32) -> Result<Vec<u8>> {
encoded_limit(width, height)?;
header_with_limits(width, height, super::GPU_PATH_LIMITS)
}
pub(crate) fn header_with_limits(width: u32, height: u32, limits: crate::RenderLimits) -> Result<Vec<u8>> {
encoded_limit_with_limits(width, height, limits)?;
let mut out = Vec::new();
out.try_reserve_exact(4096).map_err(|_| JobError::new(ErrorCode::AllocationFailed, "EXR header allocation failed"))?;
out.extend_from_slice(&20_000_630_u32.to_le_bytes());
@@ -83,20 +155,27 @@ pub struct ExrTask {
}
impl ExrTask {
pub fn new(width: u32, height: u32, pixels: Vec<[f32; 4]>, cancel: CancelToken) -> Result<Self> {
let limit = encoded_limit(width, height)?;
encoded_limit(width, height)?;
Self::new_with_limits(width, height, pixels, cancel, super::GPU_PATH_LIMITS)
}
pub(crate) fn new_with_limits(width: u32, height: u32, pixels: Vec<[f32; 4]>, cancel: CancelToken, limits: crate::RenderLimits) -> Result<Self> {
let limit = encoded_limit_with_limits(width, height, limits)?;
if pixels.len() as u64 != u64::from(width) * u64::from(height) {
return Err(invalid("EXR pixel count differs from dimensions"));
}
cancel.check()?;
let mut bytes = header(width, height)?;
checked_output_bytes(pixels.capacity() as u64, 16, 0, limits)?;
checked_stage_bytes(pixels.capacity() as u64, 16, limit, limits)?;
let mut bytes = header_with_limits(width, height, limits)?;
let row_bytes = u64::from(width) * 16;
let first = bytes.len() as u64 + u64::from(height) * 8;
let total = first + u64::from(height) * (row_bytes + 8);
if total > limit {
let (first, total) = scanline_layout(width, height, bytes.len(), limits)?;
let first = first as u64;
let additional = total.checked_sub(bytes.len()).ok_or_else(|| invalid("EXR address space exceeded"))?;
bytes.try_reserve_exact(additional).map_err(|_| JobError::new(ErrorCode::AllocationFailed, "EXR output allocation failed"))?;
if bytes.capacity() as u64 > limit {
return Err(invalid("EXR output exceeds its admission"));
}
let additional = usize::try_from(total).ok().and_then(|n| n.checked_sub(bytes.len())).ok_or_else(|| invalid("EXR address space exceeded"))?;
bytes.try_reserve_exact(additional).map_err(|_| JobError::new(ErrorCode::AllocationFailed, "EXR output allocation failed"))?;
for y in 0..height {
bytes.extend_from_slice(&(first + u64::from(y) * (row_bytes + 8)).to_le_bytes());
}
@@ -197,4 +276,43 @@ mod tests {
cancel.cancel();
assert_eq!(task.step(1).unwrap_err().code, ErrorCode::Cancelled);
}
#[test]
fn explicit_output_policy_is_exact_bounded_and_checked_before_allocation() {
let wide = super::super::CpuBudget::Workstation.image_limits(false);
assert_eq!(output_pixels_with_limits(8193, 1, wide).unwrap(), 8193);
assert_eq!(encoded_limit_with_limits(8193, 1, wide).unwrap(), 135200);
assert_eq!(checked_output_bytes(8193, 8, 65536, wide).unwrap(), 131080);
for (w, h) in [(0, 1), (1, 0), (u32::MAX, u32::MAX)] {
assert!(output_pixels_with_limits(w, h, wide).is_err());
}
assert!(output_pixels_with_limits(8193, 1, super::super::GPU_PATH_LIMITS).is_err(), "a reduced fit is not acceptance");
for hostile in [
crate::RenderLimits { max_dimension: u64::MAX, ..wide },
crate::RenderLimits { max_pixels: u64::MAX, ..wide },
crate::RenderLimits { max_scratch_bytes: u64::MAX, ..wide },
] {
assert!(output_pixels_with_limits(1, 1, hostile).is_err());
}
assert!(checked_output_bytes(u64::MAX, 16, 0, wide).is_err());
assert!(checked_output_bytes(1, 1, u64::MAX, wide).is_err());
assert!(scanline_layout(1, 1, usize::MAX, wide).is_err());
assert!(checked_output_bytes(1, 16, 0, crate::RenderLimits { max_scratch_bytes: 15, ..wide }).is_err());
assert!(checked_output_bytes(isize::MAX as u64 + 1, 1, 0, wide).is_err());
}
#[test]
fn explicit_gpu_exr_keeps_legacy_bytes_and_dimension_before_cancel_order() {
let pixels = vec![[4.0, 1.0, 0.25, 1.0]; 6];
let expected = encode(pixels.clone(), 3, 2);
let mut task = ExrTask::new_with_limits(3, 2, pixels, CancelToken::default(), super::super::GPU_PATH_LIMITS).unwrap();
assert!(task.step(2).unwrap());
assert_eq!(task.finish().unwrap().0, expected);
let cancel = CancelToken::default();
cancel.cancel();
let error = ExrTask::new(0, 1, vec![], cancel.clone()).err().unwrap();
assert_eq!(error.code, ErrorCode::InvalidRequest);
assert!(error.to_string().contains("linear EXR requires 1..8192"));
assert_eq!(ExrTask::new(1, 1, vec![], cancel).err().unwrap().code, ErrorCode::InvalidRequest);
}
}
+150 -120
View File
@@ -1,120 +1,150 @@
//! Decoder for the exact retained native-master profile, not arbitrary external
//! EXR import. Dimensions, header, offsets, row sizes and every sample are checked.
use super::{CancelToken, ErrorCode, JobError, Result};
fn invalid(message: &str) -> JobError {
JobError::new(ErrorCode::InvalidRequest, message)
}
pub(super) struct Decoder {
width: usize,
height: usize,
row: usize,
table: usize,
first: usize,
pixels: Vec<[f32; 4]>,
}
impl Decoder {
pub(super) fn new(bytes: &[u8], width: u32, height: u32, cancel: &CancelToken) -> Result<Self> {
cancel.check()?;
let limit = super::linear_exr::encoded_limit(width, height)?;
let expected = super::linear_exr::header(width, height)?;
if bytes.len() as u64 > limit || !bytes.starts_with(&expected) {
return Err(invalid("master is not the retained native FLOAT32 linear-sRGB/D65 profile"));
}
let first = expected.len() + height as usize * 8;
let length = first + height as usize * (width as usize * 16 + 8);
if bytes.len() != length {
return Err(invalid("native master has missing or trailing scanline bytes"));
}
let count = width as usize * height as usize;
let mut pixels = Vec::new();
pixels.try_reserve_exact(count).map_err(|_| JobError::new(ErrorCode::AllocationFailed, "master decoding allocation failed"))?;
if pixels.capacity() != count {
return Err(invalid("master decoding exceeded admitted capacity"));
}
Ok(Self { width: width as usize, height: height as usize, row: 0, table: expected.len(), first, pixels })
}
pub(super) fn step(&mut self, bytes: &[u8], cancel: &CancelToken) -> Result<bool> {
cancel.check()?;
for _ in 0..4 {
if self.row == self.height {
break;
}
let row_bytes = self.width * 16;
let offset = self.first + self.row * (row_bytes + 8);
let table = self.table + self.row * 8;
let stored = bytes.get(table..table + 8).and_then(|s| s.try_into().ok()).map(u64::from_le_bytes);
let y = word(bytes, offset)?;
let length = word(bytes, offset + 4)?;
if stored != Some(offset as u64) || y != self.row as u32 || length as usize != row_bytes {
return Err(invalid("native master scanline offset, index or length differs"));
}
for x in 0..self.width {
if x % 4096 == 0 {
cancel.check()?;
}
let mut pixel = [0.0; 4];
for (plane, channel) in [3, 2, 1, 0].into_iter().enumerate() {
pixel[channel] = f32::from_bits(word(bytes, offset + 8 + plane * self.width * 4 + x * 4)?);
}
if pixel.iter().any(|value| !value.is_finite() || *value < 0.0) || pixel[3] > 1.0 {
return Err(invalid("native master has invalid radiance or coverage"));
}
self.pixels.push(pixel);
}
self.row += 1;
}
cancel.check()?;
Ok(self.row == self.height)
}
pub(super) fn rows(&self) -> u64 {
self.row as u64
}
pub(super) fn finish(self) -> Result<Vec<[f32; 4]>> {
if self.row != self.height {
return Err(invalid("native master decoding is incomplete"));
}
Ok(self.pixels)
}
}
fn word(bytes: &[u8], offset: usize) -> Result<u32> {
bytes.get(offset..offset + 4).and_then(|s| s.try_into().ok()).map(u32::from_le_bytes).ok_or_else(|| invalid("truncated native master word"))
}
#[cfg(test)]
mod tests {
use super::*;
fn source() -> (Vec<u8>, Vec<[f32; 4]>) {
let pixels = vec![[8.0, 0.1, 2.0, 0.5], [0.0, -0.0, 0.000001, 0.0]];
let mut task = super::super::linear_exr::ExrTask::new(2, 1, pixels.clone(), CancelToken::default()).unwrap();
assert!(task.step(1).unwrap());
(task.finish().unwrap().0, pixels)
}
#[test]
fn exact_native_profile_roundtrips_bits_and_rejects_wrong_layout_or_partial_decode() {
let (bytes, pixels) = source();
let cancel = CancelToken::default();
let mut decoder = Decoder::new(&bytes, 2, 1, &cancel).unwrap();
assert!(decoder.step(&bytes, &cancel).unwrap());
assert_eq!(
decoder.finish().unwrap().iter().map(|p| p.map(f32::to_bits)).collect::<Vec<_>>(),
pixels.iter().map(|p| p.map(f32::to_bits)).collect::<Vec<_>>()
);
assert!(Decoder::new(&bytes, 1, 2, &cancel).is_err());
assert!(Decoder::new(&bytes[..bytes.len() - 1], 2, 1, &cancel).is_err());
assert!(Decoder::new(&bytes, 2, 1, &cancel).unwrap().finish().is_err());
let mut corrupt = bytes.clone();
let table = super::super::linear_exr::header(2, 1).unwrap().len();
corrupt[table] ^= 1;
assert!(Decoder::new(&corrupt, 2, 1, &cancel).unwrap().step(&corrupt, &cancel).is_err());
let mut corrupt = bytes.clone();
let last = corrupt.len() - 4;
corrupt[last..].copy_from_slice(&f32::NAN.to_le_bytes());
assert!(Decoder::new(&corrupt, 2, 1, &cancel).unwrap().step(&corrupt, &cancel).is_err());
let mut decoder = Decoder::new(&bytes, 2, 1, &cancel).unwrap();
cancel.cancel();
assert!(decoder.step(&bytes, &cancel).is_err());
assert_eq!(decoder.rows(), 0);
}
}
//! Decoder for the exact retained native-master profile, not arbitrary external
//! EXR import. Dimensions, header, offsets, row sizes and every sample are checked.
use super::{CancelToken, ErrorCode, JobError, Result};
fn invalid(message: &str) -> JobError {
JobError::new(ErrorCode::InvalidRequest, message)
}
pub(super) struct Decoder {
width: usize,
height: usize,
row: usize,
table: usize,
first: usize,
pixels: Vec<[f32; 4]>,
}
impl Decoder {
pub(super) fn new(bytes: &[u8], width: u32, height: u32, cancel: &CancelToken) -> Result<Self> {
cancel.check()?;
super::linear_exr::encoded_limit(width, height)?;
Self::new_with_limits(bytes, width, height, cancel, super::GPU_PATH_LIMITS)
}
pub(crate) fn new_with_limits(bytes: &[u8], width: u32, height: u32, cancel: &CancelToken, limits: crate::RenderLimits) -> Result<Self> {
cancel.check()?;
let limit = super::linear_exr::encoded_limit_with_limits(width, height, limits)?;
let count = super::linear_exr::output_pixels_with_limits(width, height, limits)?;
super::linear_exr::checked_output_bytes(count, 16, 0, limits)?;
super::linear_exr::checked_stage_bytes(count, 16, 4096, limits)?;
let expected = if limits == super::GPU_PATH_LIMITS {
super::linear_exr::header(width, height)?
} else {
super::linear_exr::header_with_limits(width, height, limits)?
};
if bytes.len() as u64 > limit || !bytes.starts_with(&expected) {
return Err(invalid("master is not the retained native FLOAT32 linear-sRGB/D65 profile"));
}
let (first, length) = super::linear_exr::scanline_layout(width, height, expected.len(), limits)?;
if bytes.len() != length {
return Err(invalid("native master has missing or trailing scanline bytes"));
}
let count = width as usize * height as usize;
let mut pixels = Vec::new();
pixels.try_reserve_exact(count).map_err(|_| JobError::new(ErrorCode::AllocationFailed, "master decoding allocation failed"))?;
if pixels.capacity() != count {
return Err(invalid("master decoding exceeded admitted capacity"));
}
Ok(Self { width: width as usize, height: height as usize, row: 0, table: expected.len(), first, pixels })
}
pub(super) fn step(&mut self, bytes: &[u8], cancel: &CancelToken) -> Result<bool> {
cancel.check()?;
for _ in 0..4 {
if self.row == self.height {
break;
}
let row_bytes = self.width * 16;
let offset = self.first + self.row * (row_bytes + 8);
let table = self.table + self.row * 8;
let stored = bytes.get(table..table + 8).and_then(|s| s.try_into().ok()).map(u64::from_le_bytes);
let y = word(bytes, offset)?;
let length = word(bytes, offset + 4)?;
if stored != Some(offset as u64) || y != self.row as u32 || length as usize != row_bytes {
return Err(invalid("native master scanline offset, index or length differs"));
}
for x in 0..self.width {
if x % 4096 == 0 {
cancel.check()?;
}
let mut pixel = [0.0; 4];
for (plane, channel) in [3, 2, 1, 0].into_iter().enumerate() {
pixel[channel] = f32::from_bits(word(bytes, offset + 8 + plane * self.width * 4 + x * 4)?);
}
if pixel.iter().any(|value| !value.is_finite() || *value < 0.0) || pixel[3] > 1.0 {
return Err(invalid("native master has invalid radiance or coverage"));
}
self.pixels.push(pixel);
}
self.row += 1;
}
cancel.check()?;
Ok(self.row == self.height)
}
pub(super) fn rows(&self) -> u64 {
self.row as u64
}
pub(super) fn finish(self) -> Result<Vec<[f32; 4]>> {
if self.row != self.height {
return Err(invalid("native master decoding is incomplete"));
}
Ok(self.pixels)
}
}
fn word(bytes: &[u8], offset: usize) -> Result<u32> {
let end = offset.checked_add(4).ok_or_else(|| invalid("native master word offset overflow"))?;
bytes.get(offset..end).and_then(|s| s.try_into().ok()).map(u32::from_le_bytes).ok_or_else(|| invalid("truncated native master word"))
}
#[cfg(test)]
mod tests {
use super::*;
fn source() -> (Vec<u8>, Vec<[f32; 4]>) {
let pixels = vec![[8.0, 0.1, 2.0, 0.5], [0.0, -0.0, 0.000001, 0.0]];
let mut task = super::super::linear_exr::ExrTask::new(2, 1, pixels.clone(), CancelToken::default()).unwrap();
assert!(task.step(1).unwrap());
(task.finish().unwrap().0, pixels)
}
#[test]
fn exact_native_profile_roundtrips_bits_and_rejects_wrong_layout_or_partial_decode() {
let (bytes, pixels) = source();
let cancel = CancelToken::default();
let mut decoder = Decoder::new(&bytes, 2, 1, &cancel).unwrap();
assert!(decoder.step(&bytes, &cancel).unwrap());
assert_eq!(
decoder.finish().unwrap().iter().map(|p| p.map(f32::to_bits)).collect::<Vec<_>>(),
pixels.iter().map(|p| p.map(f32::to_bits)).collect::<Vec<_>>()
);
assert!(Decoder::new(&bytes, 1, 2, &cancel).is_err());
assert!(Decoder::new(&bytes[..bytes.len() - 1], 2, 1, &cancel).is_err());
assert!(Decoder::new(&bytes, 2, 1, &cancel).unwrap().finish().is_err());
let mut corrupt = bytes.clone();
let table = super::super::linear_exr::header(2, 1).unwrap().len();
corrupt[table] ^= 1;
assert!(Decoder::new(&corrupt, 2, 1, &cancel).unwrap().step(&corrupt, &cancel).is_err());
let mut corrupt = bytes.clone();
let last = corrupt.len() - 4;
corrupt[last..].copy_from_slice(&f32::NAN.to_le_bytes());
assert!(Decoder::new(&corrupt, 2, 1, &cancel).unwrap().step(&corrupt, &cancel).is_err());
let mut decoder = Decoder::new(&bytes, 2, 1, &cancel).unwrap();
cancel.cancel();
assert!(decoder.step(&bytes, &cancel).is_err());
assert_eq!(decoder.rows(), 0);
}
#[test]
fn explicit_wide_native_roundtrip_retains_raw_bits_and_legacy_limits() {
let wide = super::super::CpuBudget::Workstation.image_limits(false);
let raw = vec![[4.0, 1.0, 0.25, 1.0]; 8193];
let cancel = CancelToken::default();
let mut task = super::super::linear_exr::ExrTask::new_with_limits(8193, 1, raw.clone(), cancel.clone(), wide).unwrap();
assert!(task.step(1).unwrap());
let (bytes, unchanged) = task.finish().unwrap();
assert_eq!(unchanged, raw);
assert!(Decoder::new(&bytes, 8193, 1, &cancel).is_err());
let mut decoder = Decoder::new_with_limits(&bytes, 8193, 1, &cancel, wide).unwrap();
assert!(decoder.step(&bytes, &cancel).unwrap());
assert_eq!(decoder.finish().unwrap(), raw);
cancel.cancel();
assert_eq!(Decoder::new(&[], 0, 0, &cancel).err().unwrap().code, ErrorCode::Cancelled);
assert_eq!(Decoder::new_with_limits(&[], 0, 0, &cancel, wide).err().unwrap().code, ErrorCode::Cancelled);
}
}
+733 -41
View File
@@ -19,6 +19,7 @@ pub(crate) mod encoding;
pub mod finishing;
mod host;
pub(crate) mod image;
pub mod interactive;
pub mod led_strip;
mod lens;
#[cfg(test)]
@@ -31,6 +32,7 @@ pub mod photographic;
mod png;
pub mod presentation;
mod punctual;
mod raster_lights;
mod regrade;
#[cfg(test)]
mod rt2_tests;
@@ -43,13 +45,18 @@ pub(crate) use host::FRAME_METADATA_BYTES;
pub(crate) use host::Reservation;
pub use host::{
CpuHost, HostKind, ImageHost, JobEnvironmentInfo, JobHandle, JobStatus, LinearAlphaConvention, LinearColourEncoding, LinearMasterInfo,
PreviewInfo, RenderFrame, RenderPreview,
PartialPreviewInfo, PartialPreviewProjection, PreviewInfo, RenderFrame, RenderPartialPreview, RenderPreview,
};
pub use image::{Admission, ImageTask, TaskPoll, admit, admit_geometry, admit_geometry_with_limits};
pub use lens::ThinLens;
pub use lighting::{AreaLight, AreaLights, MAX_AREA_LIGHTS, StudioLighting, Sun, ToneMap};
pub use png::PngTask;
pub use punctual::{PunctualDiagnostics, PunctualLightOptions, SpotConeOptions};
pub use raster_lights::{
deserialize as deserialize_raster_lights, deserialize_areas as deserialize_raster_area_lights,
deserialize_punctual as deserialize_raster_punctual_lights, validate as validate_raster_lights, validate_areas as validate_raster_area_lights,
validate_punctual as validate_raster_punctual_lights,
};
/// Native path-tracing ceilings, further restricted by the actual device.
/// Kept in the device-independent layer so saved intent and UI agree with execution.
@@ -426,6 +433,38 @@ pub struct RectangleLightOptions {
pub intensity: f64,
}
impl RectangleLightOptions {
/// Canonical numerical domain shared by CPU transport and schematic Raster.
pub(crate) fn validate(&self) -> Result<()> {
use solidcraft_geom::Vec3;
let vec = |v: [f64; 3]| Vec3::new(v[0], v[1], v[2]);
let c = vec(self.center_mm);
let u = vec(self.edge_u_mm);
let v = vec(self.edge_v_mm);
if !c.is_finite()
|| !u.is_finite()
|| !v.is_finite()
|| !self.color.iter().all(|x| x.is_finite() && (0.0..=1.0).contains(x))
|| !self.intensity.is_finite()
|| !(0.0..=64.0).contains(&self.intensity)
|| !(1e-6..=1e6).contains(&u.len())
|| !(1e-6..=1e6).contains(&v.len())
|| (u.dot(v) / (u.len() * v.len())).abs() > 1e-10
{
return Err(JobError::new(ErrorCode::InvalidRequest, "invalid finite rectangular light"));
}
for a in [-0.5, 0.5] {
for b in [-0.5, 0.5] {
let p = c + u * a + v * b;
if !p.is_finite() || [p.x, p.y, p.z].into_iter().any(|x| x.abs() > 1e9) {
return Err(JobError::new(ErrorCode::InvalidRequest, "rectangular light corner exceeds world bounds"));
}
}
}
Ok(())
}
}
/// Explicit visual rotation about a certified planar face's outward normal.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
@@ -655,16 +694,38 @@ impl CpuProgressiveOptions {
budget.validate_image(width, height, Some(*self))
}
/// Nonfinal checkpoints whose PNG allocations may remain externally retained.
/// At most two reusable checkpoint reservations. A pinned consumer keeps its
/// slot charged; it does not authorize a new allocation per pass.
#[cfg(feature = "raytrace-cpu")]
pub(crate) fn preview_count(&self) -> u32 {
if self.samples_per_pixel <= 1 { 0 } else { (self.samples_per_pixel - 1).ilog2() + 1 }
pub(crate) fn checkpoint_slots(&self) -> u32 {
self.samples_per_pixel.saturating_sub(1).min(2)
}
}
/// Runtime CPU dielectric transport policy, independent of resource and preview budgets.
/// StrictTir preserves deterministic TIR and refuses positive dielectric-depth tails.
/// It does not promise convergence or general physical glass support.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CpuDielectricTransport {
#[default]
LegacyBounded,
StrictTir,
}
impl CpuDielectricTransport {
pub fn is_legacy(&self) -> bool {
*self == Self::LegacyBounded
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct RaytraceOptions {
#[serde(skip_serializing_if = "CpuDielectricTransport::is_legacy")]
pub dielectric_transport: CpuDielectricTransport,
#[serde(skip_serializing_if = "directional_shadow_disabled")]
pub directional_shadow_transmission: bool,
#[serde(skip_serializing_if = "CpuBudget::is_compact")]
pub budget: CpuBudget,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -700,6 +761,8 @@ pub struct RaytraceOptions {
impl Default for RaytraceOptions {
fn default() -> Self {
Self {
dielectric_transport: CpuDielectricTransport::LegacyBounded,
directional_shadow_transmission: false,
budget: CpuBudget::Compact,
progressive: None,
punctual_lights: None,
@@ -721,6 +784,10 @@ impl Default for RaytraceOptions {
}
}
fn directional_shadow_disabled(value: &bool) -> bool {
!*value
}
fn single_primary_sample(samples: &u8) -> bool {
*samples == 1
}
@@ -759,6 +826,12 @@ fn bounded_area_lights<'de, D: serde::Deserializer<'de>>(deserializer: D) -> std
if lights.len() == 4 {
return Err(serde::de::Error::custom("at most four rectangular lights"));
}
if lights.is_empty() {
lights.try_reserve_exact(4).map_err(serde::de::Error::custom)?;
if lights.capacity() > 4 {
return Err(serde::de::Error::custom("rectangular light capacity exceeds four"));
}
}
lights.push(light);
}
Ok(lights)
@@ -883,6 +956,11 @@ impl RaytraceOptions {
Ok(())
}
pub fn validate(&self) -> Result<()> {
// Opaque reflection has a separate bounded continuation policy. Do not
// advertise strict dielectric tails across that unqualified consumer.
if self.dielectric_transport == CpuDielectricTransport::StrictTir && self.reflections.as_ref().is_some_and(|r| !r.bodies.is_empty()) {
return Err(JobError::new(ErrorCode::InvalidRequest, "strict CPU dielectric transport does not support selected opaque reflections yet"));
}
if let Some(progressive) = self.progressive {
self.budget.validate_samples(progressive.samples_per_pixel)?;
if self.primary_samples != 1 || self.reflections.as_ref().is_some_and(|r| !r.bodies.is_empty() && r.samples != 1) {
@@ -891,14 +969,11 @@ impl RaytraceOptions {
"CPU progressive requires primary_samples: 1 and one reflection attempt per camera sample",
));
}
if !self.dielectrics.is_empty()
|| self.anisotropic_faces.iter().any(Option::is_some)
|| self.area_lights.as_ref().is_some_and(|lights| !lights.is_empty())
{
return Err(JobError::new(
ErrorCode::InvalidRequest,
"CPU progressive currently supports isotropic opaque surfaces and directional/point/spot lights; use fixed sampling for dielectric, anisotropic or area/LED inputs",
));
// Smooth certified dielectric trees now complete one real camera
// sample before film accumulation. Anisotropic progressive transport
// remains unsupported; geometry/identity certificates run at capture.
if self.anisotropic_faces.iter().any(Option::is_some) {
return Err(JobError::new(ErrorCode::InvalidRequest, "CPU progressive does not support anisotropic inputs; use fixed sampling"));
}
}
if self.environment.is_some_and(|environment| {
@@ -963,31 +1038,7 @@ impl RaytraceOptions {
}
if let Some(lights) = &self.area_lights {
for light in lights {
use solidcraft_geom::Vec3;
let vec = |v: [f64; 3]| Vec3::new(v[0], v[1], v[2]);
let c = vec(light.center_mm);
let u = vec(light.edge_u_mm);
let v = vec(light.edge_v_mm);
if !c.is_finite()
|| !u.is_finite()
|| !v.is_finite()
|| !light.color.iter().all(|x| x.is_finite() && (0.0..=1.0).contains(x))
|| !light.intensity.is_finite()
|| !(0.0..=64.0).contains(&light.intensity)
|| !(1e-6..=1e6).contains(&u.len())
|| !(1e-6..=1e6).contains(&v.len())
|| (u.dot(v) / (u.len() * v.len())).abs() > 1e-10
{
return Err(JobError::new(ErrorCode::InvalidRequest, "invalid finite rectangular light"));
}
for a in [-0.5, 0.5] {
for b in [-0.5, 0.5] {
let p = c + u * a + v * b;
if !p.is_finite() || [p.x, p.y, p.z].into_iter().any(|x| x.abs() > 1e9) {
return Err(JobError::new(ErrorCode::InvalidRequest, "rectangular light corner exceeds world bounds"));
}
}
}
light.validate()?;
}
}
if let Some(lights) = &self.punctual_lights {
@@ -1027,8 +1078,8 @@ impl RaytraceOptions {
if self.preset.is_some() && self.lights.is_some() {
return Err(JobError::new(ErrorCode::InvalidRequest, "choose either a lighting preset or custom lights"));
}
if !self.exposure_ev.is_finite() || !(-16.0..=16.0).contains(&self.exposure_ev) {
return Err(JobError::new(ErrorCode::InvalidRequest, "raytrace exposure_ev must be finite and within -16..16"));
if !self.exposure_ev.is_finite() || !(-32.0..=32.0).contains(&self.exposure_ev) {
return Err(JobError::new(ErrorCode::InvalidRequest, "raytrace exposure_ev must be finite and within -32..32"));
}
if let Some(lights) = &self.lights {
if lights.len() > 8 || lights.capacity() > 8 {
@@ -1045,6 +1096,20 @@ impl RaytraceOptions {
}
}
}
if self.directional_shadow_transmission
&& (!self.shadows
|| self.dielectrics.is_empty()
|| self.reflections.is_some()
|| self.anisotropic_faces.iter().any(Option::is_some)
|| self.environment.is_some()
|| self.punctual_lights.as_ref().is_some_and(|lights| !lights.is_empty())
|| self.area_lights.as_ref().is_some_and(|lights| !lights.is_empty()))
{
return Err(JobError::new(
ErrorCode::InvalidRequest,
"directional shadow transmission requires shadows and selected dielectrics without reflections, anisotropy, environment or local lights",
));
}
Ok(())
}
}
@@ -1053,6 +1118,17 @@ impl RaytraceOptions {
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ImageRequest {
/// Resolved Raster-only capture of RenderSetup.exposure_ev, never a second authority.
#[serde(default, skip_serializing_if = "zero_raster_exposure")]
pub raster_exposure_ev: f64,
/// Explicit transient opaque Raster rig; None keeps the original implicit studio.
#[serde(default, deserialize_with = "deserialize_raster_lights", skip_serializing_if = "Option::is_none")]
pub raster_lights: Option<Vec<DirectionalLightOptions>>,
/// Runtime-only local rig. Any explicit local collection suppresses implicit studio lights.
#[serde(default, deserialize_with = "deserialize_raster_punctual_lights", skip_serializing_if = "Option::is_none")]
pub raster_punctual_lights: Option<Vec<PunctualLightOptions>>,
#[serde(default, deserialize_with = "deserialize_raster_area_lights", skip_serializing_if = "Option::is_none")]
pub raster_area_lights: Option<Vec<RectangleLightOptions>>,
/// Transient visibility of the three implicit studio lights; never saved in a design.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub studio_weights: Option<[f64; 3]>,
@@ -1085,6 +1161,95 @@ pub enum AlphaConvention {
}
impl ImageRequest {
pub fn has_local_raster_lights(&self) -> bool {
self.raster_punctual_lights.is_some() || self.raster_area_lights.is_some()
}
pub fn has_explicit_raster_lights(&self) -> bool {
self.raster_lights.is_some() || self.has_local_raster_lights()
}
/// Only the new local-rig route charges each bounded light evaluation.
/// Existing directional and implicit routes retain their original pixel budget.
pub(super) fn raster_light_work_factor(&self) -> Result<u64> {
if !self.has_local_raster_lights() {
return Ok(1);
}
self.validate_raster_request()?;
let count = self
.raster_lights
.as_ref()
.map_or(0, Vec::len)
.checked_add(self.raster_punctual_lights.as_ref().map_or(0, Vec::len))
.and_then(|n| n.checked_add(self.raster_area_lights.as_ref().map_or(0, Vec::len)))
.ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "Raster light work overflow"))?;
u64::try_from(count.max(1)).map_err(|_| JobError::new(ErrorCode::LimitExceeded, "Raster light work overflow"))
}
/// Reject explicit Raster intent before allocating or entering another producer.
pub fn validate_raster_request(&self) -> Result<()> {
if !self.raster_exposure_ev.is_finite() || !(-32.0..=32.0).contains(&self.raster_exposure_ev) {
return Err(JobError::new(ErrorCode::InvalidRequest, "Raster exposure must be finite and within -32..32 EV"));
}
if self.raster_exposure_ev != 0.0
&& (self.integrator != Integrator::Raster || self.raytrace.is_some() || self.environment.is_some() || self.textures.is_some())
{
return Err(JobError::new(ErrorCode::InvalidRequest, "Raster exposure cannot enter another integrator or captured GPU resources"));
}
raster_lights::validate(&self.raster_lights)?;
raster_lights::validate_punctual(&self.raster_punctual_lights)?;
raster_lights::validate_areas(&self.raster_area_lights)?;
if self.has_explicit_raster_lights()
&& (self.integrator != Integrator::Raster
|| self.raytrace.is_some()
|| self.studio_weights.is_some()
|| self.environment.is_some()
|| self.textures.is_some())
{
return Err(JobError::new(
ErrorCode::InvalidRequest,
"explicit Raster lights require Raster without raytrace, GPU resources or studio_weights",
));
}
Ok(())
}
pub fn raster_lights_retained_bytes(&self) -> Result<u64> {
raster_lights::retained_bytes(&self.raster_lights)?
.checked_add(raster_lights::local_retained_bytes(&self.raster_punctual_lights, &self.raster_area_lights)?)
.ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "Raster light retained size overflow"))
}
pub(super) fn raster_lights_task_bytes(&self) -> Result<u64> {
raster_lights::task_bytes(&self.raster_lights)?
.checked_add(raster_lights::local_task_bytes(&self.raster_punctual_lights, &self.raster_area_lights)?)
.ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "Raster task light size overflow"))
}
/// Internal status/task copy: the new Vec allocation is bounded and fallible.
pub(super) fn try_clone_for_host(&self) -> Result<Self> {
self.validate_raster_request()?;
// Preserve the old path, including existing unrelated payload semantics.
if !self.has_explicit_raster_lights() {
return Ok(self.clone());
}
Ok(Self {
raster_exposure_ev: self.raster_exposure_ev,
raster_lights: raster_lights::try_copy(&self.raster_lights)?,
raster_punctual_lights: raster_lights::try_copy_punctual(&self.raster_punctual_lights)?,
raster_area_lights: raster_lights::try_copy_areas(&self.raster_area_lights)?,
studio_weights: self.studio_weights,
environment: self.environment.clone(),
textures: self.textures.clone(),
integrator: self.integrator,
raytrace: None, // validated absent above, never clone-then-clear
camera: self.camera,
width: self.width,
height: self.height,
background: self.background,
transparent_background: self.transparent_background,
})
}
pub fn cpu_budget(&self) -> CpuBudget {
if self.integrator == Integrator::Raytrace {
self.raytrace.as_ref().map_or(CpuBudget::Compact, |options| options.budget)
@@ -1103,6 +1268,10 @@ impl ImageRequest {
}
}
fn zero_raster_exposure(value: &f64) -> bool {
*value == 0.0
}
#[derive(Clone, Debug, Serialize)]
pub struct FrameInfo {
pub job: JobId,
@@ -1120,7 +1289,224 @@ pub struct FrameInfo {
}
#[cfg(test)]
mod runtime_request_roundtrip_tests {
fn raster_local_test_request() -> ImageRequest {
serde_json::from_value(
serde_json::json!({"camera":crate::Camera::default(),"width":8,"height":8,"background":[0,0,0],"transparent_background":false}),
)
.unwrap()
}
fn raster_local_test_point() -> PunctualLightOptions {
PunctualLightOptions {
position_mm: [0.0, 0.0, 10.0],
color: [1.0; 3],
intensity: 2.0,
reference_distance_mm: 10.0,
minimum_distance_mm: 0.001,
cone: None,
}
}
fn raster_local_test_rectangle() -> RectangleLightOptions {
RectangleLightOptions {
center_mm: [0.0, 0.0, 10.0],
edge_u_mm: [2.0, 0.0, 0.0],
edge_v_mm: [0.0, -2.0, 0.0],
color: [1.0; 3],
intensity: 2.0,
}
}
#[test]
fn raster_local_wire_omission_null_empty_and_host_copy_keep_distinct_intent() {
let legacy = raster_local_test_request();
let wire = serde_json::to_value(&legacy).unwrap();
assert!(wire.get("raster_punctual_lights").is_none());
assert!(wire.get("raster_area_lights").is_none());
assert!(!legacy.has_explicit_raster_lights());
let mut null = wire.clone();
null["raster_punctual_lights"] = serde_json::Value::Null;
null["raster_area_lights"] = serde_json::Value::Null;
let decoded: ImageRequest = serde_json::from_value(null).unwrap();
assert_eq!(serde_json::to_value(decoded).unwrap(), wire);
for key in ["raster_punctual_lights", "raster_area_lights"] {
let mut explicit = wire.clone();
explicit[key] = serde_json::json!([]);
let input: ImageRequest = serde_json::from_value(explicit).unwrap();
assert!(input.has_explicit_raster_lights() && input.has_local_raster_lights());
assert!(input.raster_lights.is_none(), "no implicit directional materialisation");
assert_eq!(input.raster_light_work_factor().unwrap(), 1);
let copy = input.try_clone_for_host().unwrap();
assert_eq!(serde_json::to_value(copy).unwrap(), serde_json::to_value(input).unwrap());
}
}
#[test]
fn raster_local_eight_shared_point_spot_four_areas_and_directional_caps_are_independent() {
let mut input = raster_local_test_request();
let point = raster_local_test_point();
let mut spot = point;
spot.cone = Some(SpotConeOptions { emitted_axis: [0.0, 0.0, -1.0], inner_half_angle_radians: 0.2, outer_half_angle_radians: 0.5 });
input.raster_lights = Some(vec![DirectionalLightOptions { direction: [0.0, 0.0, 1.0], color: [1.0; 3], intensity: 1.0 }; 8]);
assert_eq!(input.raster_light_work_factor().unwrap(), 1, "old directional work path unchanged");
input.raster_punctual_lights = Some(vec![point, spot, point, spot, point, spot, point, spot]);
input.raster_area_lights = Some(vec![raster_local_test_rectangle(); 4]);
input.validate_raster_request().unwrap();
assert_eq!(input.raster_light_work_factor().unwrap(), 20);
input.raster_punctual_lights.as_mut().unwrap().push(point);
assert_eq!(input.validate_raster_request().unwrap_err().code, ErrorCode::InvalidRequest);
assert!(serde_json::from_value::<ImageRequest>(serde_json::to_value(&input).unwrap()).is_err());
input.raster_punctual_lights = Some(vec![point]);
input.raster_area_lights.as_mut().unwrap().push(raster_local_test_rectangle());
assert!(input.validate_raster_request().is_err());
assert!(serde_json::from_value::<ImageRequest>(serde_json::to_value(input).unwrap()).is_err());
}
#[test]
fn raster_local_spare_capacities_refuse_before_copy_or_task_reservation() {
let mut input = raster_local_test_request();
input.raster_punctual_lights = Some(Vec::with_capacity(9));
assert!(input.try_clone_for_host().is_err());
assert!(input.raster_lights_retained_bytes().is_err());
assert!(input.raster_lights_task_bytes().is_err());
input.raster_punctual_lights = None;
input.raster_area_lights = Some(Vec::with_capacity(5));
assert!(input.try_clone_for_host().is_err());
assert!(input.raster_lights_retained_bytes().is_err());
assert!(input.raster_lights_task_bytes().is_err());
}
#[test]
fn raster_local_fallible_fixed_dto_copy_preserves_values_and_accounts_both_families() {
let mut input = raster_local_test_request();
input.raster_punctual_lights = Some(vec![raster_local_test_point()]);
input.raster_area_lights = Some(vec![raster_local_test_rectangle()]);
let expected = (input.raster_punctual_lights.as_ref().unwrap().capacity() * std::mem::size_of::<PunctualLightOptions>()
+ input.raster_area_lights.as_ref().unwrap().capacity() * std::mem::size_of::<RectangleLightOptions>()) as u64;
assert_eq!(input.raster_lights_retained_bytes().unwrap(), expected);
let task_fee = (8 * std::mem::size_of::<PunctualLightOptions>() + 4 * std::mem::size_of::<RectangleLightOptions>()) as u64;
assert_eq!(input.raster_lights_task_bytes().unwrap(), task_fee);
let copy = input.try_clone_for_host().unwrap();
assert_eq!(copy.raster_punctual_lights, input.raster_punctual_lights);
assert_eq!(copy.raster_area_lights, input.raster_area_lights);
assert!(copy.raster_lights_retained_bytes().unwrap() <= task_fee);
input.raster_punctual_lights.as_mut().unwrap()[0].intensity = 0.0;
input.raster_area_lights.as_mut().unwrap()[0].intensity = 0.0;
assert_eq!(copy.raster_punctual_lights.as_ref().unwrap()[0].intensity, 2.0);
assert_eq!(copy.raster_area_lights.as_ref().unwrap()[0].intensity, 2.0);
}
#[test]
fn raster_local_empty_intent_still_refuses_other_producers_and_implicit_weights() {
for family in [0, 1] {
let mut input = raster_local_test_request();
if family == 0 {
input.raster_punctual_lights = Some(Vec::new());
} else {
input.raster_area_lights = Some(Vec::new());
}
input.studio_weights = Some([0.0; 3]);
assert!(input.validate_raster_request().is_err());
input.studio_weights = None;
input.raytrace = Some(Default::default());
assert!(input.validate_raster_request().is_err());
input.raytrace = None;
input.integrator = Integrator::Raytrace;
assert!(input.validate_raster_request().is_err());
assert!(super::interactive::cpu_request(&input).is_err());
}
}
#[test]
fn raster_local_shared_rectangle_validator_preserves_canonical_bounds_and_error_order() {
let mut rectangle = raster_local_test_rectangle();
rectangle.color = [0.0; 3];
rectangle.intensity = 0.0;
rectangle.edge_u_mm = [1.0, 1.0, 0.0];
assert_eq!(rectangle.validate().unwrap_err().message, "invalid finite rectangular light");
rectangle = raster_local_test_rectangle();
rectangle.center_mm = [1e9, 0.0, 0.0];
assert_eq!(rectangle.validate().unwrap_err().message, "rectangular light corner exceeds world bounds");
let mut input = raster_local_test_request();
input.raster_area_lights = Some(vec![rectangle.clone()]);
assert!(input.validate_raster_request().is_err());
let options = RaytraceOptions { area_lights: Some(vec![rectangle]), ..Default::default() };
assert_eq!(options.validate().unwrap_err().message, "rectangular light corner exceeds world bounds");
}
#[test]
fn raster_light_wire_is_nullable_bounded_strict_and_fallibly_copied() {
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Rig {
#[serde(default, deserialize_with = "super::raster_lights::deserialize", skip_serializing_if = "Option::is_none")]
raster_lights: Option<Vec<DirectionalLightOptions>>,
}
use serde_json::json;
for value in [json!({}), json!({"raster_lights":null})] {
let rig: Rig = serde_json::from_value(value).unwrap();
assert!(rig.raster_lights.is_none());
assert_eq!(serde_json::to_value(rig).unwrap(), json!({}));
}
let light = json!({"direction":[0.0,0.0,1.0],"color":[0.25,0.5,1.0],"intensity":2.0});
for count in [0, 1, 8] {
let rig: Rig = serde_json::from_value(json!({"raster_lights":vec![light.clone();count]})).unwrap();
assert_eq!(rig.raster_lights.as_ref().unwrap().len(), count);
assert!(rig.raster_lights.as_ref().unwrap().capacity() <= 8);
let copy = super::raster_lights::try_copy(&rig.raster_lights).unwrap();
assert_eq!(copy, rig.raster_lights);
assert!(copy.as_ref().unwrap().capacity() <= 8);
assert_eq!(serde_json::to_value(rig).unwrap(), json!({"raster_lights":vec![light.clone();count]}));
}
assert!(serde_json::from_value::<Rig>(json!({"raster_lights":vec![light.clone();9]})).is_err());
for value in [
json!(true),
json!({}),
json!([{"direction":[0,0,0],"color":[1,1,1],"intensity":0}]),
json!([{"direction":[0,0,1],"color":[1,1,1],"intensity":1,"shadows":true}]),
] {
assert!(serde_json::from_value::<Rig>(json!({"raster_lights":value})).is_err());
}
let mut excessive = Vec::with_capacity(9);
excessive.push(DirectionalLightOptions { direction: [0.0, 0.0, 1.0], color: [1.0; 3], intensity: 1.0 });
assert!(super::raster_lights::try_copy(&Some(excessive)).is_err());
for intensity in [f64::NAN, f64::INFINITY, -1.0, 65.0] {
let invalid = Some(vec![DirectionalLightOptions { direction: [0.0, 0.0, 1.0], color: [1.0; 3], intensity }]);
assert!(super::raster_lights::try_copy(&invalid).is_err());
}
}
use super::*;
#[test]
fn dielectric_transport_wire_policy_preserves_legacy_and_rejects_hostile_values() {
let legacy: RaytraceOptions = serde_json::from_value(serde_json::json!({})).unwrap();
assert_eq!(legacy.dielectric_transport, CpuDielectricTransport::LegacyBounded);
assert!(serde_json::to_value(&legacy).unwrap().get("dielectric_transport").is_none());
let strict: RaytraceOptions = serde_json::from_value(serde_json::json!({"dielectric_transport":"strict_tir"})).unwrap();
strict.validate().unwrap();
assert_eq!(serde_json::to_value(&strict).unwrap()["dielectric_transport"], "strict_tir");
for value in [serde_json::json!(null), serde_json::json!(true), serde_json::json!(7), serde_json::json!("unlimited"), serde_json::json!({})] {
assert!(serde_json::from_value::<RaytraceOptions>(serde_json::json!({"dielectric_transport":value})).is_err());
}
}
#[test]
fn strict_dielectric_policy_refuses_unqualified_opaque_continuations_without_mutation() {
let options = RaytraceOptions {
dielectric_transport: CpuDielectricTransport::StrictTir,
reflections: Some(ReflectionOptions {
bodies: vec![ReflectionBody { body: "Body".into(), mode: ReflectionMode::Mirror }],
..Default::default()
}),
..Default::default()
};
let before = options.clone();
assert_eq!(options.validate().unwrap_err().code, ErrorCode::InvalidRequest);
assert_eq!(options, before);
RaytraceOptions { dielectric_transport: CpuDielectricTransport::LegacyBounded, ..options }.validate().unwrap();
}
#[test]
fn default_preset_and_empty_lights_requests_roundtrip_without_invalid_null_sequence() {
for options in [
@@ -1186,7 +1572,7 @@ mod runtime_request_roundtrip_tests {
color: [1.0; 3],
intensity: 1.0,
};
assert!(RaytraceOptions { progressive, area_lights: Some(vec![area]), ..Default::default() }.validate().is_err());
RaytraceOptions { progressive, area_lights: Some(vec![area]), ..Default::default() }.validate().unwrap();
let dielectric = DielectricOptions {
body_index: 0,
body_name: "Body".into(),
@@ -1195,7 +1581,9 @@ mod runtime_request_roundtrip_tests {
absorption_per_mm: [0.0; 3],
thickness_mm: 1.0,
};
assert!(RaytraceOptions { progressive, dielectrics: vec![dielectric], ..Default::default() }.validate().is_err());
// Superseded global rejection: smooth optical trees are implemented per
// camera sample; capture-time certification remains separately required.
RaytraceOptions { progressive, dielectrics: vec![dielectric], ..Default::default() }.validate().unwrap();
let face = AnisotropicFaceOptions {
body_index: 0,
body_name: "Body".into(),
@@ -1208,4 +1596,308 @@ mod runtime_request_roundtrip_tests {
assert!(RaytraceOptions { progressive, anisotropic_faces: [Some(face), None], ..Default::default() }.validate().is_err());
RaytraceOptions { progressive, area_lights: Some(Vec::new()), ..Default::default() }.validate().unwrap();
}
#[test]
fn progressive_rectangle_and_led_admission_preserves_source_limits_and_atomic_rejection() {
let strip = led_strip::LedStripOptions {
center_mm: [0.0, 0.0, 10.0],
length_mm: 2.0,
width_mm: 2.0,
length_axis: [1.0, 0.0, 0.0],
width_axis: [0.0, -1.0, 0.0],
color: [1.0, 0.5, 0.25],
intensity: 2.0,
};
for samples in [1, 4, 16, 64] {
let mut options = RaytraceOptions {
progressive: Some(CpuProgressiveOptions { samples_per_pixel: 3, seed: 97 }),
lights: Some(Vec::new()),
..Default::default()
};
for count in 1..=4 {
strip.append_to(&mut options, samples).unwrap();
options.validate().unwrap();
assert_eq!(options.area_lights.as_ref().unwrap().len(), count);
assert_eq!(
options.retained_bytes().unwrap(),
(options.area_lights.as_ref().unwrap().capacity() * std::mem::size_of::<RectangleLightOptions>()) as u64
);
}
let before = options.clone();
let capacity = options.area_lights.as_ref().unwrap().capacity();
assert!(strip.append_to(&mut options, samples).is_err());
assert_eq!(options, before);
assert_eq!(options.area_lights.as_ref().unwrap().capacity(), capacity);
options.area_lights.as_mut().unwrap().pop();
let before = options.clone();
let conflicting = if samples == 1 { 4 } else { 1 };
assert!(strip.append_to(&mut options, conflicting).is_err());
assert_eq!(options, before);
for invalid in [0, 2, 65, u32::MAX] {
let mut hostile = options.clone();
hostile.area_samples = Some(invalid);
assert!(hostile.validate().is_err());
}
let mut hostile = options.clone();
hostile.area_lights.as_mut().unwrap()[0].intensity = f64::NAN;
assert!(hostile.validate().is_err());
}
let mut maximum = RaytraceOptions {
progressive: Some(CpuProgressiveOptions::default()),
lights: Some(vec![DirectionalLightOptions { direction: [0.0, 0.0, 1.0], color: [1.0; 3], intensity: 1.0 }; 8]),
punctual_lights: Some(vec![
PunctualLightOptions {
position_mm: [0.0, 0.0, 10.0],
color: [1.0; 3],
intensity: 1.0,
reference_distance_mm: 10.0,
minimum_distance_mm: 0.001,
cone: None,
};
8
]),
..Default::default()
};
for _ in 0..4 {
strip.append_to(&mut maximum, 16).unwrap();
}
maximum.validate().unwrap();
maximum.area_lights.as_mut().unwrap().push(strip.rectangle().unwrap());
assert!(maximum.validate().is_err());
for options in [
RaytraceOptions { progressive: Some(CpuProgressiveOptions::default()), area_samples: Some(16), ..Default::default() },
RaytraceOptions { progressive: Some(CpuProgressiveOptions::default()), seed: Some(7), ..Default::default() },
] {
assert!(options.validate().is_err());
}
}
}
/// Fixed representative presentation plan; fitting never grants source admission.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PartialPreviewPlan {
source_width: u32,
source_height: u32,
width: u32,
height: u32,
}
impl PartialPreviewPlan {
pub fn source_width(&self) -> u32 {
self.source_width
}
pub fn source_height(&self) -> u32 {
self.source_height
}
pub fn width(&self) -> u32 {
self.width
}
pub fn height(&self) -> u32 {
self.height
}
pub fn new(source_width: u32, source_height: u32) -> Result<Self> {
if source_width == 0 || source_height == 0 {
return Err(JobError::new(ErrorCode::InvalidRequest, "partial preview source dimensions must be positive"));
}
let edge = source_width.max(source_height);
let fit = |side: u32| {
if edge <= 1024 { side } else { ((u64::from(side) * 1024 + u64::from(edge) / 2) / u64::from(edge)).max(1) as u32 }
};
Ok(Self { source_width, source_height, width: fit(source_width), height: fit(source_height) })
}
pub fn validate(&self) -> Result<()> {
if *self != Self::new(self.source_width, self.source_height)? {
return Err(JobError::new(ErrorCode::InvalidRequest, "partial preview dimensions differ from the fixed plan"));
}
Ok(())
}
pub fn pixels(&self) -> u64 {
u64::from(self.width) * u64::from(self.height)
}
pub fn source_index(&self, preview_index: usize) -> Result<usize> {
self.validate()?;
let index = u64::try_from(preview_index).map_err(|_| JobError::new(ErrorCode::LimitExceeded, "partial preview index overflow"))?;
if index >= self.pixels() {
return Err(JobError::new(ErrorCode::InvalidRequest, "partial preview index outside its plan"));
}
let x = (index % u64::from(self.width)) * u64::from(self.source_width) / u64::from(self.width);
let y = (index / u64::from(self.width)) * u64::from(self.source_height) / u64::from(self.height);
let source = y
.checked_mul(u64::from(self.source_width))
.and_then(|v| v.checked_add(x))
.ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "partial source index overflow"))?;
usize::try_from(source).map_err(|_| JobError::new(ErrorCode::LimitExceeded, "partial source index exceeds address space"))
}
pub fn encoded_limit(&self) -> Result<u64> {
self.validate()?;
self.pixels()
.checked_mul(8)
.and_then(|v| v.checked_add(65_536))
.ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "partial PNG capacity overflow"))
}
pub fn covered_bytes(&self) -> Result<u64> {
self.validate()?;
self.pixels().checked_mul(4).ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "partial coverage capacity overflow"))
}
pub fn snapshot_work(&self) -> Result<u64> {
self.validate()?;
self.pixels()
.checked_mul(14)
.and_then(|v| v.checked_add(65_538))
.ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "partial work bound overflow"))
}
/// Two retained payload slots; existing admitted RGBA/encoder scratch is reused.
#[cfg(feature = "raytrace-cpu")]
pub(crate) fn additional_bytes(&self) -> Result<u64> {
self.slot_bytes()?
.checked_mul(2)
.and_then(|v| v.checked_add(1024))
.ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "partial preview admission overflow"))
}
pub(crate) fn slot_bytes(&self) -> Result<u64> {
self.encoded_limit()?
.checked_add(self.covered_bytes()?)
.and_then(|v| v.checked_add(2048))
.ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "partial retained slot overflow"))
}
}
/// Explicit protected-feedback aggregate ceiling; the ordinary MAX_CPU_WORK is unchanged.
// At most 1024² representatives: 14 work units each plus 65,538 codec/hash
// overhead. At most 64 early milestones and MAX_SPP-1 complete-pass snapshots.
// This Cartesian upper bound is deliberately conservative; ordinary work is unchanged.
pub const MAX_PROTECTED_CPU_WORK: u64 = MAX_CPU_WORK + 14_745_602 * (MAX_CPU_SAMPLES_PER_PIXEL as u64 + 63);
/// Checked per-job feedback policy, not an admission token or an arbitrary work envelope.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ProtectedPreviewPlan {
preview: PartialPreviewPlan,
samples_per_pixel: u32,
budget: CpuBudget,
total_commits: u64,
quota_limit: u32,
}
impl ProtectedPreviewPlan {
pub fn new(source_width: u32, source_height: u32, samples_per_pixel: u32, budget: CpuBudget) -> Result<Self> {
budget.validate_image(source_width, source_height, Some(CpuProgressiveOptions { samples_per_pixel, seed: 0 }))?;
let preview = PartialPreviewPlan::new(source_width, source_height)?;
let total_commits = u64::from(source_width)
.checked_mul(u64::from(source_height))
.and_then(|v| v.checked_mul(u64::from(samples_per_pixel)))
.ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "protected camera-commit count overflow"))?;
let pixels = u64::from(source_width) * u64::from(source_height);
let early =
u32::try_from(pixels.saturating_sub(1).min(64)).map_err(|_| JobError::new(ErrorCode::LimitExceeded, "protected early quota overflow"))?;
let quota_limit =
early.checked_add(samples_per_pixel - 1).ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "protected quota overflow"))?;
let plan = Self { preview, samples_per_pixel, budget, total_commits, quota_limit };
plan.aggregate_limit()?;
Ok(plan)
}
pub fn validate(&self) -> Result<()> {
if *self != Self::new(self.preview.source_width(), self.preview.source_height(), self.samples_per_pixel, self.budget)? {
return Err(JobError::new(ErrorCode::InvalidRequest, "protected feedback plan differs from its canonical policy"));
}
Ok(())
}
pub fn preview_plan(&self) -> PartialPreviewPlan {
self.preview
}
pub fn samples_per_pixel(&self) -> u32 {
self.samples_per_pixel
}
pub fn budget(&self) -> CpuBudget {
self.budget
}
pub fn total_commits(&self) -> u64 {
self.total_commits
}
pub fn ordinary_limit(&self) -> u64 {
self.budget.limits().max_work_units
}
pub fn quota_limit(&self) -> u32 {
self.quota_limit
}
pub fn snapshot_work(&self) -> Result<u64> {
self.preview.snapshot_work()
}
pub fn presentation_limit(&self) -> Result<u64> {
self.snapshot_work()?
.checked_mul(u64::from(self.quota_limit))
.ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "protected presentation allowance overflow"))
}
pub fn aggregate_limit(&self) -> Result<u64> {
self.ordinary_limit()
.checked_add(self.presentation_limit()?)
.filter(|v| *v <= MAX_PROTECTED_CPU_WORK)
.ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "protected aggregate work exceeds its explicit ceiling"))
}
/// Number of reached milestones in O(1), including a final sentinel without
/// adding a final-image snapshot. A delayed consumer cannot create a long
/// uninterruptible coalescing loop in the worker.
#[cfg(feature = "raytrace-cpu")]
pub(crate) fn reached_milestones(&self, committed: u64) -> Result<u32> {
if committed > self.total_commits {
return Err(JobError::new(ErrorCode::InvalidRequest, "protected frontier exceeds its final target"));
}
let pixels = u64::from(self.preview.source_width()) * u64::from(self.preview.source_height());
let early = pixels.saturating_sub(1).min(64);
let reached_early = if committed == 0 || early == 0 {
0
} else if committed >= pixels - 1 {
early
} else if early == 1 {
1
} else {
committed
.checked_mul(early - 1)
.map(|n| n.div_ceil(pixels - 2))
.ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "protected reached milestone overflow"))?
};
let passes = (committed / pixels).min(u64::from(self.samples_per_pixel - 1));
reached_early
.checked_add(passes)
.and_then(|n| u32::try_from(n).ok())
.filter(|n| *n <= self.quota_limit)
.ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "protected reached quota overflow"))
}
/// Zero-based, strictly increasing milestones below the final target.
pub fn milestone(&self, index: u32) -> Result<u64> {
if index >= self.quota_limit {
return Err(JobError::new(ErrorCode::InvalidRequest, "protected milestone index outside its quota"));
}
let pixels = u64::from(self.preview.source_width()) * u64::from(self.preview.source_height());
let early = u32::try_from(pixels.saturating_sub(1).min(64))
.map_err(|_| JobError::new(ErrorCode::LimitExceeded, "protected early milestone overflow"))?;
if index < early {
if early == 1 {
return Ok(1);
}
return pixels
.checked_sub(2)
.and_then(|n| n.checked_mul(u64::from(index)))
.and_then(|n| n.checked_div(u64::from(early - 1)))
.and_then(|n| n.checked_add(1))
.ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "protected early milestone arithmetic overflow"));
}
u64::from(index - early + 1)
.checked_mul(pixels)
.ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "protected pass milestone arithmetic overflow"))
}
}
/// Actual admitted policy and monotonic producer usage; never a saved design field.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
pub struct ProtectedPreviewReport {
pub policy_version: u32,
pub ordinary_limit: u64,
pub presentation_limit: u64,
pub aggregate_limit: u64,
pub ordinary_used: u64,
pub presentation_used: u64,
pub accepted_snapshots: u32,
pub quota_limit: u32,
pub next_milestone_index: u32,
pub quota_exhausted: bool,
pub milestones_complete: bool,
}
+148 -7
View File
@@ -98,6 +98,14 @@ impl Photographic {
}
count.checked_mul(8).and_then(|n| n.checked_add(16_384)).ok_or_else(|| invalid("photographic scratch size overflow"))
}
pub(crate) fn scratch_bytes_with_limits(self, width: u32, height: u32, limits: crate::RenderLimits) -> Result<u64> {
self.validate()?;
let count = super::linear_exr::output_pixels_with_limits(width, height, limits)?;
if !self.active() {
return Ok(0);
}
super::linear_exr::checked_stage_bytes(count, 8, 16_384, limits)
}
}
#[derive(Clone, Copy, Debug, Default)]
struct Highlight {
@@ -114,6 +122,39 @@ pub struct PhotographicTask {
impl PhotographicTask {
pub fn new(width: u32, height: u32, source: Vec<u8>, linear: Vec<[f32; 4]>, options: Photographic, cancel: CancelToken) -> Result<Self> {
options.scratch_bytes(width, height)?;
Self::create(width, height, source, linear, options, cancel, GPU_PATH_LIMITS)
}
pub(crate) fn new_with_limits(
width: u32,
height: u32,
source: Vec<u8>,
linear: Vec<[f32; 4]>,
options: Photographic,
cancel: CancelToken,
limits: crate::RenderLimits,
) -> Result<Self> {
options.scratch_bytes_with_limits(width, height, limits)?;
cancel.check()?;
let count = super::linear_exr::output_pixels_with_limits(width, height, limits)?;
if usize::try_from(count).ok() != Some(linear.len()) || linear.len().checked_mul(4) != Some(source.len()) || !options.active() {
return Err(invalid("photographic stage requires an admitted opaque image, matching source pixels and an active effect"));
}
let raw_bytes = super::linear_exr::checked_output_bytes(linear.capacity() as u64, 16, 0, limits)? as u64;
let source_bytes = super::linear_exr::checked_output_bytes(source.capacity() as u64, 1, 0, limits)? as u64;
let retained =
raw_bytes.checked_add(source_bytes).and_then(|n| n.checked_add(16_384)).ok_or_else(|| invalid("photographic retained byte overflow"))?;
super::linear_exr::checked_stage_bytes(count, 4, retained, limits)?;
Self::create(width, height, source, linear, options, cancel, limits)
}
fn create(
width: u32,
height: u32,
source: Vec<u8>,
linear: Vec<[f32; 4]>,
options: Photographic,
cancel: CancelToken,
limits: crate::RenderLimits,
) -> Result<Self> {
if std::mem::size_of::<PhotographicState>() > 16_384 {
return Err(invalid("photographic metadata exceeds its admitted allowance"));
}
@@ -122,7 +163,7 @@ impl PhotographicTask {
if state.capacity() != 1 {
return Err(invalid("photographic state exceeds bounded capacity"));
}
state.push(PhotographicState::new(width, height, source, linear, options, cancel)?);
state.push(PhotographicState::new(width, height, source, linear, options, cancel, limits)?);
Ok(Self { state })
}
pub fn step(&mut self, units: usize) -> Result<bool> {
@@ -153,21 +194,32 @@ struct PhotographicState {
ready: bool,
}
impl PhotographicState {
pub fn new(width: u32, height: u32, source: Vec<u8>, linear: Vec<[f32; 4]>, options: Photographic, cancel: CancelToken) -> Result<Self> {
pub fn new(
width: u32,
height: u32,
source: Vec<u8>,
linear: Vec<[f32; 4]>,
options: Photographic,
cancel: CancelToken,
limits: crate::RenderLimits,
) -> Result<Self> {
options.validate()?;
cancel.check()?;
let count = u64::from(width) * u64::from(height);
if width == 0
|| height == 0
|| u64::from(width) > GPU_PATH_LIMITS.max_dimension
|| u64::from(height) > GPU_PATH_LIMITS.max_dimension
|| count > GPU_PATH_LIMITS.max_pixels
|| super::linear_exr::output_pixels_with_limits(width, height, limits).is_err()
|| usize::try_from(count).ok() != Some(linear.len())
|| linear.len().checked_mul(4) != Some(source.len())
|| !options.active()
{
return Err(invalid("photographic stage requires an admitted opaque image, matching source pixels and an active effect"));
}
let raw_bytes = super::linear_exr::checked_output_bytes(linear.capacity() as u64, 16, 0, limits)? as u64;
let source_bytes = super::linear_exr::checked_output_bytes(source.capacity() as u64, 1, 0, limits)? as u64;
let retained =
raw_bytes.checked_add(source_bytes).and_then(|n| n.checked_add(16_384)).ok_or_else(|| invalid("photographic retained byte overflow"))?;
super::linear_exr::checked_stage_bytes(count, 4, retained, limits)?;
let mut output = Vec::new();
output.try_reserve_exact(source.len()).map_err(|_| JobError::new(ErrorCode::AllocationFailed, "photographic output allocation failed"))?;
if output.capacity() != source.len() {
@@ -373,9 +425,15 @@ impl PhotographicState {
}
self.output.push(255);
}
self.pixel = end;
self.commit_pixels(end)
}
// Output bytes are provisional until the final cancellation check succeeds.
// Keep the old cursor on failure so finish cannot return an unacknowledged
// terminal batch. Cancellation after an acknowledged completion is unchanged.
fn commit_pixels(&mut self, end: usize) -> Result<bool> {
self.cancel.check()?;
Ok(end == count)
self.pixel = end;
Ok(end == self.width * self.height)
}
pub fn progress(&self) -> (u64, u64) {
(self.scanned as u64 + self.pixel as u64, (self.width * self.height * if self.options.flare.strength > 0.0 { 2 } else { 1 }) as u64)
@@ -449,4 +507,87 @@ mod tests {
assert!(task.step(1).is_err());
assert!(serde_json::from_str::<Photographic>(r#"{"physical_lens":true}"#).is_err());
}
#[test]
fn explicit_wide_photographic_keeps_constants_and_legacy_refusal() {
let wide = super::super::CpuBudget::Workstation.image_limits(false);
let options = Photographic { radial_distortion: 0.1, chromatic_pixels: 2.0, soft_focus: 0.3, sharpening: 0.2, ..Default::default() };
assert!(PhotographicTask::new(8193, 1, vec![], vec![], options, CancelToken::default()).is_err());
let rgba = [128, 128, 128, 255].repeat(8193);
let mut task =
PhotographicTask::new_with_limits(8193, 1, rgba.clone(), vec![[0.25, 0.25, 0.25, 1.0]; 8193], options, CancelToken::default(), wide)
.unwrap();
while !task.step(256).unwrap() {}
assert_eq!(task.finish().unwrap(), rgba);
let cancel = CancelToken::default();
cancel.cancel();
assert_eq!(Photographic::default().scratch_bytes(0, u32::MAX).unwrap(), 0);
assert_eq!(PhotographicTask::new(0, 1, vec![], vec![], options, cancel.clone()).err().unwrap().code, ErrorCode::InvalidRequest);
assert_eq!(PhotographicTask::new(0, 1, vec![], vec![], Photographic::default(), cancel).err().unwrap().code, ErrorCode::Cancelled);
}
#[test]
fn explicit_gpu_photographic_matches_legacy_and_cancel_never_finishes() {
let options = Photographic { grain: 0.03, seed: 37, ..Default::default() };
let rgba = [64, 128, 192, 255].repeat(6);
let linear = vec![[0.1, 0.5, 2.0, 1.0]; 6];
let mut legacy = PhotographicTask::new(3, 2, rgba.clone(), linear.clone(), options, CancelToken::default()).unwrap();
let mut explicit =
PhotographicTask::new_with_limits(3, 2, rgba.clone(), linear.clone(), options, CancelToken::default(), GPU_PATH_LIMITS).unwrap();
while !legacy.step(1).unwrap() {}
while !explicit.step(16).unwrap() {}
assert_eq!(legacy.finish().unwrap(), explicit.finish().unwrap());
// A pre-cancelled request is independently valid: matching 3x2 opaque
// buffers, finite samples and an active in-range effect. Cancellation
// is the constructor's refusal, not invalid fixture data.
let pre_cancelled = CancelToken::default();
pre_cancelled.cancel();
assert_eq!(
PhotographicTask::new_with_limits(3, 2, rgba.clone(), linear.clone(), options, pre_cancelled, GPU_PATH_LIMITS).err().unwrap().code,
ErrorCode::Cancelled,
);
let cancel = CancelToken::default();
let mut task = PhotographicTask::new_with_limits(3, 2, rgba, linear, options, cancel.clone(), GPU_PATH_LIMITS).unwrap();
assert!(!task.step(1).unwrap());
let before_cancel = task.progress();
assert!(before_cancel.0 > 0 && before_cancel.0 < before_cancel.1, "cancel real in-flight work");
cancel.cancel();
assert_eq!(task.step(1).unwrap_err().code, ErrorCode::Cancelled);
assert_eq!(task.progress(), before_cancel, "cancelled work must not advance");
// The historical finish contract rejects an incomplete image; it does
// not promise Cancelled precedence over that completeness guard.
assert!(task.finish().is_err(), "a cancelled incomplete image must never be returned");
}
#[test]
fn cancelled_photographic_cursor_commit_keeps_written_output_provisional() {
let cancel = CancelToken::default();
let options = Photographic { soft_focus: 0.5, ..Default::default() };
let rgba = vec![64, 128, 192, 255];
let mut state = PhotographicState::new(1, 1, rgba.clone(), vec![[0.1, 0.5, 2.0, 1.0]], options, cancel.clone(), GPU_PATH_LIMITS).unwrap();
let before_commit = state.progress();
assert_eq!(before_commit, (0, 1));
// Local commit-seam unit: valid bytes stand for the completed final
// batch, but its cursor has not been acknowledged. No scheduling race
// or end-to-end rendering is simulated or claimed by this fixture.
state.output.extend_from_slice(&rgba);
assert_eq!(state.output.len(), 4);
cancel.cancel();
assert_eq!(state.commit_pixels(1).unwrap_err().code, ErrorCode::Cancelled);
assert_eq!(state.progress(), before_commit);
assert!(state.finish().is_err(), "unacknowledged bytes cannot become a finished image");
}
#[test]
fn photographic_finish_after_acknowledged_completion_survives_later_cancel() {
let cancel = CancelToken::default();
let options = Photographic { soft_focus: 0.5, ..Default::default() };
let rgba = vec![64, 128, 192, 255];
let mut task =
PhotographicTask::new_with_limits(1, 1, rgba.clone(), vec![[0.1, 0.5, 2.0, 1.0]], options, cancel.clone(), GPU_PATH_LIMITS).unwrap();
assert!(task.step(1).unwrap(), "the real step must acknowledge completion first");
assert_eq!(task.progress(), (1, 1));
cancel.cancel();
assert_eq!(task.finish().unwrap(), rgba, "later cancellation preserves the historical completed finish contract");
}
}
+77 -1
View File
@@ -51,8 +51,20 @@ impl PngTask {
return Err(JobError::new(ErrorCode::InvalidRequest, "invalid PNG dimensions or RGBA length"));
}
cancel.check()?;
Self::new_with_limits(request, rgba, cancel, super::GPU_PATH_LIMITS)
}
pub(crate) fn new_with_limits(request: &ImageRequest, rgba: Vec<u8>, cancel: CancelToken, limits: crate::RenderLimits) -> Result<Self> {
let pixels = super::linear_exr::output_pixels_with_limits(request.width, request.height, limits)?;
let rgba_bytes = super::linear_exr::checked_output_bytes(pixels, 4, 0, limits)?;
if rgba.len() != rgba_bytes {
return Err(JobError::new(ErrorCode::InvalidRequest, "invalid PNG dimensions or RGBA length"));
}
let limit = super::linear_exr::checked_output_bytes(pixels, 8, 65_536, limits)?;
let retained = super::linear_exr::checked_output_bytes(rgba.capacity() as u64, 1, 0, limits)? as u64;
super::linear_exr::checked_stage_bytes(1, retained, limit as u64, limits)?;
cancel.check()?;
let bytes = Arc::new(Mutex::new(Vec::new()));
let output = Output { bytes: bytes.clone(), limit: (pixels * 8 + 65_536) as usize, cancel: cancel.clone() };
let output = Output { bytes: bytes.clone(), limit, cancel: cancel.clone() };
let mut encoder = png::Encoder::new(output, request.width, request.height);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
@@ -85,3 +97,67 @@ impl ImageWork for PngTask {
Ok(TaskPoll::Complete(std::mem::take(&mut *bytes)))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn request(width: u32, height: u32) -> ImageRequest {
ImageRequest {
raster_punctual_lights: None,
raster_area_lights: None,
raster_exposure_ev: 0.0,
raster_lights: None,
width,
height,
camera: Default::default(),
integrator: Default::default(),
raytrace: None,
environment: None,
textures: None,
studio_weights: None,
background: [0; 3],
transparent_background: false,
}
}
fn finish(mut task: PngTask) -> Vec<u8> {
loop {
if let TaskPoll::Complete(bytes) = task.step(1).unwrap() {
return bytes;
}
}
}
#[test]
fn explicit_png_keeps_default_bytes_and_roundtrips_a_narrow_wide_image() {
let small = request(3, 2);
let rgba = [20, 40, 60, 255].repeat(6);
let legacy = finish(PngTask::new(&small, rgba.clone(), CancelToken::default()).unwrap());
let explicit = finish(PngTask::new_with_limits(&small, rgba, CancelToken::default(), super::super::GPU_PATH_LIMITS).unwrap());
assert_eq!(explicit, legacy);
let wide = request(8193, 1);
let rgba = [20, 40, 60, 255].repeat(8193);
assert!(PngTask::new(&wide, vec![], CancelToken::default()).is_err());
let png = finish(
PngTask::new_with_limits(&wide, rgba.clone(), CancelToken::default(), super::super::CpuBudget::Workstation.image_limits(false)).unwrap(),
);
let decoded = image::load_from_memory(&png).unwrap().to_rgba8();
assert_eq!(decoded.dimensions(), (8193, 1));
assert_eq!(decoded.as_raw(), &rgba);
}
#[test]
fn png_dimension_length_and_cancel_precedence_remains_legacy() {
let cancel = CancelToken::default();
cancel.cancel();
assert_eq!(PngTask::new(&request(0, 1), vec![], cancel.clone()).err().unwrap().code, ErrorCode::InvalidRequest);
assert_eq!(PngTask::new(&request(1, 1), vec![], cancel.clone()).err().unwrap().code, ErrorCode::InvalidRequest);
assert_eq!(PngTask::new(&request(1, 1), vec![0; 4], cancel.clone()).err().unwrap().code, ErrorCode::Cancelled);
let bad = crate::RenderLimits { max_scratch_bytes: u64::MAX, ..super::super::GPU_PATH_LIMITS };
assert!(PngTask::new_with_limits(&request(1, 1), vec![0; 4], CancelToken::default(), bad).is_err());
let cancel = CancelToken::default();
let mut task = PngTask::new_with_limits(&request(1, 1), vec![0; 4], cancel.clone(), super::super::GPU_PATH_LIMITS).unwrap();
cancel.cancel();
assert_eq!(task.step(1).err().unwrap().code, ErrorCode::Cancelled);
}
}
+72
View File
@@ -45,6 +45,11 @@ impl Presentation {
pub fn display(self, width: u32, height: u32) -> Result<Display> {
self.validate()?;
super::linear_exr::encoded_limit(width, height)?;
self.display_with_limits(width, height, super::GPU_PATH_LIMITS)
}
pub(crate) fn display_with_limits(self, width: u32, height: u32, limits: crate::RenderLimits) -> Result<Display> {
self.validate()?;
super::linear_exr::output_pixels_with_limits(width, height, limits)?;
Ok(Display {
width,
height,
@@ -97,6 +102,26 @@ impl Display {
return Err(JobError::new(ErrorCode::InvalidRequest, "bloom/display length mismatch"));
}
super::linear_exr::encoded_limit(self.width, self.height)?;
self.encode_bloom_at_with_limits(input, output, cancel, offset, bloom, super::GPU_PATH_LIMITS)
}
pub(crate) fn encode_bloom_at_with_limits(
self,
input: &[[f32; 4]],
output: &mut [u8],
cancel: &CancelToken,
offset: usize,
bloom: Option<&[[f32; 3]]>,
limits: crate::RenderLimits,
) -> Result<()> {
if bloom.is_some_and(|pixels| pixels.len() != input.len()) {
return Err(JobError::new(ErrorCode::InvalidRequest, "bloom/display length mismatch"));
}
let count = super::linear_exr::output_pixels_with_limits(self.width, self.height, limits)?;
super::linear_exr::checked_output_bytes(count, 16, 0, limits)?;
super::linear_exr::checked_output_bytes(count, 4, 0, limits)?;
if bloom.is_some() {
super::linear_exr::checked_output_bytes(count, 12, 0, limits)?;
}
self.finishing.validate()?;
if let Some(backdrop) = self.backdrop {
backdrop.validate()?;
@@ -143,3 +168,50 @@ impl Display {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn raw_radiance_ev_and_coverage_have_independent_analytic_display_oracles() {
let raw = [[4.0, 1.0, 0.25, 1.0], [2.0, 0.5, 0.125, 0.5]];
let before = raw.map(|pixel| pixel.map(f32::to_bits));
let display = Presentation { exposure_ev: 1.0, background: [0; 3], ..Default::default() }.display(2, 1).unwrap();
let mut output = [0; 8];
display.encode(&raw, &mut output, &CancelToken::default()).unwrap();
let opaque = [8.0 / 9.0, 2.0 / 3.0, 1.0 / 3.0].map(solidcraft_geom::srgb::encode8);
let covered = [4.0 / 9.0, 1.0 / 3.0, 1.0 / 6.0].map(solidcraft_geom::srgb::encode8);
assert_eq!(&output[..4], &[opaque[0], opaque[1], opaque[2], 255]);
assert_eq!(&output[4..], &[covered[0], covered[1], covered[2], 255]);
assert_eq!(raw.map(|pixel| pixel.map(f32::to_bits)), before);
}
#[test]
fn wide_display_requires_explicit_policy_on_both_calls_without_allocating_a_frame() {
let wide = super::super::CpuBudget::Workstation.image_limits(false);
assert!(Presentation::default().display(8193, 1).is_err());
let display = Presentation::default().display_with_limits(8193, 1, wide).unwrap();
let mut output = [0; 4];
let raw = [[1.0, 0.0, 0.0, 1.0]];
assert!(display.encode_at(&raw, &mut output, &CancelToken::default(), 8192).is_err());
display.encode_bloom_at_with_limits(&raw, &mut output, &CancelToken::default(), 8192, None, wide).unwrap();
assert_eq!(output, [solidcraft_geom::srgb::encode8(0.5), 0, 0, 255]);
assert!(display.encode_bloom_at_with_limits(&raw, &mut output, &CancelToken::default(), 8193, None, wide).is_err());
}
#[test]
fn explicit_gpu_display_matches_legacy_and_preserves_validation_before_cancel() {
let display = Presentation { exposure_ev: -1.0, ..Default::default() }.display(3, 2).unwrap();
let input = [[0.125, 2.0, 0.5, 1.0]; 6];
let mut legacy = [0; 24];
let mut explicit = [0; 24];
display.encode(&input, &mut legacy, &CancelToken::default()).unwrap();
display.encode_bloom_at_with_limits(&input, &mut explicit, &CancelToken::default(), 0, None, super::super::GPU_PATH_LIMITS).unwrap();
assert_eq!(explicit, legacy);
let cancel = CancelToken::default();
cancel.cancel();
assert_eq!(display.encode(&[], &mut explicit, &cancel).unwrap_err().code, ErrorCode::InvalidRequest);
assert_eq!(display.encode(&input, &mut explicit, &cancel).unwrap_err().code, ErrorCode::Cancelled);
}
}
+227
View File
@@ -0,0 +1,227 @@
//! Bounded transient Raster input. No design persistence or implicit light conversion.
use std::fmt;
use super::{DirectionalLightOptions, ErrorCode, JobError, Result};
const MAX_LIGHTS: usize = crate::studio::MAX_SCHEMATIC_DIRECTIONAL_LIGHTS;
fn invalid(message: &str) -> JobError {
JobError::new(ErrorCode::InvalidRequest, message)
}
fn validate_light(light: &DirectionalLightOptions) -> Result<()> {
if !light.direction.iter().all(|v| v.is_finite() && v.abs() <= 1e9) || !light.direction.iter().any(|v| *v != 0.0) {
return Err(invalid("Raster light direction must be finite, nonzero and bounded by 1e9"));
}
if !light.color.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v))
|| !light.intensity.is_finite()
|| !(0.0..=64.0).contains(&light.intensity)
{
return Err(invalid("Raster light requires linear RGB in 0..1 and intensity in 0..64"));
}
Ok(())
}
pub fn validate(lights: &Option<Vec<DirectionalLightOptions>>) -> Result<()> {
if let Some(lights) = lights {
if lights.len() > MAX_LIGHTS || lights.capacity() > MAX_LIGHTS {
return Err(invalid("Raster light length and retained capacity must be at most eight"));
}
for light in lights {
validate_light(light)?;
}
}
Ok(())
}
fn reserve() -> Result<Vec<DirectionalLightOptions>> {
let mut lights = Vec::new();
lights.try_reserve_exact(MAX_LIGHTS).map_err(|_| JobError::new(ErrorCode::AllocationFailed, "could not reserve Raster lights"))?;
if lights.capacity() > MAX_LIGHTS {
return Err(invalid("Raster light allocation exceeded its bounded capacity"));
}
Ok(lights)
}
/// Copies only the bounded new payload; never whole-request clone-then-clear.
pub(super) fn try_copy(lights: &Option<Vec<DirectionalLightOptions>>) -> Result<Option<Vec<DirectionalLightOptions>>> {
validate(lights)?;
let Some(source) = lights else { return Ok(None) };
if source.is_empty() {
return Ok(Some(Vec::new()));
}
let mut copy = reserve()?;
// Each element consists solely of fixed arrays and a scalar; no hidden allocation.
for light in source {
copy.push(DirectionalLightOptions { direction: light.direction, color: light.color, intensity: light.intensity });
}
Ok(Some(copy))
}
pub(super) fn retained_bytes(lights: &Option<Vec<DirectionalLightOptions>>) -> Result<u64> {
validate(lights)?;
let capacity = lights.as_ref().map_or(0, Vec::capacity);
capacity
.checked_mul(std::mem::size_of::<DirectionalLightOptions>())
.and_then(|bytes| u64::try_from(bytes).ok())
.ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "Raster light retained size overflow"))
}
/// Covers host clones and public ImageTask callers retaining an original Vec,
/// including an empty Vec with nonzero capacity. None keeps the legacy estimate.
pub(super) fn task_bytes(lights: &Option<Vec<DirectionalLightOptions>>) -> Result<u64> {
validate(lights)?;
if lights.is_none() {
return Ok(0);
}
MAX_LIGHTS
.checked_mul(std::mem::size_of::<DirectionalLightOptions>())
.and_then(|bytes| u64::try_from(bytes).ok())
.ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "Raster task light size overflow"))
}
/// Missing fields use serde(default); explicit null is the unchanged legacy path.
pub fn deserialize<'de, D: serde::Deserializer<'de>>(deserializer: D) -> std::result::Result<Option<Vec<DirectionalLightOptions>>, D::Error> {
struct Nullable;
impl<'de> serde::de::Visitor<'de> for Nullable {
type Value = Option<Vec<DirectionalLightOptions>>;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("null or at most eight Raster directional lights")
}
fn visit_none<E: serde::de::Error>(self) -> std::result::Result<Self::Value, E> {
Ok(None)
}
fn visit_unit<E: serde::de::Error>(self) -> std::result::Result<Self::Value, E> {
Ok(None)
}
fn visit_some<D: serde::Deserializer<'de>>(self, d: D) -> std::result::Result<Self::Value, D::Error> {
struct Sequence;
impl<'de> serde::de::Visitor<'de> for Sequence {
type Value = Vec<DirectionalLightOptions>;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("at most eight Raster directional lights")
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut sequence: A) -> std::result::Result<Self::Value, A::Error> {
let mut lights = Vec::new();
while let Some(light) = sequence.next_element::<DirectionalLightOptions>()? {
if lights.len() == MAX_LIGHTS {
return Err(serde::de::Error::custom("at most eight Raster directional lights"));
}
validate_light(&light).map_err(serde::de::Error::custom)?;
if lights.is_empty() {
lights = reserve().map_err(serde::de::Error::custom)?;
}
lights.push(light);
}
Ok(lights)
}
}
d.deserialize_seq(Sequence).map(Some)
}
}
deserializer.deserialize_option(Nullable)
}
/// New local families share the real DTO validators with CPU transport.
pub fn validate_punctual(lights: &Option<Vec<super::PunctualLightOptions>>) -> Result<()> {
if let Some(lights) = lights {
if lights.len() > 8 || lights.capacity() > 8 {
return Err(invalid("Raster point/spot length and capacity must be at most eight"));
}
for light in lights {
light.validate()?;
}
}
Ok(())
}
pub fn validate_areas(lights: &Option<Vec<super::RectangleLightOptions>>) -> Result<()> {
if let Some(lights) = lights {
if lights.len() > 4 || lights.capacity() > 4 {
return Err(invalid("Raster rectangular length and capacity must be at most four"));
}
for light in lights {
light.validate()?;
}
}
Ok(())
}
pub fn deserialize_punctual<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> std::result::Result<Option<Vec<super::PunctualLightOptions>>, D::Error> {
let lights = super::punctual::bounded_punctual(deserializer)?;
validate_punctual(&lights).map_err(serde::de::Error::custom)?;
Ok(lights)
}
pub fn deserialize_areas<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> std::result::Result<Option<Vec<super::RectangleLightOptions>>, D::Error> {
let lights = super::bounded_area_lights(deserializer)?;
validate_areas(&lights).map_err(serde::de::Error::custom)?;
Ok(lights)
}
pub(super) fn try_copy_punctual(lights: &Option<Vec<super::PunctualLightOptions>>) -> Result<Option<Vec<super::PunctualLightOptions>>> {
validate_punctual(lights)?;
let Some(source) = lights else { return Ok(None) };
if source.is_empty() {
return Ok(Some(Vec::new()));
}
let mut copy = Vec::new();
copy.try_reserve_exact(8).map_err(|_| JobError::new(ErrorCode::AllocationFailed, "could not reserve Raster point/spot lights"))?;
if copy.capacity() > 8 {
return Err(invalid("Raster point/spot allocation exceeded its bounded capacity"));
}
// Fixed-size DTO: the admitted reserve covers every push, without hidden allocation.
copy.extend(source.iter().copied());
Ok(Some(copy))
}
pub(super) fn try_copy_areas(lights: &Option<Vec<super::RectangleLightOptions>>) -> Result<Option<Vec<super::RectangleLightOptions>>> {
validate_areas(lights)?;
let Some(source) = lights else { return Ok(None) };
if source.is_empty() {
return Ok(Some(Vec::new()));
}
let mut copy = Vec::new();
copy.try_reserve_exact(4).map_err(|_| JobError::new(ErrorCode::AllocationFailed, "could not reserve Raster rectangular lights"))?;
if copy.capacity() > 4 {
return Err(invalid("Raster rectangular allocation exceeded its bounded capacity"));
}
// RectangleLightOptions contains only fixed arrays and a scalar.
copy.extend(source.iter().cloned());
Ok(Some(copy))
}
pub(super) fn local_retained_bytes(
punctual: &Option<Vec<super::PunctualLightOptions>>,
areas: &Option<Vec<super::RectangleLightOptions>>,
) -> Result<u64> {
validate_punctual(punctual)?;
validate_areas(areas)?;
let p = punctual.as_ref().map_or(0, Vec::capacity);
let a = areas.as_ref().map_or(0, Vec::capacity);
p.checked_mul(std::mem::size_of::<super::PunctualLightOptions>())
.and_then(|n| a.checked_mul(std::mem::size_of::<super::RectangleLightOptions>()).and_then(|m| n.checked_add(m)))
.and_then(|n| u64::try_from(n).ok())
.ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "Raster local retained size overflow"))
}
pub(super) fn local_task_bytes(
punctual: &Option<Vec<super::PunctualLightOptions>>,
areas: &Option<Vec<super::RectangleLightOptions>>,
) -> Result<u64> {
validate_punctual(punctual)?;
validate_areas(areas)?;
// Covers the original public-task Vec capacity even for Some(empty), and
// the bounded fallible host-copy capacity. No allocation occurs here on valid input.
let p = if punctual.is_some() { 8usize } else { 0 };
let a = if areas.is_some() { 4usize } else { 0 };
p.checked_mul(std::mem::size_of::<super::PunctualLightOptions>())
.and_then(|n| a.checked_mul(std::mem::size_of::<super::RectangleLightOptions>()).and_then(|m| n.checked_add(m)))
.and_then(|n| u64::try_from(n).ok())
.ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "Raster local task size overflow"))
}
+66 -11
View File
@@ -7,19 +7,29 @@ use std::sync::Arc;
pub(super) fn required_bytes(source: &RenderFrame, settings: Presentation) -> Result<u64> {
settings.validate()?;
super::linear_exr::encoded_limit(source.info.width, source.info.height)?;
required_bytes_with_limits(source, settings, super::GPU_PATH_LIMITS)
}
pub(crate) fn required_bytes_with_limits(source: &RenderFrame, settings: Presentation, limits: crate::RenderLimits) -> Result<u64> {
settings.validate()?;
super::linear_exr::encoded_limit_with_limits(source.info.width, source.info.height, limits)?;
if source.linear_exr().is_none() {
return Err(JobError::new(ErrorCode::InvalidRequest, "this completed frame has no retained raw linear master"));
}
// Includes decoded float4, bloom ping/pong float3 or display bytes, bounded
// PNG output and codec/header/task metadata. The source keeps its own lease.
Ok(u64::from(source.info.width) * u64::from(source.info.height) * 48
+ 1_048_576
+ settings.finishing.photographic.scratch_bytes(source.info.width, source.info.height)?)
output_bytes(source.info.width, source.info.height, settings, limits)
}
fn output_bytes(width: u32, height: u32, settings: Presentation, limits: crate::RenderLimits) -> Result<u64> {
let pixels = super::linear_exr::output_pixels_with_limits(width, height, limits)?;
let photo = settings.finishing.photographic.scratch_bytes_with_limits(width, height, limits)?;
let extra = photo.checked_add(1_048_576).ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "regrade byte estimate overflow"))?;
super::linear_exr::checked_stage_bytes(pixels, 48, extra, limits)
}
pub(super) struct RegradeTask {
source: Arc<RenderFrame>,
settings: Presentation,
limits: crate::RenderLimits,
cancel: CancelToken,
decoder: Option<Decoder>,
bloom: Option<BloomTask>,
@@ -33,11 +43,25 @@ pub(super) struct RegradeTask {
impl RegradeTask {
pub(super) fn new(source: Arc<RenderFrame>, settings: Presentation, cancel: CancelToken) -> Result<Self> {
required_bytes(&source, settings)?;
Self::new_with_limits(source, settings, cancel, super::GPU_PATH_LIMITS)
}
pub(crate) fn new_with_limits(
source: Arc<RenderFrame>,
settings: Presentation,
cancel: CancelToken,
limits: crate::RenderLimits,
) -> Result<Self> {
required_bytes_with_limits(&source, settings, limits)?;
let raw = source.linear_exr().ok_or_else(|| JobError::new(ErrorCode::InvalidRequest, "linear master disappeared"))?;
let decoder = Decoder::new(raw, source.info.width, source.info.height, &cancel)?;
let decoder = if limits == super::GPU_PATH_LIMITS {
Decoder::new(raw, source.info.width, source.info.height, &cancel)?
} else {
Decoder::new_with_limits(raw, source.info.width, source.info.height, &cancel, limits)?
};
Ok(Self {
source,
settings,
limits,
cancel,
decoder: Some(decoder),
bloom: None,
@@ -70,6 +94,10 @@ impl ImageWork for RegradeTask {
return self.pending(done, total);
}
let request = ImageRequest {
raster_punctual_lights: None,
raster_area_lights: None,
raster_exposure_ev: 0.0,
raster_lights: None,
studio_weights: None,
width,
height,
@@ -81,7 +109,7 @@ impl ImageWork for RegradeTask {
background: self.settings.background,
transparent_background: self.settings.transparent_background,
};
self.png = Some(PngTask::new(&request, task.finish()?, self.cancel.clone())?);
self.png = Some(PngTask::new_with_limits(&request, task.finish()?, self.cancel.clone(), self.limits)?);
return self.pending(0, u64::from(height));
}
if let Some(mut decoder) = self.decoder.take() {
@@ -93,7 +121,7 @@ impl ImageWork for RegradeTask {
}
let pixels = decoder.finish()?;
if let Some(bloom) = self.settings.finishing.bloom.filter(|b| b.active()) {
self.bloom = Some(BloomTask::new(width, height, pixels, bloom, self.cancel.clone())?);
self.bloom = Some(BloomTask::new_with_limits(width, height, pixels, bloom, self.cancel.clone(), self.limits)?);
return self.pending(0, u64::from(height) * 7);
}
self.linear = Some(pixels);
@@ -124,7 +152,14 @@ impl ImageWork for RegradeTask {
.ok_or_else(|| JobError::new(ErrorCode::Internal, "missing regrade pixels"))?;
let output = self.rgba.get_mut(self.encoded * 4..end * 4).ok_or_else(|| JobError::new(ErrorCode::Internal, "missing regrade output"))?;
let glow = self.glow.as_deref().and_then(|g| g.get(self.encoded..end));
self.settings.display(width, height)?.encode_bloom_at(pixels, output, &self.cancel, self.encoded, glow)?;
self.settings.display_with_limits(width, height, self.limits)?.encode_bloom_at_with_limits(
pixels,
output,
&self.cancel,
self.encoded,
glow,
self.limits,
)?;
self.encoded = end;
if end < count {
return self.pending(end as u64, count as u64);
@@ -132,18 +167,23 @@ impl ImageWork for RegradeTask {
self.glow = None;
if self.settings.finishing.photographic.active() {
let pixels = self.linear.take().ok_or_else(|| JobError::new(ErrorCode::Internal, "missing photographic master pixels"))?;
self.photographic = Some(super::photographic::PhotographicTask::new(
self.photographic = Some(super::photographic::PhotographicTask::new_with_limits(
width,
height,
std::mem::take(&mut self.rgba),
pixels,
self.settings.finishing.photographic,
self.cancel.clone(),
self.limits,
)?);
return self.pending(0, u64::from(width) * u64::from(height));
}
self.linear = None;
let request = ImageRequest {
raster_punctual_lights: None,
raster_area_lights: None,
raster_exposure_ev: 0.0,
raster_lights: None,
studio_weights: None,
width,
height,
@@ -155,7 +195,22 @@ impl ImageWork for RegradeTask {
background: self.settings.background,
transparent_background: self.settings.transparent_background,
};
self.png = Some(PngTask::new(&request, std::mem::take(&mut self.rgba), self.cancel.clone())?);
self.png = Some(PngTask::new_with_limits(&request, std::mem::take(&mut self.rgba), self.cancel.clone(), self.limits)?);
self.pending(0, u64::from(height))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn regrade_size_estimate_is_explicit_checked_and_does_not_grant_a_lease() {
let wide = super::super::CpuBudget::Workstation.image_limits(false);
assert_eq!(output_bytes(8193, 1, Presentation::default(), wide).unwrap(), 8193 * 48 + 1_048_576);
assert!(output_bytes(8193, 1, Presentation::default(), super::super::GPU_PATH_LIMITS).is_err());
assert!(output_bytes(u32::MAX, u32::MAX, Presentation::default(), wide).is_err());
assert!(output_bytes(1, 1, Presentation::default(), crate::RenderLimits { max_scratch_bytes: u64::MAX, ..wide }).is_err());
assert!(output_bytes(1, 1, Presentation::default(), crate::RenderLimits { max_scratch_bytes: 1_048_576, ..wide }).is_err());
}
}
+95 -2
View File
@@ -6,6 +6,10 @@ use solidcraft_geom::Aabb3;
fn request(integrator: Integrator) -> ImageRequest {
ImageRequest {
raster_punctual_lights: None,
raster_area_lights: None,
raster_exposure_ev: 0.0,
raster_lights: None,
studio_weights: None,
environment: None,
textures: None,
@@ -52,11 +56,14 @@ fn options_preserve_the_eight_light_boundary_and_refuse_the_ninth() {
#[test]
fn exposure_refuses_nonfinite_and_out_of_range_values() {
for exposure_ev in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -16.01, 16.01] {
for exposure_ev in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -33.0, 33.0, -32.01, 32.01] {
let options = RaytraceOptions { exposure_ev, ..Default::default() };
assert_eq!(options.validate().unwrap_err().code, ErrorCode::InvalidRequest);
}
for exposure_ev in [-16.0, 16.0] {
// CPU exposure now shares the saved/UI range; the old +/-16 refusal
// is superseded, not a reason to change any transport oracle.
assert_eq!(RaytraceOptions::default().exposure_ev, 0.0);
for exposure_ev in [-32.0, -17.0, -16.0, 0.0, 16.0, 17.0, 32.0] {
assert!(RaytraceOptions { exposure_ev, ..Default::default() }.validate().is_ok());
}
}
@@ -173,3 +180,89 @@ fn emission_runtime_json_is_bounded_explicit_and_roundtrips() {
assert_eq!(serde_json::from_str::<RaytraceOptions>("{}").unwrap().emissions, None);
assert_eq!(serde_json::from_str::<RaytraceOptions>("{\"emissions\":[]}").unwrap().emissions, Some(Vec::new()));
}
fn directional_options() -> RaytraceOptions {
RaytraceOptions {
directional_shadow_transmission: true,
dielectrics: vec![DielectricOptions {
body_index: 0,
body_name: "directional glass".into(),
boundary: DielectricBoundary::ThinSheet,
ior: 1.0,
absorption_per_mm: [0.0; 3],
thickness_mm: 1.0,
}],
..Default::default()
}
}
#[test]
fn directional_mode_is_explicit_false_omitted_and_true_roundtrips() {
let old_default = serde_json::json!({"reflections":null,"anisotropic_faces":[null,null],"preset":null,"exposure_ev":0.0,"shadows":true});
let decoded: RaytraceOptions = serde_json::from_value(old_default.clone()).unwrap();
assert!(!decoded.directional_shadow_transmission);
assert_eq!(serde_json::to_value(&decoded).unwrap(), old_default);
let mut explicit = old_default.clone();
explicit["directional_shadow_transmission"] = serde_json::json!(false);
assert_eq!(serde_json::to_value(serde_json::from_value::<RaytraceOptions>(explicit).unwrap()).unwrap(), old_default);
let enabled = directional_options();
enabled.validate().unwrap();
let wire = serde_json::to_value(&enabled).unwrap();
assert_eq!(wire["directional_shadow_transmission"], serde_json::json!(true));
assert_eq!(serde_json::from_value::<RaytraceOptions>(wire).unwrap(), enabled);
for bad in [serde_json::json!(null), serde_json::json!(1), serde_json::json!("true")] {
let mut wire = old_default.clone();
wire["directional_shadow_transmission"] = bad;
assert!(serde_json::from_value::<RaytraceOptions>(wire).is_err());
}
}
#[test]
fn directional_mode_refuses_incompatible_inputs_without_rewriting_legacy() {
for case in 0..7 {
let mut options = directional_options();
match case {
0 => options.shadows = false,
1 => options.dielectrics = Vec::new(),
2 => options.reflections = Some(ReflectionOptions { bodies: Vec::new(), max_bounces: 1, samples: 1 }),
3 => {
options.anisotropic_faces[0] = Some(AnisotropicFaceOptions {
body_index: 0,
body_name: "directional glass".into(),
face: 0,
tangent: [1.0, 0.0, 0.0],
orientation: AnisotropicOrientation::QuarterTurn,
roughness_x: 0.2,
roughness_y: 0.5,
})
}
4 => options.environment = Some(RaytraceEnvironmentOptions { color: [1.0; 3], intensity: 1.0 }),
5 => {
options.punctual_lights = Some(vec![PunctualLightOptions {
position_mm: [0.0, 0.0, 10.0],
color: [1.0; 3],
intensity: 1.0,
reference_distance_mm: 10.0,
minimum_distance_mm: 0.001,
cone: None,
}])
}
_ => {
options.area_lights = Some(vec![RectangleLightOptions {
center_mm: [0.0, 0.0, 10.0],
edge_u_mm: [1.0, 0.0, 0.0],
edge_v_mm: [0.0, -1.0, 0.0],
color: [1.0; 3],
intensity: 1.0,
}])
}
}
assert_eq!(options.validate().unwrap_err().code, ErrorCode::InvalidRequest);
options.directional_shadow_transmission = false;
options.validate().unwrap();
}
let mut empty_locals = directional_options();
empty_locals.punctual_lights = Some(Vec::new());
empty_locals.area_lights = Some(Vec::new());
empty_locals.validate().unwrap();
}
@@ -146,6 +146,10 @@ fn direct_response_respects_geometry_visibility_and_quarter_turn() {
fn decoded_cpu_png_rotates_only_the_two_explicit_planar_targets() {
fn render(scene: ProductScene, camera: Camera, options: RaytraceOptions) -> ::image::RgbaImage {
let request = ImageRequest {
raster_punctual_lights: None,
raster_area_lights: None,
raster_exposure_ev: 0.0,
raster_lights: None,
studio_weights: None,
environment: None,
textures: None,
@@ -64,6 +64,10 @@ fn request(orientation: AnisotropicOrientation, reflect: bool) -> ImageRequest {
..Default::default()
};
ImageRequest {
raster_punctual_lights: None,
raster_area_lights: None,
raster_exposure_ev: 0.0,
raster_lights: None,
studio_weights: None,
environment: None,
textures: None,
@@ -64,6 +64,10 @@ fn task(samples: u8, selected: bool, face_override: bool, shadows: bool) -> Imag
..Default::default()
};
let request = ImageRequest {
raster_punctual_lights: None,
raster_area_lights: None,
raster_exposure_ev: 0.0,
raster_lights: None,
studio_weights: None,
environment: None,
textures: None,
@@ -204,9 +208,9 @@ fn reused_shading_preserves_original_per_add_radiance_bound() {
fn request_admission_and_task_slot_caps_are_not_expanded() {
let fast = task(64, false, false, false);
let legacy = task(64, false, false, false);
assert_eq!(fast.options, legacy.options);
assert_eq!(*fast.visibility_owner.options(), *legacy.visibility_owner.options());
assert_eq!(fast.admission.estimated_total_bytes, legacy.admission.estimated_total_bytes);
assert_eq!(fast.options.reflections.as_ref().unwrap().samples, 64);
assert_eq!(fast.visibility_owner.options().reflections.as_ref().unwrap().samples, 64);
assert!(std::mem::size_of::<ImageTask>() <= 4096);
assert_eq!(crate::raytrace::MAX_WORK, 1_000_000_000);
assert_eq!(job::image::MAX_JOB_BYTES, 384 * 1024 * 1024);
@@ -229,6 +233,10 @@ fn spatial_reuse_fixture(s: u8, r: u8, selected: bool, face_override: bool, shad
..Default::default()
};
let request = ImageRequest {
raster_punctual_lights: None,
raster_area_lights: None,
raster_exposure_ev: 0.0,
raster_lights: None,
studio_weights: None,
environment: None,
textures: None,
@@ -350,7 +358,7 @@ fn completed_spatial_reuse_checks_cancel_and_work_limit_before_any_mutation() {
fn spatial_lights_fixture(config: (u8, u8, u32, u32, bool), mask: u8, width: u32, partial: bool) -> ImageTask {
let (s, r, n, seed, shadows) = config;
let previous = spatial_reuse_fixture(s, r, false, false, shadows);
let mut options = previous.options;
let (fixture_scene, mut options) = previous.visibility_owner.into_capture();
options.exposure_ev = 1.0;
options.lights = Some(if mask & 1 != 0 {
vec![job::DirectionalLightOptions { direction: [0.0, 0.0, 1.0], color: [0.25, 0.5, 1.0], intensity: 2.0 }]
@@ -403,7 +411,7 @@ fn spatial_lights_fixture(config: (u8, u8, u32, u32, bool), mask: u8, width: u32
if options.area_lights.is_some() {
options.seed = Some(seed);
}
let mut scene = previous.scene;
let mut scene = fixture_scene;
if partial {
let mesh = Arc::get_mut(&mut scene.meshes[0].mesh).unwrap();
mesh.positions[0].x = 0.0;
@@ -510,12 +518,16 @@ fn completed_marker_refuses_malformed_proof_sample_bounce_hit_and_shading_state(
6 => task.pending.as_mut().unwrap().area_ready = true,
7 => task.pending.as_mut().unwrap().environment_sample = 0,
8 => task.pending.as_mut().unwrap().area_light = 1,
9 => task.options.reflections.as_mut().unwrap().bodies[0].body = "Visible".into(),
9 => {
let (fixture_scene, mut fixture_options) = task.visibility_owner.into_capture();
fixture_options.reflections.as_mut().unwrap().bodies[0].body = "Visible".into();
task.visibility_owner = dielectric::DirectVisibilityOwner::new(fixture_scene, fixture_options);
}
10 => task.sample = task.sample_count() + 1,
11 => task.pending.as_mut().unwrap().light = task.lights.lights().len() + 2,
12 => task.finish_spatial = true,
13 => task.emit_spatial_pixel = true,
14 => task.spatial = task.options.primary_samples,
14 => task.spatial = task.visibility_owner.options().primary_samples,
15 => task.pending.as_mut().unwrap().emission_cursor = 1,
16 => task.pending.as_mut().unwrap().surface_cursor = 1,
17 => task.pending.as_mut().unwrap().surface_ready = false,
@@ -580,7 +592,7 @@ fn spatial_reuse_quantum_partitions_preserve_complete_png_and_public_work_bound(
fn spatial_surface_fixture(s: u8, r: u8, selected: bool) -> ImageTask {
let previous = spatial_lights_fixture((s, r, 4, 7, true), 63, 2, true);
let mut options = previous.options;
let (fixture_scene, mut options) = previous.visibility_owner.into_capture();
options.surface_effects = Some(vec![crate::surface_patterns::SurfaceEffect {
body: "Visible".into(),
frame: Default::default(),
@@ -592,7 +604,7 @@ fn spatial_surface_fixture(s: u8, r: u8, selected: bool) -> ImageTask {
options.reflections.as_mut().unwrap().bodies[0].body = "Visible".into();
}
let request = ImageRequest { raytrace: Some(options.clone()), ..previous.request };
ImageTask::new_with_environment(previous.scene, request, previous.cancel, options, Some(environment())).unwrap()
ImageTask::new_with_environment(fixture_scene, request, previous.cancel, options, Some(environment())).unwrap()
}
#[test]
+476
View File
@@ -241,6 +241,482 @@ pub fn overlaps(a: Bounds, b: Bounds) -> bool {
lo.x <= hi.x && lo.y <= hi.y && lo.z <= hi.z
}
/// Immediate parents of already-certified axis-aligned boxes. Strict containment
/// is sufficient for this exact box subset; touching and partial overlap refuse.
/// Eight overrides bound this to at most 64 comparisons, without allocation.
pub(super) fn parents(certificates: &[Option<Certificate>; MAX_OVERRIDES], nested: bool) -> Result<[Option<usize>; MAX_OVERRIDES]> {
let mut result = [None; MAX_OVERRIDES];
for (child, certificate) in certificates.iter().enumerate() {
let Some(Certificate::ClosedBox(bounds)) = certificate else { continue };
for (candidate, certificate) in certificates.iter().enumerate() {
let Some(Certificate::ClosedBox(container)) = certificate else { continue };
if candidate == child || !overlaps(*bounds, *container) {
continue;
}
if !nested {
return Err(TraceError::InvalidInput("nested, touching or overlapping dielectric volumes are unsupported"));
}
if strictly_contains(*container, *bounds) {
let parent = result.get_mut(child).ok_or(TraceError::Internal("dielectric child index"))?;
let replace = match *parent {
None => true,
Some(previous) => match certificates.get(previous).copied().flatten() {
Some(Certificate::ClosedBox(old)) => strictly_contains(old, *container),
_ => return Err(TraceError::Internal("dielectric parent certificate")),
},
};
if replace {
*parent = Some(candidate);
}
} else if !strictly_contains(*bounds, *container) {
return Err(TraceError::InvalidInput("dielectric boxes must be disjoint or strictly nested; touching and overlap are unsupported"));
}
}
}
Ok(result)
}
fn strictly_contains(outer: Bounds, inner: Bounds) -> bool {
let lo = outer.min();
let hi = outer.max();
let a = inner.min();
let b = inner.max();
lo.x < a.x && lo.y < a.y && lo.z < a.z && hi.x > b.x && hi.y > b.y && hi.z > b.z
}
/// A certified axis-aligned face point, shared by propagation and Beer intervals.
/// This is used only by the explicit nested-box path, never by general spawn.
#[derive(Clone, Copy)]
pub(super) struct ExactBoxFace {
pub point: Vec3,
axis: usize,
outward: f64,
entering: bool,
}
impl ExactBoxFace {
pub fn spawn(self, direction: Vec3, reflected: bool) -> Result<super::Ray> {
let ray = super::Ray::new(self.point, direction, 0.0_f64.next_up(), super::MAX_DISTANCE_MM)?;
let side = super::component(ray.direction(), self.axis) * self.outward;
let exterior = if reflected { self.entering } else { !self.entering };
if (exterior && side <= 0.0) || (!exterior && side >= 0.0) {
return Err(TraceError::InvalidInput("nested box continuation is tangent or on the wrong side"));
}
Ok(ray)
}
}
/// Recover the exact constant-axis coordinate from the actual certified face.
/// Tangential coordinates are validated, never clamped or displaced. Equal
/// ray/other-face depths conservatively refuse independently of ordinal ties.
pub(super) fn exact_box_face(bounds: Bounds, vertices: [Vec3; 3], normal: Vec3, position: Vec3, incoming: super::Ray) -> Result<ExactBoxFace> {
let mut face = None;
for axis in 0..3 {
let [a, b, c] = vertices.map(|v| super::component(v, axis));
if a == b && b == c {
if face.is_some() {
return Err(TraceError::InvalidInput("nested box face has ambiguous constant axes"));
}
face = Some((axis, a));
}
}
let (axis, plane) = face.ok_or(TraceError::InvalidInput("nested box face is not axis aligned"))?;
let lo = bounds.min();
let hi = bounds.max();
let outward = if plane == super::component(lo, axis) {
-1.0
} else if plane == super::component(hi, axis) {
1.0
} else {
return Err(TraceError::InvalidInput("nested box face disagrees with its certificate"));
};
let axis_vector = match axis {
0 => Vec3::X,
1 => Vec3::Y,
2 => Vec3::Z,
_ => return Err(TraceError::Internal("nested box face axis")),
};
if normal != axis_vector * outward || Triangle::new(vertices)?.geometric_normal() != Some(normal) {
return Err(TraceError::InvalidInput("nested box outward normal disagrees with its certified face"));
}
for vertex in vertices {
for a in 0..3 {
let coordinate = super::component(vertex, a);
if coordinate != super::component(lo, a) && coordinate != super::component(hi, a) {
return Err(TraceError::InvalidInput("nested box face vertex is not a certified corner"));
}
}
}
let point = match axis {
0 => Vec3::new(plane, position.y, position.z),
1 => Vec3::new(position.x, plane, position.z),
2 => Vec3::new(position.x, position.y, plane),
_ => return Err(TraceError::Internal("nested box point axis")),
};
if !point.is_finite() {
return Err(TraceError::InvalidInput("nested box reconstructed point is not finite"));
}
let direction = super::component(incoming.direction(), axis);
if direction == 0.0 {
return Err(TraceError::InvalidInput("nested box incident ray is tangent"));
}
let depth = (plane - super::component(incoming.origin(), axis)) / direction;
let [min, max] = incoming.interval();
if !depth.is_finite() || depth < min || depth > max {
return Err(TraceError::InvalidInput("nested box face is outside the incident ray interval"));
}
for a in 0..3 {
if a == axis {
continue;
}
let coordinate = super::component(point, a);
if coordinate <= super::component(lo, a) || coordinate >= super::component(hi, a) {
return Err(TraceError::InvalidInput("nested box edge, corner or outside-face point is ambiguous"));
}
let d = super::component(incoming.direction(), a);
if d != 0.0 {
for plane in [super::component(lo, a), super::component(hi, a)] {
let other = (plane - super::component(incoming.origin(), a)) / d;
if other == depth {
return Err(TraceError::InvalidInput("nested box simultaneous face depths are ambiguous"));
}
}
}
}
Ok(ExactBoxFace { point, axis, outward, entering: direction * outward < 0.0 })
}
/// Hard crossing ceiling for eight disjoint certified boxes (two faces each).
pub const MAX_DIRECT_CROSSINGS: u8 = (MAX_OVERRIDES * 2) as u8;
#[derive(Clone, Copy)]
struct DirectHit {
body: usize,
vertices: [Vec3; 3],
geometry: super::TriangleHit,
}
/// Incremental RGB visibility along ONE straight exterior-to-exterior connection.
/// This is a direct-light approximation, not refracted transport or caustics.
/// Selected optics are revalidated and certified against the borrowed capture.
/// Boxes must be disjoint; sheets touching/inside boxes and endpoints on/inside
/// boxes refuse. Unselected geometry blocks. The interval is [min,max): geometry
/// exactly at a finite light endpoint does not block that light.
///
/// No BVH tie API exists for this consumer yet. A pass scans the captured source,
/// one triangle (or body transition) per step. Equal-depth different bodies refuse
/// rather than silently skipping an opaque blocker behind a selected boundary.
/// Cost is O((crossings+1)*(triangles+bodies)), not accelerated traversal. Every
/// step debits the caller's cumulative work and checks cancellation. Memory is
/// fixed, with borrowed scene/options. Valid setup/steps create no containers;
/// invalid options may construct the existing validator's bounded error String.
/// The future prepared-scene consumer passes its existing `PreparedScene.source`.
pub struct DirectVisibility<'a> {
source: &'a ProductScene,
options: &'a crate::job::RaytraceOptions,
state: DirectVisibilityState,
}
// Entirely private, reference-free and deliberately neither Clone nor Copy.
struct DirectVisibilityState {
certificates: [Option<Certificate>; MAX_OVERRIDES],
ray: super::Ray,
body: usize,
triangle: usize,
scanned: usize,
nearest: Option<DirectHit>,
ambiguous: bool,
optical: bool,
medium: Option<(usize, Vec3)>,
sheets: u8,
transmittance: Vec3,
crossings: u8,
crossing_limit: u8,
terminal: bool,
}
impl<'a> DirectVisibility<'a> {
/// Bounded setup: at most eight small certificates and 64 overlap checks.
/// Setup also spends the SAME caller-owned budget; no fresh work credit.
pub fn new(
source: &'a ProductScene,
options: &'a crate::job::RaytraceOptions,
ray: super::Ray,
crossing_limit: u8,
work: &mut super::WorkBudget,
cancel: &crate::job::CancelToken,
) -> Result<Self> {
work.tick(cancel)?;
if !(1..=MAX_DIRECT_CROSSINGS).contains(&crossing_limit) || source.meshes.len() > crate::job::MAX_CPU_BODIES {
return Err(TraceError::InvalidInput("direct visibility crossing or body bound"));
}
options.validate().map_err(|_| TraceError::InvalidInput("invalid direct visibility options"))?;
let mut certificates = [None; MAX_OVERRIDES];
let start = ray.at(ray.interval()[0])?;
let end = ray.at(ray.interval()[1])?;
for (slot, input) in certificates.iter_mut().zip(&options.dielectrics) {
work.tick(cancel)?;
let certificate = certify(source, input)?;
if let Certificate::ClosedBox(bounds) = certificate {
if contains(bounds, ray.origin()) || contains(bounds, start) || contains(bounds, end) {
return Err(TraceError::InvalidInput("direct visibility endpoints must be outside certified boxes"));
}
}
*slot = Some(certificate);
}
for (i, a) in certificates.iter().take(options.dielectrics.len()).enumerate() {
for (j, b) in certificates.iter().take(options.dielectrics.len()).enumerate().skip(i + 1) {
work.tick(cancel)?;
let (Some(a), Some(b)) = (a, b) else { continue };
let bounds = |slot: usize, certificate: Certificate| -> Result<Bounds> {
match certificate {
Certificate::ClosedBox(bounds) => Ok(bounds),
Certificate::ThinSheet => {
let input = options.dielectrics.get(slot).ok_or(TraceError::Internal("direct certificate slot"))?;
let body = source.meshes.get(input.body_index).ok_or(TraceError::Internal("direct certificate body"))?;
let bounds = body.mesh.bounds();
Bounds::new(bounds.min, bounds.max)
}
}
};
if (matches!(a, Certificate::ClosedBox(_)) || matches!(b, Certificate::ClosedBox(_))) && overlaps(bounds(i, *a)?, bounds(j, *b)?) {
return Err(TraceError::InvalidInput("direct visibility boxes must be disjoint from other selected boundaries"));
}
}
}
super::check_cancel(cancel)?;
Ok(Self {
source,
options,
state: DirectVisibilityState {
certificates,
ray,
body: 0,
triangle: 0,
scanned: 0,
nearest: None,
ambiguous: false,
optical: false,
medium: None,
sheets: 0,
transmittance: Vec3::new(1.0, 1.0, 1.0),
crossings: 0,
crossing_limit,
terminal: false,
},
})
}
/// None means real work remains. Only Some(rgb) is a completed connection.
/// Work/crossing exhaustion, cancellation and ambiguity are errors, never black
/// or white fallback values. Success OR error makes this cursor terminal.
pub fn step(&mut self, work: &mut super::WorkBudget, cancel: &crate::job::CancelToken) -> Result<Option<Vec3>> {
if self.state.terminal {
return Err(TraceError::InvalidInput("direct visibility cursor is terminal"));
}
let result = self.advance(work, cancel);
if !matches!(result, Ok(None)) {
self.state.terminal = true;
}
result
}
fn advance(&mut self, work: &mut super::WorkBudget, cancel: &crate::job::CancelToken) -> Result<Option<Vec3>> {
work.tick(cancel)?;
if !self.state.optical {
if let Some(body) = self.source.meshes.get(self.state.body) {
// A certified sheet is ONE plane. Its two triangles can round
// their depths differently at a shared edge; it must never be
// attenuated twice. Skip that entire immutable certified body
// after its first crossing, not a numerical distance interval.
if self
.options
.dielectrics
.iter()
.enumerate()
.any(|(slot, input)| input.body_index == self.state.body && self.state.sheets & (1u8 << slot) != 0)
{
self.state.body += 1;
self.state.triangle = 0;
return Ok(None);
}
if let Some(indices) = body.mesh.triangles.get(self.state.triangle) {
self.state.scanned =
self.state.scanned.checked_add(1).filter(|n| *n <= crate::job::MAX_CPU_TRIANGLES).ok_or(TraceError::WorkLimit)?;
let mut vertices = [Vec3::ZERO; 3];
for (point, index) in vertices.iter_mut().zip(indices) {
*point = *body.mesh.positions.get(*index as usize).ok_or(TraceError::InvalidInput("direct visibility vertex index"))?;
}
self.state.triangle += 1;
if let Some(geometry) = Triangle::new(vertices)?.intersect(&self.state.ray) {
if geometry.distance < self.state.ray.interval()[1] {
let hit = DirectHit { body: self.state.body, vertices, geometry };
match self.state.nearest {
None => self.state.nearest = Some(hit),
Some(old) if geometry.distance < old.geometry.distance => {
self.state.nearest = Some(hit);
self.state.ambiguous = false;
}
Some(old) if geometry.distance == old.geometry.distance && old.body != self.state.body => self.state.ambiguous = true,
_ => {}
}
}
}
} else {
self.state.body += 1;
self.state.triangle = 0;
}
return Ok(None);
}
self.state.optical = true;
return Ok(None);
}
if self.state.ambiguous {
return Err(TraceError::InvalidInput("coincident direct visibility bodies are ambiguous"));
}
let Some(hit) = self.state.nearest else {
return if self.state.medium.is_some() {
Err(TraceError::InvalidInput("direct visibility missed its certified box exit"))
} else {
Ok(Some(self.state.transmittance))
};
};
let Some((slot, input)) = self.options.dielectrics.iter().enumerate().find(|(_, input)| input.body_index == hit.body) else {
return Ok(Some(Vec3::ZERO));
};
if self.state.crossings == self.state.crossing_limit {
return Err(TraceError::WorkLimit);
}
let outward = hit.geometry.geometric_normal;
let entering = outward.dot(self.state.ray.direction()) < 0.0;
let normal = if entering { outward } else { -outward };
match self.state.certificates.get(slot).copied().flatten().ok_or(TraceError::Internal("direct visibility certificate missing"))? {
Certificate::ThinSheet => {
if self.state.medium.is_some() {
return Err(TraceError::InvalidInput("direct visibility sheet inside a box"));
}
let (_, _, transmission) = thin_sheet(self.state.ray.direction(), normal, input)?;
self.state.transmittance = multiply(self.state.transmittance, transmission);
self.state.sheets |= 1u8 << slot;
}
Certificate::ClosedBox(bounds) => {
let [a, b, c] = hit.vertices;
let [wa, wb, wc] = hit.geometry.barycentric;
let face = exact_box_face(bounds, hit.vertices, outward, a * wa + b * wb + c * wc, self.state.ray)?;
// External-angle Fresnel on BOTH straight connections. The ray is
// not bent, so an internal critical angle here would invent TIR.
let transmit = 1.0 - interface(self.state.ray.direction(), normal, 1.0, input.ior)?.reflectance;
self.state.transmittance = self.state.transmittance * transmit;
if entering {
if self.state.medium.is_some() {
return Err(TraceError::InvalidInput("direct visibility entered overlapping boxes"));
}
self.state.medium = Some((slot, face.point));
} else {
let (active, start) = self.state.medium.ok_or(TraceError::InvalidInput("direct visibility exited without entry"))?;
if active != slot {
return Err(TraceError::InvalidInput("direct visibility box exit identity mismatch"));
}
let delta = face.point - start;
let scale = delta.x.abs().max(delta.y.abs()).max(delta.z.abs());
if scale == 0.0 || !scale.is_finite() {
return Err(TraceError::InvalidInput("direct visibility box segment is degenerate"));
}
let distance = scale * (delta / scale).len2().sqrt();
self.state.transmittance = multiply(self.state.transmittance, attenuation(input.absorption_per_mm, distance)?);
self.state.medium = None;
}
}
}
if !self.state.transmittance.is_finite()
|| [self.state.transmittance.x, self.state.transmittance.y, self.state.transmittance.z].into_iter().any(|v| !(0.0..=1.0).contains(&v))
{
return Err(TraceError::InvalidInput("invalid direct visibility transmittance"));
}
self.state.crossings += 1;
if self.state.transmittance == Vec3::ZERO {
return Ok(Some(Vec3::ZERO));
}
// Preserve the original origin AND normalized direction. Calling Ray::new
// again could renormalize it and change the tie/interval calculations.
self.state.ray.min = hit.geometry.distance.next_up();
if self.state.ray.min >= self.state.ray.max {
return if self.state.medium.is_some() {
Err(TraceError::InvalidInput("direct visibility interval cannot resolve the exit"))
} else {
Ok(Some(self.state.transmittance))
};
}
self.state.body = 0;
self.state.triangle = 0;
self.state.scanned = 0;
self.state.nearest = None;
self.state.ambiguous = false;
self.state.optical = false;
Ok(None)
}
}
/// Owns one immutable capture and at most one direct-light connection inline.
/// Moving this owner moves its scene/options and reference-free cursor together.
/// No state or mutable capture access escapes: certificates cannot be rebound to
/// a different scene/options pair by a sibling consumer. Construction only moves
/// the capture; `begin` retains the original validation and caller-owned ledger.
/// The existing borrowed `DirectVisibility` API remains independently available.
pub(super) struct DirectVisibilityOwner {
source: ProductScene,
options: crate::job::RaytraceOptions,
state: Option<DirectVisibilityState>,
}
impl DirectVisibilityOwner {
pub(super) fn new(source: ProductScene, options: crate::job::RaytraceOptions) -> Self {
Self { source, options, state: None }
}
/// Test fixtures may consume their capture; no cursor state survives or escapes.
#[cfg(test)]
pub(super) fn into_capture(self) -> (ProductScene, crate::job::RaytraceOptions) {
(self.source, self.options)
}
pub(super) fn source(&self) -> &ProductScene {
&self.source
}
pub(super) fn options(&self) -> &crate::job::RaytraceOptions {
&self.options
}
/// Start explicitly on this owner's capture. Never replace an active scan.
/// A failed constructor preserves the previous idle or terminal state.
pub(super) fn begin(
&mut self,
ray: super::Ray,
crossing_limit: u8,
work: &mut super::WorkBudget,
cancel: &crate::job::CancelToken,
) -> Result<()> {
if self.state.as_ref().is_some_and(|state| !state.terminal) {
return Err(TraceError::InvalidInput("direct visibility connection is active"));
}
let state = DirectVisibility::new(&self.source, &self.options, ray, crossing_limit, work, cancel)?.state;
self.state = Some(state);
Ok(())
}
/// Advance once, rebinding only to the immutable capture owned here.
/// Retain terminal state on success AND error; only explicit `begin` starts
/// another connection. Rebinding itself has no work, validation or allocation.
pub(super) fn step(&mut self, work: &mut super::WorkBudget, cancel: &crate::job::CancelToken) -> Result<Option<Vec3>> {
let state = self.state.take().ok_or(TraceError::InvalidInput("direct visibility connection has not begun"))?;
let mut cursor = DirectVisibility { source: &self.source, options: &self.options, state };
let result = cursor.step(work, cancel);
self.state = Some(cursor.state);
result
}
}
#[cfg(test)]
#[path = "dielectric_tests.rs"]
pub(super) mod tests;
@@ -43,6 +43,10 @@ pub(in crate::raytrace) fn request(options: RaytraceOptions) -> ImageRequest {
let mut camera = crate::Camera { distance: 50.0, ..Default::default() };
camera.set_view(crate::StandardView::Top);
ImageRequest {
raster_punctual_lights: None,
raster_area_lights: None,
raster_exposure_ev: 0.0,
raster_lights: None,
studio_weights: None,
environment: None,
textures: None,
@@ -514,3 +518,685 @@ fn actual_png_brewster_thin_sheet_matches_reflected_energy() {
// maps to 98, so this also detects a consumer still using the old series.
assert_eq!(pixel.expect("Brewster sheet fixture must finish"), [95, 95, 95, 255]);
}
#[test]
fn strict_box_parent_selection_is_order_independent_and_opt_in() {
let bounds = |lo: f64, hi: f64| Bounds::new(Vec3::new(lo, lo, lo), Vec3::new(hi, hi, hi)).unwrap();
let mut certificates = [None; MAX_OVERRIDES];
certificates[0] = Some(Certificate::ClosedBox(bounds(2.0, 3.0)));
certificates[1] = Some(Certificate::ClosedBox(bounds(0.0, 5.0)));
certificates[2] = Some(Certificate::ClosedBox(bounds(1.0, 4.0)));
assert!(parents(&certificates, false).is_err());
assert_eq!(parents(&certificates, true).unwrap(), [Some(2), None, Some(1), None, None, None, None, None]);
certificates.swap(0, 1);
assert_eq!(parents(&certificates, true).unwrap(), [None, Some(2), Some(0), None, None, None, None, None]);
}
#[test]
fn nesting_opt_in_still_refuses_touching_equal_and_partially_overlapping_boxes() {
let outer = Bounds::new(Vec3::ZERO, Vec3::new(4.0, 4.0, 4.0)).unwrap();
for (lo, hi) in [
(Vec3::new(0.0, 1.0, 1.0), Vec3::new(3.0, 3.0, 3.0)),
(Vec3::new(4.0, 1.0, 1.0), Vec3::new(5.0, 3.0, 3.0)),
(Vec3::new(4.0, 4.0, 1.0), Vec3::new(5.0, 5.0, 3.0)),
(Vec3::new(4.0, 4.0, 4.0), Vec3::new(5.0, 5.0, 5.0)),
(Vec3::ZERO, Vec3::new(4.0, 4.0, 4.0)),
(Vec3::new(3.0, 1.0, 1.0), Vec3::new(5.0, 3.0, 3.0)),
] {
let mut certificates = [None; MAX_OVERRIDES];
certificates[0] = Some(Certificate::ClosedBox(outer));
certificates[1] = Some(Certificate::ClosedBox(Bounds::new(lo, hi).unwrap()));
assert!(parents(&certificates, true).is_err());
}
}
pub(in crate::raytrace) fn nested_fixture(reverse: bool) -> (ProductScene, RaytraceOptions) {
let mut outer = box_body(4.0);
outer.name = "Outer".into();
let mut inner = box_body(2.0);
inner.name = "Inner".into();
for p in &mut Arc::make_mut(&mut inner.mesh).positions {
*p = Vec3::new(p.x * 0.5, p.y * 0.5, p.z + 1.0);
}
let mut scene = scene(outer);
scene.meshes.push(inner);
if reverse {
scene.meshes.reverse();
}
let dielectrics = scene
.meshes
.iter()
.enumerate()
.map(|(body_index, body)| DielectricOptions {
body_index,
body_name: body.name.clone(),
boundary: DielectricBoundary::ClosedBox,
ior: 1.0,
absorption_per_mm: if body.name == "Outer" { [0.1, 0.2, 0.0] } else { [0.3, 0.4, 0.0] },
thickness_mm: 0.0,
})
.collect();
(scene, RaytraceOptions { dielectrics, environment: environment(), lights: Some(Vec::new()), shadows: false, ..Default::default() })
}
#[test]
fn actual_nested_png_uses_parent_only_outside_the_inner_interval() {
for reverse in [false, true] {
let (scene, options) = nested_fixture(reverse);
assert!(super::super::image::admit(&scene, &request(options.clone()), &options).is_err());
let mut task = super::super::image::ImageTask::new_with_environment_and_nested(
scene,
request(options.clone()),
CancelToken::default(),
options,
None,
true,
)
.unwrap();
let mut completed = false;
for _ in 0..100_000 {
if let TaskPoll::Complete(png) = task.step(16384).unwrap() {
let image = ::image::load_from_memory(&png).unwrap().to_rgba8();
// Outer length2, inner length2; Beer radiance (exp(-.8), exp(-1.2), 1).
// Reinhard then IEC sRGB encoding gives these independently fixed bytes.
assert_eq!(image.get_pixel(0, 0).0, [151, 132, 188, 255]);
completed = true;
break;
}
}
assert!(completed, "nested final image did not complete");
}
}
#[test]
fn progressive_optics_keep_the_nested_certificate_opt_in_and_interactive_intent() {
let (scene, mut options) = nested_fixture(false);
options.progressive = Some(Default::default());
options.validate().unwrap();
let request = request(options.clone());
// A DTO succeeding never grants a nesting certificate to an ordinary job.
assert!(super::super::image::admit(&scene, &request, &options).is_err());
assert!(super::super::image::admit_with_nested(&scene, &request, &options, true).is_ok());
let interactive = crate::job::interactive::cpu_request(&request).unwrap();
assert_eq!(interactive.raytrace.as_ref().unwrap().dielectrics, options.dielectrics);
assert!(super::super::image::admit(&scene, &interactive, interactive.raytrace.as_ref().unwrap()).is_err());
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn native_host_forwards_only_the_existing_nested_media_exception() {
use crate::job::{CpuHost, ErrorCode, JobId, JobOptions, Phase};
let host = CpuHost::for_platform().unwrap();
let (scene, options) = nested_fixture(false);
let request = request(options);
let nested = JobOptions { nested_media: true, ..Default::default() };
assert!(host.admit(&scene, &request, JobOptions::default()).is_err());
assert!(host.admit(&scene, &request, nested).is_ok());
for bad in [
JobOptions { samples: 2, ..nested },
JobOptions { max_depth: 2, ..nested },
JobOptions { seed: 7, ..nested },
JobOptions { exposure_ev: 1.0, ..nested },
JobOptions { transmission_samples: 2, ..nested },
JobOptions { denoise: true, ..nested },
JobOptions { linear_exr: true, ..nested },
] {
assert_eq!(host.admit(&scene, &request, bad).unwrap_err().code, ErrorCode::InvalidRequest);
}
let mut raster = request.clone();
raster.integrator = Integrator::Raster;
raster.raytrace = None;
assert!(host.admit(&scene, &raster, nested).is_err());
let handle = host.submit_with_options(JobId { session: 6302, serial: 1, generation: 1 }, 1, scene, request, nested).unwrap();
assert_eq!(handle.wait_timeout(std::time::Duration::from_secs(60)).unwrap().progress.phase, Phase::Completed);
assert_eq!(handle.status().unwrap().options.nested_media, true);
let frame = handle.frame().unwrap().unwrap();
let image = ::image::load_from_memory(frame.png()).unwrap().to_rgba8();
assert_eq!(image.get_pixel(0, 0).0, [151, 132, 188, 255]);
}
fn face_fixture(axis: usize, positive: bool) -> (Bounds, [Vec3; 3], Vec3, Vec3) {
let point = |a: f64, u: f64, v: f64| match axis {
0 => Vec3::new(a, u, v),
1 => Vec3::new(v, a, u),
2 => Vec3::new(u, v, a),
_ => panic!("test axis"),
};
let bounds = Bounds::new(Vec3::new(1.0, 1.0, 1.0), Vec3::new(4.0, 4.0, 4.0)).unwrap();
let plane = if positive { 4.0 } else { 1.0 };
let mut vertices = [point(plane, 1.0, 1.0), point(plane, 4.0, 1.0), point(plane, 4.0, 4.0)];
if !positive {
vertices.swap(1, 2);
}
let normal = match axis {
0 => Vec3::X,
1 => Vec3::Y,
2 => Vec3::Z,
_ => panic!("test axis"),
} * if positive { 1.0 } else { -1.0 };
(bounds, vertices, normal, point(plane, 2.5, 2.5))
}
#[test]
fn exact_face_excludes_own_zero_depth_and_retains_one_ulp_neighbour_on_every_axis_and_side() {
use super::super::Ray;
for axis in 0..3 {
for positive in [false, true] {
let (bounds, vertices, normal, position) = face_fixture(axis, positive);
for entering in [false, true] {
let direction = if entering { -normal } else { normal };
let incoming = Ray::new(position - direction, direction, 0.0, 10.0).unwrap();
let face = exact_box_face(bounds, vertices, normal, position, incoming).unwrap();
assert_eq!(face.point, position, "face diagonal is valid interior");
let optics = interface(direction, -direction, if entering { 1.0 } else { 1.5 }, if entering { 1.5 } else { 1.0 }).unwrap();
let reflected = face.spawn(optics.reflected, true).unwrap();
let transmitted = face.spawn(optics.transmitted.unwrap(), false).unwrap();
let triangle = Triangle::new(vertices).unwrap();
assert!(triangle.intersect(&reflected).is_none(), "own reflected face at t0 must be excluded");
assert!(triangle.intersect(&transmitted).is_none(), "own transmitted face at t0 must be excluded");
assert_eq!(reflected.origin(), face.point);
assert_eq!(transmitted.origin(), face.point);
assert_eq!(transmitted.interval()[0], 0.0_f64.next_up());
let plane = super::super::component(position, axis);
let next = if super::super::component(direction, axis) > 0.0 { plane.next_up() } else { plane.next_down() };
let neighbour = vertices.map(|v| match axis {
0 => Vec3::new(next, v.y, v.z),
1 => Vec3::new(v.x, next, v.z),
2 => Vec3::new(v.x, v.y, next),
_ => panic!("test axis"),
});
let hit = Triangle::new(neighbour).unwrap().intersect(&transmitted).expect("one-ULP positive face must remain ahead");
assert_eq!(hit.distance, (next - plane).abs());
}
}
}
}
#[test]
fn exact_face_recovers_certified_plane_without_clamping_or_blessing_ties() {
use super::super::Ray;
let (bounds, vertices, normal, point) = face_fixture(2, false);
let incoming = Ray::new(point - Vec3::Z, Vec3::Z, 0.0, 10.0).unwrap();
let rounded = Vec3::new(point.x, point.y, point.z.next_up());
let face = exact_box_face(bounds, vertices, normal, rounded, incoming).unwrap();
assert_eq!(face.point, point);
for bad in [Vec3::new(1.0, 2.5, 1.0), Vec3::new(1.0, 1.0, 1.0), Vec3::new(1.0_f64.next_down(), 2.5, 1.0), Vec3::new(f64::NAN, 2.5, 1.0)] {
assert!(exact_box_face(bounds, vertices, normal, bad, incoming).is_err());
}
let tied = Ray::new(Vec3::ZERO, Vec3::new(1.0, 1.0, 1.0), 0.0, 10.0).unwrap();
// Even if rounded tangential reconstruction claims interior, independently
// equal actual ray/face depths cannot inherit the BVH ordinal's blessing.
let uncertain = Vec3::new(1.0_f64.next_up(), 1.0_f64.next_up(), 1.0);
assert!(exact_box_face(bounds, vertices, normal, uncertain, tied).is_err());
assert!(exact_box_face(bounds, vertices, -normal, point, incoming).is_err());
let other_bounds = Bounds::new(Vec3::ZERO, Vec3::new(4.0, 4.0, 4.0)).unwrap();
assert!(exact_box_face(other_bounds, vertices, normal, point, incoming).is_err());
let tangent = Ray::new(point - Vec3::X, Vec3::X, 0.0, 10.0).unwrap();
assert!(exact_box_face(bounds, vertices, normal, point, tangent).is_err());
assert!(face.spawn(Vec3::X, true).is_err());
assert!(face.spawn(-Vec3::Z, false).is_err());
assert!(face.spawn(Vec3::Z, true).is_err());
}
fn direct_capture(bodies: Vec<ProductMesh>) -> ProductScene {
let mut bounds = bodies[0].mesh.bounds();
for body in &bodies[1..] {
let next = body.mesh.bounds();
bounds.min = bounds.min.min(next.min);
bounds.max = bounds.max.max(next.max);
}
ProductScene { meshes: bodies, bounds }
}
fn direct_box(name: &str, offset: f64) -> ProductMesh {
let mut body = box_body(1.0);
body.name = name.into();
for p in &mut Arc::make_mut(&mut body.mesh).positions {
p.z += offset;
}
body
}
fn direct_sheet(name: &str, offset: f64) -> ProductMesh {
let mut body = direct_box(name, offset);
let mesh = Arc::make_mut(&mut body.mesh);
mesh.triangles.truncate(2);
mesh.positions.truncate(4);
body
}
fn direct_material(body: usize, name: &str, boundary: DielectricBoundary) -> DielectricOptions {
DielectricOptions {
body_index: body,
body_name: name.into(),
boundary,
ior: 1.0,
absorption_per_mm: [2.0_f64.ln(), 4.0_f64.ln(), 0.0],
thickness_mm: if boundary == DielectricBoundary::ThinSheet { 1.0 } else { 0.0 },
}
}
fn direct_ray(max: f64) -> super::super::Ray {
super::super::Ray::new(Vec3::new(0.25, 0.125, -2.0), Vec3::Z, 0.0, max).unwrap()
}
fn direct_complete(source: &ProductScene, options: &RaytraceOptions, ray: super::super::Ray) -> Vec3 {
let cancel = CancelToken::default();
let mut work = super::super::WorkBudget::new(100_000).unwrap();
let mut visibility = DirectVisibility::new(source, options, ray, MAX_DIRECT_CROSSINGS, &mut work, &cancel).unwrap();
for _ in 0..10_000 {
if let Some(rgb) = visibility.step(&mut work, &cancel).unwrap() {
assert!(visibility.step(&mut work, &cancel).is_err());
return rgb;
}
}
panic!("bounded direct connection did not complete");
}
fn direct_rgb(actual: Vec3, expected: Vec3) {
for (a, b) in [actual.x, actual.y, actual.z].into_iter().zip([expected.x, expected.y, expected.z]) {
assert!((a - b).abs() <= 1e-13, "actual={actual:?}, expected={expected:?}");
}
}
#[test]
fn direct_box_beer_uses_original_endpoints_and_multiplies_disjoint_rgb() {
let source = direct_capture(vec![direct_box("one", 0.0), direct_box("two", 3.0)]);
let first = direct_material(0, "one", DielectricBoundary::ClosedBox);
let second = direct_material(1, "two", DielectricBoundary::ClosedBox);
let options = RaytraceOptions { dielectrics: vec![first, second], ..Default::default() };
// sigma=(ln2,ln4,0), two one-mm slabs: exp(-2*sigma)=(1/4,1/16,1).
direct_rgb(direct_complete(&source, &options, direct_ray(10.0)), Vec3::new(0.25, 0.0625, 1.0));
let one = direct_capture(vec![direct_box("one", 0.0)]);
let options = RaytraceOptions { dielectrics: vec![direct_material(0, "one", DielectricBoundary::ClosedBox)], ..Default::default() };
direct_rgb(direct_complete(&one, &options, direct_ray(10.0)), Vec3::new(0.5, 0.25, 1.0));
}
#[test]
fn direct_sheet_shared_diagonal_is_one_crossing_and_uses_virtual_beer_distance() {
let source = direct_capture(vec![direct_sheet("sheet", 0.0)]);
let options = RaytraceOptions { dielectrics: vec![direct_material(0, "sheet", DielectricBoundary::ThinSheet)], ..Default::default() };
let ray = super::super::Ray::new(Vec3::new(0.25, 0.25, -2.0), Vec3::Z, 0.0, 10.0).unwrap();
direct_rgb(direct_complete(&source, &options, ray), Vec3::new(0.5, 0.25, 1.0));
}
#[test]
fn direct_exterior_box_fresnel_is_not_a_refracted_or_infinite_bounce_slab() {
let source = direct_capture(vec![direct_box("glass", 0.0)]);
let material = DielectricOptions { ior: 1.5, absorption_per_mm: [0.0; 3], ..direct_material(0, "glass", DielectricBoundary::ClosedBox) };
let options = RaytraceOptions { dielectrics: vec![material], ..Default::default() };
// Normal external R=1/25; this straight connection has (24/25)^2, with no reflected returns.
direct_rgb(direct_complete(&source, &options, direct_ray(10.0)), Vec3::new(576.0 / 625.0, 576.0 / 625.0, 576.0 / 625.0));
let sheet = direct_capture(vec![direct_sheet("sheet", 0.0)]);
let material = DielectricOptions { ior: 1.5, absorption_per_mm: [0.0; 3], ..direct_material(0, "sheet", DielectricBoundary::ThinSheet) };
let options = RaytraceOptions { dielectrics: vec![material], ..Default::default() };
// Existing incoherent sheet closure sums its virtual internal returns: (1-R)/(1+R)=12/13.
direct_rgb(direct_complete(&sheet, &options, direct_ray(10.0)), Vec3::new(12.0 / 13.0, 12.0 / 13.0, 12.0 / 13.0));
}
#[test]
fn direct_connection_keeps_unselected_opaque_blockers_and_half_open_light_endpoint() {
let options = RaytraceOptions::default();
for (z, expected) in [(7.0, Vec3::ZERO), (8.0, Vec3::new(1.0, 1.0, 1.0)), (9.0, Vec3::new(1.0, 1.0, 1.0))] {
let source = direct_capture(vec![direct_sheet("opaque", z)]);
direct_rgb(direct_complete(&source, &options, direct_ray(10.0)), expected);
}
let source = direct_capture(vec![direct_box("glass", 0.0), direct_sheet("blocker", 5.0)]);
let options = RaytraceOptions { dielectrics: vec![direct_material(0, "glass", DielectricBoundary::ClosedBox)], ..Default::default() };
assert_eq!(direct_complete(&source, &options, direct_ray(10.0)), Vec3::ZERO);
}
#[test]
fn direct_connection_refuses_coincident_different_bodies_instead_of_skipping_opaque() {
let source = direct_capture(vec![direct_sheet("selected", 0.0), direct_sheet("opaque", 0.0)]);
let options = RaytraceOptions { dielectrics: vec![direct_material(0, "selected", DielectricBoundary::ThinSheet)], ..Default::default() };
let cancel = CancelToken::default();
let mut work = super::super::WorkBudget::new(100_000).unwrap();
let mut cursor = DirectVisibility::new(&source, &options, direct_ray(10.0), MAX_DIRECT_CROSSINGS, &mut work, &cancel).unwrap();
for _ in 0..100 {
match cursor.step(&mut work, &cancel) {
Ok(None) => {}
Err(super::super::TraceError::InvalidInput(_)) => {
assert!(cursor.step(&mut work, &cancel).is_err());
return;
}
other => panic!("ambiguous visibility must refuse: {other:?}"),
}
}
panic!("coincident boundary did not refuse");
}
#[test]
fn direct_connection_bounds_validate_identity_endpoints_overlap_and_crossing_budget() {
let source = direct_capture(vec![direct_box("glass", 0.0)]);
let options = RaytraceOptions { dielectrics: vec![direct_material(0, "glass", DielectricBoundary::ClosedBox)], ..Default::default() };
let cancel = CancelToken::default();
let mut work = super::super::WorkBudget::new(100_000).unwrap();
for limit in [0, MAX_DIRECT_CROSSINGS + 1] {
assert!(DirectVisibility::new(&source, &options, direct_ray(10.0), limit, &mut work, &cancel).is_err());
}
// End at entry face, inside, and at exit face: all explicitly outside this foundation's domain.
for max in [2.0, 2.5, 3.0] {
assert!(DirectVisibility::new(&source, &options, direct_ray(max), 2, &mut work, &cancel).is_err());
}
let inside = super::super::Ray::new(Vec3::new(0.25, 0.125, 0.5), Vec3::Z, 0.0, 10.0).unwrap();
assert!(DirectVisibility::new(&source, &options, inside, 2, &mut work, &cancel).is_err());
let mut wrong = options.clone();
wrong.dielectrics[0].body_name = "wrong".into();
assert!(DirectVisibility::new(&source, &wrong, direct_ray(10.0), 2, &mut work, &cancel).is_err());
let overlap = direct_capture(vec![direct_box("a", 0.0), direct_box("b", 0.5)]);
let overlapping = RaytraceOptions {
dielectrics: vec![direct_material(0, "a", DielectricBoundary::ClosedBox), direct_material(1, "b", DielectricBoundary::ClosedBox)],
..Default::default()
};
assert!(DirectVisibility::new(&overlap, &overlapping, direct_ray(10.0), 4, &mut work, &cancel).is_err());
let mut cursor = DirectVisibility::new(&source, &options, direct_ray(10.0), 1, &mut work, &cancel).unwrap();
for _ in 0..100 {
match cursor.step(&mut work, &cancel) {
Ok(None) => {}
Err(super::super::TraceError::WorkLimit) => {
assert!(cursor.step(&mut work, &cancel).is_err());
return;
}
other => panic!("insufficient crossing budget must refuse: {other:?}"),
}
}
panic!("crossing cap did not stop");
}
#[test]
fn direct_connection_spends_caller_work_and_cancellation_is_terminal_without_result() {
let source = direct_capture(vec![direct_box("glass", 0.0)]);
let options = RaytraceOptions { dielectrics: vec![direct_material(0, "glass", DielectricBoundary::ClosedBox)], ..Default::default() };
let cancel = CancelToken::default();
let mut work = super::super::WorkBudget::new(1000).unwrap();
let mut cursor = DirectVisibility::new(&source, &options, direct_ray(10.0), 2, &mut work, &cancel).unwrap();
let used = work.used();
assert!(used > 0);
assert_eq!(cursor.step(&mut work, &cancel).unwrap(), None);
assert_eq!(work.used(), used + 1);
work.charge(work.remaining(), &cancel).unwrap();
assert!(matches!(cursor.step(&mut work, &cancel), Err(super::super::TraceError::WorkLimit)));
assert!(cursor.step(&mut work, &cancel).is_err());
let cancel = CancelToken::default();
let mut work = super::super::WorkBudget::new(1000).unwrap();
let mut cursor = DirectVisibility::new(&source, &options, direct_ray(10.0), 2, &mut work, &cancel).unwrap();
let used = work.used();
cancel.cancel();
assert!(matches!(cursor.step(&mut work, &cancel), Err(super::super::TraceError::Cancelled)));
assert_eq!(work.used(), used);
assert!(cursor.step(&mut work, &cancel).is_err());
assert!(matches!(DirectVisibility::new(&source, &options, direct_ray(10.0), 2, &mut work, &cancel), Err(super::super::TraceError::Cancelled)));
}
#[test]
fn direct_connection_rejects_hostile_optics_before_any_dark_result() {
let source = direct_capture(vec![direct_box("glass", 0.0)]);
let cancel = CancelToken::default();
let mut work = super::super::WorkBudget::new(10000).unwrap();
for value in [f64::NAN, f64::INFINITY, -1.0, 101.0] {
let material = DielectricOptions { absorption_per_mm: [value, 0.0, 0.0], ..direct_material(0, "glass", DielectricBoundary::ClosedBox) };
let options = RaytraceOptions { dielectrics: vec![material], ..Default::default() };
assert!(DirectVisibility::new(&source, &options, direct_ray(10.0), 2, &mut work, &cancel).is_err());
}
let material = DielectricOptions { ior: f64::NAN, ..direct_material(0, "glass", DielectricBoundary::ClosedBox) };
let options = RaytraceOptions { dielectrics: vec![material], ..Default::default() };
assert!(DirectVisibility::new(&source, &options, direct_ray(10.0), 2, &mut work, &cancel).is_err());
}
#[test]
fn direct_connection_accepts_all_eight_disjoint_boxes_and_preserves_reverse_rgb() {
let bodies = (0..8).map(|i| direct_box(&format!("slab{i}"), f64::from(i) * 2.0)).collect();
let source = direct_capture(bodies);
let dielectrics = (0..8).map(|i| direct_material(i, &format!("slab{i}"), DielectricBoundary::ClosedBox)).collect();
let options = RaytraceOptions { dielectrics, ..Default::default() };
let expected = Vec3::new(1.0 / 256.0, 1.0 / 65536.0, 1.0);
direct_rgb(direct_complete(&source, &options, direct_ray(20.0)), expected);
let reverse = super::super::Ray::new(Vec3::new(0.25, 0.125, 18.0), -Vec3::Z, 0.0, 20.0).unwrap();
direct_rgb(direct_complete(&source, &options, reverse), expected);
let mut too_many = options.clone();
too_many.dielectrics.push(direct_material(8, "ninth", DielectricBoundary::ClosedBox));
let cancel = CancelToken::default();
let mut work = super::super::WorkBudget::new(1000).unwrap();
assert!(DirectVisibility::new(&source, &too_many, direct_ray(20.0), 16, &mut work, &cancel).is_err());
}
#[test]
fn direct_connection_box_edge_is_refused_without_a_successful_fallback() {
let source = direct_capture(vec![direct_box("glass", 0.0)]);
let options = RaytraceOptions { dielectrics: vec![direct_material(0, "glass", DielectricBoundary::ClosedBox)], ..Default::default() };
let ray = super::super::Ray::new(Vec3::new(-10.0, -10.0, -2.0), Vec3::Z, 0.0, 10.0).unwrap();
let cancel = CancelToken::default();
let mut work = super::super::WorkBudget::new(1000).unwrap();
let mut cursor = DirectVisibility::new(&source, &options, ray, 2, &mut work, &cancel).unwrap();
for _ in 0..100 {
match cursor.step(&mut work, &cancel) {
Ok(None) => {}
Err(super::super::TraceError::InvalidInput(_)) => return,
other => panic!("box edge must refuse: {other:?}"),
}
}
panic!("edge ambiguity did not stop");
}
#[test]
fn direct_connection_setup_certification_cannot_escape_caller_work_limit() {
let source = direct_capture(vec![direct_box("glass", 0.0)]);
let options = RaytraceOptions { dielectrics: vec![direct_material(0, "glass", DielectricBoundary::ClosedBox)], ..Default::default() };
let cancel = CancelToken::default();
let mut work = super::super::WorkBudget::new(1).unwrap();
assert!(matches!(DirectVisibility::new(&source, &options, direct_ray(10.0), 2, &mut work, &cancel), Err(super::super::TraceError::WorkLimit)));
assert_eq!(work.used(), 1);
}
#[test]
fn direct_oblique_sheet_is_one_certified_plane_with_original_beer_angle() {
let mut body = direct_sheet("oblique", 0.0);
for p in &mut Arc::make_mut(&mut body.mesh).positions {
p.z += 0.5 * p.x;
}
let source = direct_capture(vec![body]);
let options = RaytraceOptions { dielectrics: vec![direct_material(0, "oblique", DielectricBoundary::ThinSheet)], ..Default::default() };
let ray = super::super::Ray::new(Vec3::new(0.25, 0.25, -2.0), Vec3::Z, 0.0, 10.0).unwrap();
// Plane z=x/2, index matched: thickness/cos(theta)=sqrt(5)/2 mm.
let distance = 5.0_f64.sqrt() / 2.0;
direct_rgb(direct_complete(&source, &options, ray), Vec3::new(2.0_f64.powf(-distance), 4.0_f64.powf(-distance), 1.0));
}
// Original owner regression cases, written before the private owner implementation.
fn direct_owner_fixture() -> DirectVisibilityOwner {
DirectVisibilityOwner::new(
direct_capture(vec![direct_box("owned glass", 0.0)]),
RaytraceOptions { dielectrics: vec![direct_material(0, "owned glass", DielectricBoundary::ClosedBox)], ..Default::default() },
)
}
fn direct_owner_move(owner: DirectVisibilityOwner) -> DirectVisibilityOwner {
owner
}
#[test]
fn direct_owner_move_each_poll_matches_borrowed_result_and_cumulative_work() {
let source = direct_capture(vec![direct_box("owned glass", 0.0)]);
let options = RaytraceOptions { dielectrics: vec![direct_material(0, "owned glass", DielectricBoundary::ClosedBox)], ..Default::default() };
let cancel = CancelToken::default();
let mut reference_work = super::super::WorkBudget::new(100_000).unwrap();
let mut owner_work = super::super::WorkBudget::new(100_000).unwrap();
let mut reference = DirectVisibility::new(&source, &options, direct_ray(10.0), 2, &mut reference_work, &cancel).unwrap();
let mut owner = direct_owner_fixture();
owner.begin(direct_ray(10.0), 2, &mut owner_work, &cancel).unwrap();
assert_eq!(owner_work.used(), reference_work.used());
for _ in 0..10_000 {
owner = direct_owner_move(owner);
let expected = reference.step(&mut reference_work, &cancel).unwrap();
let actual = owner.step(&mut owner_work, &cancel).unwrap();
assert_eq!(actual, expected);
assert_eq!(owner_work.used(), reference_work.used());
assert_eq!(owner_work.remaining(), reference_work.remaining());
if let Some(rgb) = actual {
direct_rgb(rgb, Vec3::new(0.5, 0.25, 1.0));
assert_eq!(owner.source().meshes[0].name, "owned glass");
assert_eq!(owner.options().dielectrics[0].body_name, "owned glass");
return;
}
}
panic!("owner connection did not complete within the original fixture bound");
}
#[test]
fn direct_owner_rejects_active_begin_without_replacing_cursor_or_spending_work() {
let cancel = CancelToken::default();
let mut work = super::super::WorkBudget::new(100_000).unwrap();
let mut owner = direct_owner_fixture();
owner.begin(direct_ray(10.0), 2, &mut work, &cancel).unwrap();
assert_eq!(owner.step(&mut work, &cancel).unwrap(), None);
let used = work.used();
// This shorter replacement ray would end before the glass if accepted.
assert!(owner.begin(direct_ray(1.0), 2, &mut work, &cancel).is_err());
assert_eq!(work.used(), used);
for _ in 0..10_000 {
if let Some(rgb) = owner.step(&mut work, &cancel).unwrap() {
direct_rgb(rgb, Vec3::new(0.5, 0.25, 1.0));
return;
}
}
panic!("rejected begin lost the active connection");
}
#[test]
fn direct_owner_success_is_terminal_until_explicit_begin_on_the_same_capture() {
let cancel = CancelToken::default();
let mut work = super::super::WorkBudget::new(100_000).unwrap();
let mut owner = direct_owner_fixture();
let mut first_cost = 0;
for connection in 0..2 {
owner.begin(direct_ray(10.0), 2, &mut work, &cancel).unwrap();
let mut complete = false;
for _ in 0..10_000 {
if let Some(rgb) = owner.step(&mut work, &cancel).unwrap() {
direct_rgb(rgb, Vec3::new(0.5, 0.25, 1.0));
complete = true;
break;
}
}
assert!(complete);
let used = work.used();
owner = direct_owner_move(owner);
assert!(matches!(owner.step(&mut work, &cancel), Err(super::super::TraceError::InvalidInput(_))));
assert_eq!(work.used(), used);
if connection == 0 {
first_cost = used;
assert!(first_cost > 0);
} else {
assert_eq!(used, first_cost * 2);
}
}
}
#[test]
fn direct_owner_cancelled_connection_stays_terminal_after_move_and_fresh_token() {
let cancel = CancelToken::default();
let mut work = super::super::WorkBudget::new(1000).unwrap();
let mut owner = direct_owner_fixture();
owner.begin(direct_ray(10.0), 2, &mut work, &cancel).unwrap();
assert_eq!(owner.step(&mut work, &cancel).unwrap(), None);
let used = work.used();
cancel.cancel();
assert!(matches!(owner.step(&mut work, &cancel), Err(super::super::TraceError::Cancelled)));
assert_eq!(work.used(), used);
owner = direct_owner_move(owner);
let fresh_token = CancelToken::default();
assert!(matches!(owner.step(&mut work, &fresh_token), Err(super::super::TraceError::InvalidInput(_))));
assert_eq!(work.used(), used);
assert!(matches!(owner.begin(direct_ray(10.0), 2, &mut work, &cancel), Err(super::super::TraceError::Cancelled)));
assert_eq!(work.used(), used);
assert!(owner.step(&mut work, &fresh_token).is_err());
assert_eq!(work.used(), used);
}
#[test]
fn direct_owner_exhausted_connection_stays_terminal_without_ledger_reset() {
let cancel = CancelToken::default();
let mut work = super::super::WorkBudget::new(1000).unwrap();
let mut owner = direct_owner_fixture();
owner.begin(direct_ray(10.0), 2, &mut work, &cancel).unwrap();
assert_eq!(owner.step(&mut work, &cancel).unwrap(), None);
work.charge(work.remaining(), &cancel).unwrap();
let used = work.used();
assert!(matches!(owner.step(&mut work, &cancel), Err(super::super::TraceError::WorkLimit)));
owner = direct_owner_move(owner);
assert!(matches!(owner.step(&mut work, &cancel), Err(super::super::TraceError::InvalidInput(_))));
assert_eq!(work.used(), used);
assert_eq!(work.remaining(), 0);
assert!(matches!(owner.begin(direct_ray(10.0), 2, &mut work, &cancel), Err(super::super::TraceError::WorkLimit)));
assert_eq!(work.used(), used);
assert!(owner.step(&mut work, &cancel).is_err());
assert_eq!(work.used(), used);
}
#[test]
fn direct_owner_idle_and_failed_begin_do_not_create_a_connection() {
let cancel = CancelToken::default();
let mut work = super::super::WorkBudget::new(1000).unwrap();
let mut owner = direct_owner_fixture();
assert!(owner.step(&mut work, &cancel).is_err());
assert_eq!(work.used(), 0);
assert!(owner.begin(direct_ray(10.0), 0, &mut work, &cancel).is_err());
let used = work.used();
assert_eq!(used, 1); // The original constructor charges before checking the limit.
owner = direct_owner_move(owner);
assert!(owner.step(&mut work, &cancel).is_err());
assert_eq!(work.used(), used);
owner.begin(direct_ray(10.0), 2, &mut work, &cancel).unwrap();
assert_eq!(owner.step(&mut work, &cancel).unwrap(), None);
}
#[test]
fn direct_owner_crossing_limit_error_stays_terminal_after_move() {
let cancel = CancelToken::default();
let mut work = super::super::WorkBudget::new(100_000).unwrap();
let mut owner = direct_owner_fixture();
owner.begin(direct_ray(10.0), 1, &mut work, &cancel).unwrap();
let mut refused = false;
for _ in 0..10_000 {
match owner.step(&mut work, &cancel) {
Ok(None) => {}
Err(super::super::TraceError::WorkLimit) => {
refused = true;
break;
}
other => panic!("crossing-limited owner returned {other:?}"),
}
}
assert!(refused);
let used = work.used();
owner = direct_owner_move(owner);
assert!(matches!(owner.step(&mut work, &cancel), Err(super::super::TraceError::InvalidInput(_))));
assert_eq!(work.used(), used);
}
#[test]
fn direct_owner_consuming_capture_discards_active_cursor_before_a_new_context() {
let cancel = CancelToken::default();
let mut work = super::super::WorkBudget::new(100_000).unwrap();
let mut owner = direct_owner_fixture();
owner.begin(direct_ray(10.0), 2, &mut work, &cancel).unwrap();
assert_eq!(owner.step(&mut work, &cancel).unwrap(), None);
let used = work.used();
let (mut source, mut options) = owner.into_capture();
source.meshes[0].name = "new capture".into();
options.dielectrics[0].body_name = "new capture".into();
options.dielectrics[0].absorption_per_mm = [0.0; 3];
let mut new_owner = DirectVisibilityOwner::new(source, options);
assert!(new_owner.step(&mut work, &cancel).is_err());
assert_eq!(work.used(), used);
new_owner.begin(direct_ray(10.0), 2, &mut work, &cancel).unwrap();
assert!(work.used() > used);
for _ in 0..10_000 {
if let Some(rgb) = new_owner.step(&mut work, &cancel).unwrap() {
assert_eq!(rgb, Vec3::new(1.0, 1.0, 1.0));
return;
}
}
panic!("new owner did not complete its new capture");
}
@@ -35,6 +35,10 @@ fn request() -> ImageRequest {
let mut camera = crate::Camera { distance: 50.0, ..Default::default() };
camera.set_view(crate::StandardView::Top);
ImageRequest {
raster_punctual_lights: None,
raster_area_lights: None,
raster_exposure_ev: 0.0,
raster_lights: None,
studio_weights: None,
environment: None,
textures: None,
@@ -129,7 +133,7 @@ fn centre(png: &[u8]) -> [u8; 4] {
#[test]
fn progressive_hdr_and_rough_reflections_keep_the_same_absolute_prefix_at_every_quantum() {
for (target, checkpoints) in [(3, vec![1, 2]), (7, vec![1, 2, 4])] {
for (target, checkpoints) in [(3, vec![1, 2]), (7, vec![1, 2, 3, 4, 5, 6])] {
let result = progressive_hdr_png(target, 82, 1, Some(input()));
assert_eq!(result.previews.iter().map(|(n, _)| *n).collect::<Vec<_>>(), checkpoints);
let other = progressive_hdr_png(target, 82, 16384, Some(input()));
@@ -40,6 +40,10 @@ fn request(mode: ReflectionMode) -> ImageRequest {
let mut camera = crate::Camera { distance: 50.0, ..Default::default() };
camera.set_view(crate::StandardView::Top);
ImageRequest {
raster_punctual_lights: None,
raster_area_lights: None,
raster_exposure_ev: 0.0,
raster_lights: None,
studio_weights: None,
environment: None,
textures: None,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+41 -2
View File
@@ -12,7 +12,9 @@ pub const MAX_LIGHTS: usize = 8;
pub const MAX_LIGHT_INTENSITY: f64 = 64.0;
/// Refuse unrepresentable or excessive output rather than silently clip the transport.
pub const MAX_LINEAR_RADIANCE: f64 = 1.0e12;
pub const MAX_EXPOSURE_EV: f64 = 16.0;
// With admitted radiance <=1e12, exposure <=2^32 remains below2^73 in f64.
// This output-only range does not change the transport or film bounds.
pub const MAX_EXPOSURE_EV: f64 = 32.0;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct DirectionalLight {
@@ -135,6 +137,43 @@ impl Lighting {
}
Ok(sum)
}
/// Sum direct lighting with independently completed RGB transmittance.
/// Ineligible lights never call `transmittance`. Each returned channel must
/// be finite in [0, 1]; errors propagate without clipping or a fallback.
/// No exposure, tone mapping, display encoding or indirect transport here.
pub(super) fn shade_linear_transmitted(
&self,
surface: OpaqueSurface,
normal: Vec3,
view: Vec3,
mut transmittance: impl FnMut(usize, Vec3) -> Result<Vec3>,
) -> Result<Vec3> {
let n = unit_direction(normal).map_err(|_| TraceError::InvalidInput("finite nonzero shading normal required"))?;
let v = unit_direction(view).map_err(|_| TraceError::InvalidInput("finite nonzero shading view required"))?;
let mut sum = Vec3::ZERO;
if n.dot(v) <= 0.0 {
return Ok(sum);
}
for (index, light) in self.lights.iter().enumerate() {
let cosine = n.dot(light.direction).max(0.0);
if cosine == 0.0 || light.intensity == 0.0 || light.color == Vec3::ZERO {
continue;
}
let transmission = transmittance(index, light.direction)?;
if !transmission.is_finite() || !channels(transmission).into_iter().all(|c| (0.0..=1.0).contains(&c)) {
return Err(TraceError::InvalidInput("direct transmittance must be finite RGB in [0, 1]"));
}
if transmission == Vec3::ZERO {
continue;
}
let brdf = surface.brdf(n, v, light.direction).map_err(|_| TraceError::InvalidInput("invalid opaque surface lighting directions"))?;
let weight = light.intensity * cosine;
let incident = Vec3::new(brdf.x * light.color.x, brdf.y * light.color.y, brdf.z * light.color.z) * weight;
sum += Vec3::new(incident.x * transmission.x, incident.y * transmission.y, incident.z * transmission.z);
validate_radiance(sum)?;
}
Ok(sum)
}
}
fn channels(v: Vec3) -> [f64; 3] {
@@ -154,7 +193,7 @@ pub(super) fn validate_radiance(radiance: Vec3) -> Result<()> {
pub fn map_to_display(radiance: Vec3, exposure_ev: f64) -> Result<Vec3> {
validate_radiance(radiance)?;
if !exposure_ev.is_finite() || !(-MAX_EXPOSURE_EV..=MAX_EXPOSURE_EV).contains(&exposure_ev) {
return Err(TraceError::InvalidInput("RT2 exposure must be finite and in [-16, 16] EV"));
return Err(TraceError::InvalidInput("RT2 exposure must be finite and in [-32, 32] EV"));
}
crate::studio::tone_map(radiance * exposure_ev.exp2()).map_err(|_| TraceError::InvalidInput("invalid exposed radiance"))
}
+164 -1
View File
@@ -108,7 +108,7 @@ fn invalid_inputs_are_refused_and_extreme_direction_scales_normalize() {
let excess_capacity = Vec::<DirectionalLight>::with_capacity(MAX_LIGHTS + 1);
assert_eq!(excess_capacity.len(), 0);
assert!(Lighting::new(excess_capacity).is_err(), "empty vectors cannot retain an unbounded light allocation");
for bad in [f64::NAN, f64::INFINITY, -16.01, 16.01] {
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -33.0, 33.0, -32.01, 32.01] {
assert!(map_to_display(Vec3::ZERO, bad).is_err());
}
for bad in [f64::NAN, f64::INFINITY, -1.0, MAX_LINEAR_RADIANCE * 2.0] {
@@ -132,3 +132,166 @@ fn original_presets_have_distinct_bounded_real_direct_responses() {
warm.shade_linear(lambert(), Vec3::Z, Vec3::Z, |_, _| Ok(true)).unwrap()
);
}
#[test]
fn exposure_full_saved_range_has_finite_binary_scaled_rational_outputs() {
// Independent powers of two make the exposed channels exactly 1, 1/2, 0.
// Do not obtain expected values from another renderer or the display mapper.
for (ev, unit) in [(17.0, 1.0 / 131_072.0), (32.0, 1.0 / 4_294_967_296.0), (-17.0, 131_072.0), (-32.0, 4_294_967_296.0)] {
let actual = map_to_display(Vec3::new(unit, unit * 0.5, 0.0), ev).unwrap();
assert!((actual - Vec3::new(0.5, 1.0 / 3.0, 0.0)).len() < 1e-14);
}
for ev in [-32.0, -17.0, 0.0, 17.0, 32.0] {
assert_eq!(map_to_display(Vec3::ZERO, ev).unwrap(), Vec3::ZERO);
let high = map_to_display(Vec3::new(MAX_LINEAR_RADIANCE, MAX_LINEAR_RADIANCE, MAX_LINEAR_RADIANCE), ev).unwrap();
assert!(high.is_finite());
assert!([high.x, high.y, high.z].into_iter().all(|v| (0.0..=1.0).contains(&v)));
}
// Expanding exposure never expands the admitted LINEAR transport bound.
assert!(map_to_display(Vec3::new(MAX_LINEAR_RADIANCE * 2.0, 0.0, 0.0), -32.0).is_err());
}
#[test]
fn transmitted_lambert_has_independent_analytic_colour_and_channel_weights() {
let light = DirectionalLight::new(Vec3::Z, Vec3::new(1.0, 0.5, 0.25), PI).unwrap();
let lighting = Lighting::new(vec![light]).unwrap();
let actual = lighting
.shade_linear_transmitted(lambert(), Vec3::Z, Vec3::Z, |index, direction| {
assert_eq!((index, direction), (0, Vec3::Z));
Ok(Vec3::new(0.5, 0.25, 0.75))
})
.unwrap();
// base(.8,.5,.25) * light(1,.5,.25) * PI/PI * T(.5,.25,.75).
assert!((actual - Vec3::new(0.4, 0.0625, 0.046875)).len() < 1e-14);
}
#[test]
fn transmitted_white_black_and_binary_visibility_preserve_boolean_shading() {
let lighting = Lighting::preset(StudioPreset::WarmCool).unwrap();
for metallic in [0.0, 0.5, 1.0] {
let surface = OpaqueSurface::new(Vec3::new(0.8, 0.3, 0.2), metallic, 0.5, 1.5).unwrap();
for selection in 0..3 {
let visible = |index: usize| match selection {
0 => false,
1 => true,
_ => index % 2 == 0,
};
let old = lighting.shade_linear(surface, Vec3::Z, Vec3::Z, |i, _| Ok(visible(i))).unwrap();
let rgb = lighting
.shade_linear_transmitted(surface, Vec3::Z, Vec3::Z, |i, _| Ok(if visible(i) { Vec3::new(1.0, 1.0, 1.0) } else { Vec3::ZERO }))
.unwrap();
assert_eq!(rgb, old);
}
}
}
#[test]
fn transmitted_two_lights_use_independent_indices_and_sum_before_output() {
let lighting = Lighting::new(vec![
DirectionalLight::new(Vec3::Z, Vec3::new(1.0, 0.5, 0.0), PI).unwrap(),
DirectionalLight::new(Vec3::Z, Vec3::new(0.5, 0.0, 1.0), 2.0 * PI).unwrap(),
])
.unwrap();
let mut visited = Vec::new();
let actual = lighting
.shade_linear_transmitted(lambert(), Vec3::Z, Vec3::Z, |index, direction| {
visited.push((index, direction));
match index {
0 => Ok(Vec3::new(0.25, 1.0, 0.5)),
1 => Ok(Vec3::new(1.0, 0.5, 0.5)),
_ => panic!("unexpected light index"),
}
})
.unwrap();
assert_eq!(visited, vec![(0, Vec3::Z), (1, Vec3::Z)]);
// Light0=(.2,.25,0), light1=(.8,0,.25); no tone mapping or exposure.
assert!((actual - Vec3::new(1.0, 0.25, 0.25)).len() < 1e-14);
}
#[test]
fn transmitted_ineligible_lights_do_not_request_visibility_or_renumber_indices() {
let white = Vec3::new(1.0, 1.0, 1.0);
let lighting = Lighting::new(vec![
DirectionalLight::new(Vec3::Z, white, 0.0).unwrap(),
DirectionalLight::new(-Vec3::Z, white, 1.0).unwrap(),
DirectionalLight::new(Vec3::Z, Vec3::ZERO, 1.0).unwrap(),
DirectionalLight::new(Vec3::X, white, 1.0).unwrap(),
DirectionalLight::new(Vec3::Z, white, PI).unwrap(),
])
.unwrap();
let mut visited = Vec::new();
let actual = lighting
.shade_linear_transmitted(lambert(), Vec3::Z, Vec3::Z, |index, direction| {
assert_eq!((index, direction), (4, Vec3::Z));
visited.push(index);
Ok(white)
})
.unwrap();
assert_eq!(visited, vec![4]);
assert!((actual - Vec3::new(0.8, 0.5, 0.25)).len() < 1e-14);
assert_eq!(lighting.shade_linear_transmitted(lambert(), Vec3::Z, -Vec3::Z, |_, _| panic!("backfacing view")).unwrap(), Vec3::ZERO);
assert_eq!(lighting.shade_linear_transmitted(lambert(), Vec3::Z, Vec3::X, |_, _| panic!("tangent view")).unwrap(), Vec3::ZERO);
assert_eq!(
Lighting::new(Vec::new()).unwrap().shade_linear_transmitted(lambert(), Vec3::Z, Vec3::Z, |_, _| panic!("no lights")).unwrap(),
Vec3::ZERO
);
}
#[test]
fn transmitted_rejects_every_invalid_channel_and_propagates_callback_errors() {
let lighting = Lighting::new(vec![DirectionalLight::new(Vec3::Z, Vec3::new(1.0, 1.0, 1.0), PI).unwrap()]).unwrap();
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -0.01, 1.01] {
for channel in 0..3 {
let transmittance = match channel {
0 => Vec3::new(bad, 0.0, 0.0),
1 => Vec3::new(0.0, bad, 0.0),
_ => Vec3::new(0.0, 0.0, bad),
};
assert!(matches!(
lighting.shade_linear_transmitted(lambert(), Vec3::Z, Vec3::Z, |_, _| Ok(transmittance)),
Err(TraceError::InvalidInput(_))
));
}
}
for error in [TraceError::Cancelled, TraceError::WorkLimit, TraceError::MemoryLimit] {
assert_eq!(lighting.shade_linear_transmitted(lambert(), Vec3::Z, Vec3::Z, |_, _| Err(error)), Err(error));
}
}
#[test]
fn transmitted_keeps_shading_direction_validation_and_scale_normalization() {
let lighting = Lighting::new(vec![DirectionalLight::new(Vec3::Z, Vec3::new(1.0, 1.0, 1.0), PI).unwrap()]).unwrap();
for bad in [Vec3::ZERO, Vec3::new(f64::NAN, 0.0, 1.0), Vec3::new(0.0, 0.0, f64::INFINITY)] {
assert!(lighting.shade_linear_transmitted(lambert(), bad, Vec3::Z, |_, _| panic!("invalid normal")).is_err());
assert!(lighting.shade_linear_transmitted(lambert(), Vec3::Z, bad, |_, _| panic!("invalid view")).is_err());
}
for magnitude in [1e-300, 1e308] {
let actual = lighting
.shade_linear_transmitted(lambert(), Vec3::new(0.0, 0.0, magnitude), Vec3::new(0.0, 0.0, magnitude), |_, _| Ok(Vec3::new(0.5, 0.25, 1.0)))
.unwrap();
assert!((actual - Vec3::new(0.4, 0.125, 0.25)).len() < 1e-14);
}
}
#[test]
fn transmitted_checks_accumulated_radiance_after_transmission_without_clamping() {
// Hostile INTERNAL fixture deliberately bypasses DirectionalLight::new's
// intensity admission. This isolates the existing accumulated-radiance gate;
// these intensities are not asserted to be accepted by the public API.
// Each unattenuated term is (.6,.375,.1875)*MAX; two exceed MAX in red.
let light = DirectionalLight { direction: Vec3::Z, color: Vec3::new(1.0, 1.0, 1.0), intensity: 0.75 * PI * MAX_LINEAR_RADIANCE };
let lighting = Lighting::new(vec![light, light]).unwrap();
let mut visits = 0;
assert!(matches!(
lighting.shade_linear_transmitted(lambert(), Vec3::Z, Vec3::Z, |_, _| {
visits += 1;
Ok(Vec3::new(1.0, 1.0, 1.0))
}),
Err(TraceError::InvalidInput(_))
));
assert_eq!(visits, 2);
let attenuated = lighting.shade_linear_transmitted(lambert(), Vec3::Z, Vec3::Z, |_, _| Ok(Vec3::new(0.5, 0.5, 0.5))).unwrap();
let expected = Vec3::new(0.6, 0.375, 0.1875) * MAX_LINEAR_RADIANCE;
assert!((attenuated - expected).len() <= MAX_LINEAR_RADIANCE * 1e-14);
assert_eq!(lighting.shade_linear_transmitted(lambert(), Vec3::Z, Vec3::Z, |_, _| Ok(Vec3::ZERO)).unwrap(), Vec3::ZERO);
}
+133 -3
View File
@@ -66,6 +66,20 @@ fn check_cancel(cancel: &CancelToken) -> Result<()> {
pub struct WorkBudget {
limit: u64,
used: u64,
protected: Option<ProtectedWork>,
}
/// One owned ledger, not another spendable budget. Keeping the policy and actual
/// counters here preserves the final report after the image task drops its film.
#[derive(Debug)]
struct ProtectedWork {
plan: crate::job::ProtectedPreviewPlan,
ordinary_used: u64,
presentation_used: u64,
presentation_limit: u64,
aggregate_limit: u64,
accepted_snapshots: u32,
next_milestone_index: u32,
}
impl WorkBudget {
@@ -73,23 +87,139 @@ impl WorkBudget {
if limit == 0 || limit > crate::job::MAX_CPU_WORK {
return Err(TraceError::InvalidInput("work limit outside 1..=MAX_WORK"));
}
Ok(Self { limit, used: 0 })
Ok(Self { limit, used: 0, protected: None })
}
pub(crate) fn new_protected(plan: crate::job::ProtectedPreviewPlan) -> Result<Self> {
plan.validate().map_err(|_| TraceError::InvalidInput("invalid protected feedback plan"))?;
let presentation_limit = plan.presentation_limit().map_err(|_| TraceError::WorkLimit)?;
let aggregate_limit = plan.aggregate_limit().map_err(|_| TraceError::WorkLimit)?;
if aggregate_limit > crate::job::MAX_PROTECTED_CPU_WORK {
return Err(TraceError::WorkLimit);
}
Ok(Self {
limit: plan.ordinary_limit(),
used: 0,
protected: Some(ProtectedWork {
plan,
ordinary_used: 0,
presentation_used: 0,
presentation_limit,
aggregate_limit,
accepted_snapshots: 0,
next_milestone_index: 0,
}),
})
}
pub fn used(&self) -> u64 {
self.used
}
/// Legacy meaning is unchanged. Protected ordinary callers retain B-O;
/// presentation and aggregate capacity have separate, explicit getters.
pub fn remaining(&self) -> u64 {
self.limit - self.used
self.ordinary_remaining()
}
pub(crate) fn ordinary_remaining(&self) -> u64 {
self.limit - self.protected.as_ref().map_or(self.used, |state| state.ordinary_used)
}
pub(crate) fn presentation_remaining(&self) -> u64 {
self.protected.as_ref().map_or(0, |state| state.presentation_limit - state.presentation_used)
}
pub(crate) fn aggregate_remaining(&self) -> u64 {
self.protected.as_ref().map_or(self.limit, |state| state.aggregate_limit) - self.used
}
pub(crate) fn protected_report(&self) -> Option<crate::job::ProtectedPreviewReport> {
let state = self.protected.as_ref()?;
let quota_limit = state.plan.quota_limit();
Some(crate::job::ProtectedPreviewReport {
policy_version: 1,
ordinary_limit: self.limit,
presentation_limit: state.presentation_limit,
aggregate_limit: state.aggregate_limit,
ordinary_used: state.ordinary_used,
presentation_used: state.presentation_used,
accepted_snapshots: state.accepted_snapshots,
quota_limit,
next_milestone_index: state.next_milestone_index,
quota_exhausted: state.accepted_snapshots == quota_limit,
milestones_complete: state.next_milestone_index == quota_limit,
})
}
/// Read-only eligibility at a real committed camera frontier. Source progress
/// is not a work credit: it selects a milestone, never reserves ordinary work.
fn protected_ready(&self, committed: u64) -> Result<bool> {
let state = self.protected.as_ref().ok_or(TraceError::InvalidInput("protected feedback policy missing"))?;
if committed >= state.plan.total_commits()
|| state.accepted_snapshots >= state.plan.quota_limit()
|| state.next_milestone_index >= state.plan.quota_limit()
{
return Ok(false);
}
let milestone = state.plan.milestone(state.next_milestone_index).map_err(|_| TraceError::Internal("invalid protected milestone"))?;
let cost = state.plan.snapshot_work().map_err(|_| TraceError::WorkLimit)?;
Ok(committed >= milestone && self.presentation_remaining() >= cost && self.aggregate_remaining() >= cost)
}
/// Debit ONE W and coalesce all reached milestones in the same transaction.
/// A wait/refusal changes no counters. Cancellation after a debit is not a refund.
fn charge_presentation(&mut self, committed: u64, cancel: &CancelToken) -> Result<bool> {
check_cancel(cancel)?;
if !self.protected_ready(committed)? {
return Ok(false);
}
let state = self.protected.as_ref().ok_or(TraceError::InvalidInput("protected feedback policy missing"))?;
let cost = state.plan.snapshot_work().map_err(|_| TraceError::WorkLimit)?;
let presentation_used = state.presentation_used.checked_add(cost).filter(|v| *v <= state.presentation_limit).ok_or(TraceError::WorkLimit)?;
let used = self.used.checked_add(cost).filter(|v| *v <= state.aggregate_limit).ok_or(TraceError::WorkLimit)?;
let accepted = state.accepted_snapshots.checked_add(1).filter(|n| *n <= state.plan.quota_limit()).ok_or(TraceError::WorkLimit)?;
let next = state.plan.reached_milestones(committed).map_err(|_| TraceError::Internal("invalid protected reached frontier"))?;
check_cancel(cancel)?;
let state = self.protected.as_mut().ok_or(TraceError::Internal("protected ledger disappeared"))?;
state.presentation_used = presentation_used;
state.accepted_snapshots = accepted;
state.next_milestone_index = next;
self.used = used;
Ok(true)
}
fn tick(&mut self, cancel: &CancelToken) -> Result<()> {
self.charge(1, cancel)
}
fn charge(&mut self, units: u64, cancel: &CancelToken) -> Result<()> {
check_cancel(cancel)?;
self.used = self.used.checked_add(units).filter(|used| *used <= self.limit).ok_or(TraceError::WorkLimit)?;
if let Some(state) = &mut self.protected {
let ordinary = state.ordinary_used.checked_add(units).filter(|v| *v <= self.limit).ok_or(TraceError::WorkLimit)?;
let used = self.used.checked_add(units).filter(|v| *v <= state.aggregate_limit).ok_or(TraceError::WorkLimit)?;
state.ordinary_used = ordinary;
self.used = used;
} else {
self.used = self.used.checked_add(units).filter(|used| *used <= self.limit).ok_or(TraceError::WorkLimit)?;
}
Ok(())
}
/// Private test boundary only: the ordinary allowance is fixed before any
/// real task work, preserving Q and the same O/V ledger. No production caller.
#[cfg(test)]
fn new_protected_for_test(plan: crate::job::ProtectedPreviewPlan, ordinary: u64) -> Result<Self> {
let mut work = Self::new_protected(plan)?;
if ordinary == 0 || ordinary > work.limit {
return Err(TraceError::InvalidInput("test ordinary limit outside profile"));
}
let state = work.protected.as_mut().ok_or(TraceError::Internal("test protected policy missing"))?;
state.aggregate_limit =
ordinary.checked_add(state.presentation_limit).filter(|v| *v <= crate::job::MAX_PROTECTED_CPU_WORK).ok_or(TraceError::WorkLimit)?;
work.limit = ordinary;
Ok(work)
}
}
fn bounded(point: Vec3) -> bool {
File diff suppressed because it is too large Load Diff
+67
View File
@@ -8,6 +8,73 @@ pub(super) struct Progressive {
pub options: CpuProgressiveOptions,
pub resolving: bool,
pub resolve_pixel: usize,
pub partial_plan: Option<crate::job::PartialPreviewPlan>,
pub partial: Option<PartialResolve>,
pub last_partial: Option<crate::film::primary::PartialFrontier>,
}
pub(super) struct PartialResolve {
pub plan: crate::job::PartialPreviewPlan,
pub frontier: crate::film::primary::PartialFrontier,
pub resume_pixel: usize,
pub next: usize,
pub covered: Vec<u32>,
}
/// Read-only O(1) test for new represented data; no scan over the source film.
/// A completed global pass is meaningful even if its last source pixels are
/// not representatives: the receiver's global Unknown authority changes then.
pub(super) fn represented_changed(
plan: crate::job::PartialPreviewPlan,
before: Option<crate::film::primary::PartialFrontier>,
now: crate::film::primary::PartialFrontier,
) -> crate::job::Result<bool> {
use crate::job::{ErrorCode, JobError};
plan.validate()?;
let source_pixels = u64::from(plan.source_width()) * u64::from(plan.source_height());
if u64::try_from(now.pixels).ok() != Some(source_pixels) || now.updated_prefix >= now.pixels {
return Err(JobError::new(ErrorCode::InvalidScene, "partial film frontier differs from its source plan"));
}
let Some(before) = before else { return Ok(now.completed_passes != 0 || now.updated_prefix != 0) };
if before.pixels != now.pixels
|| before.completed_passes > now.completed_passes
|| (before.completed_passes == now.completed_passes && before.updated_prefix > now.updated_prefix)
{
return Err(JobError::new(ErrorCode::InvalidScene, "partial film frontier went backwards"));
}
if before.completed_passes != now.completed_passes {
return Ok(true);
}
if before.updated_prefix == now.updated_prefix {
return Ok(false);
}
let w = u64::from(plan.source_width());
let h = u64::from(plan.source_height());
let pw = u64::from(plan.width());
let ph = u64::from(plan.height());
let lower = u64::try_from(before.updated_prefix).map_err(|_| JobError::new(ErrorCode::LimitExceeded, "partial source index overflow"))?;
let x = lower % w;
let y = lower / w;
let mut v = y.checked_mul(ph).ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "partial row mapping overflow"))?.div_ceil(h);
if v >= ph {
return Ok(false);
}
let mut u = if v * h / ph == y {
x.checked_mul(pw).ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "partial column mapping overflow"))?.div_ceil(w)
} else {
0
};
if u >= pw {
v += 1;
u = 0;
}
if v >= ph {
return Ok(false);
}
let display =
v.checked_mul(pw).and_then(|n| n.checked_add(u)).ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "partial display index overflow"))?;
let display = usize::try_from(display).map_err(|_| JobError::new(ErrorCode::LimitExceeded, "partial display address overflow"))?;
Ok(plan.source_index(display)? < now.updated_prefix)
}
/// Integer-only mixing before conversion to exactly representable 53-bit unit
@@ -44,6 +44,10 @@ fn task(blocker: Option<(f64, f64)>, shadows: bool) -> ImageTask {
..Default::default()
};
let request = ImageRequest {
raster_punctual_lights: None,
raster_area_lights: None,
raster_exposure_ev: 0.0,
raster_lights: None,
studio_weights: None,
environment: None,
textures: None,
@@ -90,12 +94,12 @@ fn actual_spot_consumer_uses_emitted_world_axis_and_cone() {
let point = render(task(None, true));
for (axis, expected) in [([-20.0, 0.0, -10.0], point), ([20.0, 0.0, 10.0], [0, 0, 0, 255])] {
let previous = task(None, true);
let mut options = previous.options.clone();
let mut options = previous.visibility_owner.options().clone();
options.punctual_lights.as_mut().unwrap()[0].cone =
Some(job::SpotConeOptions { emitted_axis: axis, inner_half_angle_radians: 0.2, outer_half_angle_radians: 0.5 });
let mut request = previous.request.clone();
request.raytrace = Some(options.clone());
let actual = ImageTask::new(previous.scene.clone(), request, CancelToken::default(), options).unwrap();
let actual = ImageTask::new(previous.visibility_owner.source().clone(), request, CancelToken::default(), options).unwrap();
assert_eq!(render(actual), expected);
}
}
@@ -136,7 +140,7 @@ fn punctual_work_exhaustion_is_shared_and_task_metadata_remains_bounded() {
fn mixed(n: u32, shadows: bool) -> ImageTask {
let previous = task(None, shadows);
let mut options = previous.options.clone();
let mut options = previous.visibility_owner.options().clone();
options.lights = Some(vec![job::DirectionalLightOptions { direction: [0.0, 0.0, 1.0], color: [1.0; 3], intensity: 1.0 }; 8]);
options.punctual_lights = Some(vec![options.punctual_lights.as_ref().unwrap()[0]; 8]);
options.area_lights = Some(vec![
@@ -152,7 +156,7 @@ fn mixed(n: u32, shadows: bool) -> ImageTask {
options.area_samples = Some(n);
let mut request = previous.request.clone();
request.raytrace = Some(options.clone());
ImageTask::new(previous.scene.clone(), request, CancelToken::default(), options).unwrap()
ImageTask::new(previous.visibility_owner.source().clone(), request, CancelToken::default(), options).unwrap()
}
#[test]
@@ -227,7 +231,7 @@ fn mixed_area_final_shadow_cancellation_and_shared_exhaustion_prevent_publicatio
#[test]
fn mixed_normal_incidence_radiance_is_summed_before_single_display_transform() {
let mut task = mixed(1, false);
let options = &mut task.options;
let (fixture_scene, mut options) = task.visibility_owner.into_capture();
for point in options.punctual_lights.as_mut().unwrap() {
point.position_mm = [0.0, 0.0, 10.0];
point.color = [1.0; 3];
@@ -237,6 +241,7 @@ fn mixed_normal_incidence_radiance_is_summed_before_single_display_transform() {
area.color = [1.0; 3];
}
task.request.raytrace = Some(options.clone());
task.visibility_owner = dielectric::DirectVisibilityOwner::new(fixture_scene, options);
// White rough conductor at normal incidence: f*cos=1/(4*pi).
// Eight unit directionals + eight unit point sources + four Le*A/r²=.08 rectangles.
let linear = (8.0 + 8.0 + 4.0 * 0.08) / (4.0 * std::f64::consts::PI);
@@ -248,14 +253,14 @@ fn mixed_normal_incidence_radiance_is_summed_before_single_display_transform() {
fn scene_dependent_punctual_singularities_complete_and_keep_diagnostic_counts() {
for (height, reference, near_source) in [(0.0, 10.0, true), (0.0009, 10.0, true), (0.001, 1e6, false)] {
let previous = task(None, true);
let mut options = previous.options.clone();
let mut options = previous.visibility_owner.options().clone();
let light = &mut options.punctual_lights.as_mut().unwrap()[0];
light.position_mm = [0.0, 0.0, height];
light.reference_distance_mm = reference;
light.intensity = 64.0;
let mut request = previous.request.clone();
request.raytrace = Some(options.clone());
let mut work = ImageTask::new(previous.scene.clone(), request, CancelToken::default(), options).unwrap();
let mut work = ImageTask::new(previous.visibility_owner.source().clone(), request, CancelToken::default(), options).unwrap();
let mut completed = false;
for _ in 0..100_000 {
if let TaskPoll::Complete(png) = work.step(1).unwrap() {
@@ -183,6 +183,10 @@ fn request(reflections: Option<ReflectionOptions>) -> ImageRequest {
let mut camera = crate::Camera { distance: 50.0, ..Default::default() };
camera.set_view(crate::StandardView::Front);
ImageRequest {
raster_punctual_lights: None,
raster_area_lights: None,
raster_exposure_ev: 0.0,
raster_lights: None,
studio_weights: None,
environment: None,
textures: None,

Some files were not shown because too many files have changed in this diff Show More