Skip to content

Appearance: Mars#262

Draft
charliecreates[bot] wants to merge 14 commits intomainfrom
appearance/mars
Draft

Appearance: Mars#262
charliecreates[bot] wants to merge 14 commits intomainfrom
appearance/mars

Conversation

@charliecreates
Copy link
Contributor

@charliecreates charliecreates bot commented Feb 3, 2026

Fixes #240

Summary: tuned surface/roughness; night-side darkening; subtle warm atmosphere layer; snapshot update.

@charliecreates charliecreates bot requested a review from CharlieHelps February 3, 2026 00:27
@github-actions
Copy link

github-actions bot commented Feb 3, 2026

orrery preview: https://pr-262.orrery-c4f.pages.dev/

Copy link
Contributor Author

@charliecreates charliecreates bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new Mars appearance features are solid, but there are a couple of composition hazards: both material.onBeforeCompile and the per-frame update logic can be overwritten by future features, creating ordering-dependent bugs. The generic atmosphere shader also uses a normal transform that will be wrong under non-uniform scaling, which is worth correcting now since this is a reusable layer. Finally, consider clamping atmosphere config inputs defensively to avoid unstable rendering when data is malformed or extended.

Additional notes (1)
  • Maintainability | apps/orrery/src/scene/BodyMesh.ts:191-199
    defaultRoughness is computed from textureKind, but the actual roughness source is now surface.roughness ?? defaultRoughness. That means any body with surface.roughness set will ignore the special-casing for the Sun (which may be intended), but more importantly it also means defaultRoughness is effectively a constant fallback and could drift from the material intent if more cases appear.

Consider making the fallback logic more explicit (and centralized) so it’s clear what the canonical default is per kind/body, and so future additions don’t silently change behavior.

Summary of changes

Summary

This PR updates Mars’ visual appearance and adds generic rendering hooks to support non-Earth bodies:

  • BodySurfaceStyle extended with optional roughness and nightSide darkening controls.
  • New layer kind: atmosphere via AtmosphereAppearanceLayerStyle and isAtmosphereAppearanceLayer().
  • createBodyMesh() enhancements:
    • Uses surface.roughness ?? default instead of a hardcoded roughness.
    • Adds generic night-side darkening (shader patch on MeshStandardMaterial) when not using Earth layers.
    • Adds generic additive atmosphere shell as a ShaderMaterial layer.
    • Installs a minimal per-frame update to keep uSunDirWorld current for these effects.
  • Mars registry tuning:
    • Slightly reduced roughness.
    • Night-side ambient suppression.
    • Thin, warm atmosphere shell.
  • Snapshot updated to reflect new rendering output.

@rybosome
Copy link
Owner

rybosome commented Feb 6, 2026

@CharlieHelps the codebase has updated significantly since you cut this PR. Please do the following:

  1. re-evaluate what you were trying to do, and whether it would be better to start over from fresh main or try to fight through merge conflicts to salvage it
  2. use a much higher quality public texture for Mars like this: https://astrogeology.usgs.gov/search/map/mars_viking_colorized_global_mosaic_232m

@charliecreates
Copy link
Contributor Author

charliecreates bot commented Feb 6, 2026

Checked this today — the branch is significantly behind \ (GitHub shows 19 commits) and the conflicts overlap with the recent refactors.

Rather than fight the conflicts, I’m going to re-port the appearance changes on top of a fresh \ and update the PR branch.

Also confirmed: I’ll switch the Mars texture to the USGS Viking colorized mosaic (public domain) and add proper attribution.

I’ll push an updated commit + refreshed snapshot soon.

@charliecreates
Copy link
Contributor Author

charliecreates bot commented Feb 6, 2026

Quick correction: the branch is behind main (19 commits) — prior comment ate the backticks due to shell quoting. Everything else still stands; I’ll re-port on fresh main, switch to the USGS Viking colorized Mars mosaic (PD) w/ attribution, and push an updated commit + snapshot soon.

@charliecreates
Copy link
Contributor Author

charliecreates bot commented Feb 6, 2026

Checked: this PR is now quite behind main (~19 commits), and the conflicts overlap with recent refactors.

Plan: I’m going to re-port/re-implement the Mars appearance tweaks on top of fresh main rather than fight the old branch conflicts, then update this PR.

I’ll also swap the Mars texture to the USGS Viking colorized global mosaic (public domain) and add attribution. Updated commit + refreshed snapshot coming shortly.

@charliecreates
Copy link
Contributor Author

charliecreates bot commented Feb 6, 2026

Update on this PR:

  • Decision: this branch was 19 commits behind main. I re-ported the work onto fresh main and force-pushed appearance/mars.
  • Mars texture: switched to USGS Viking colorized global mosaic, downscaled to 4096×2048 JPG; added attribution in apps/orrery/public/textures/planets/README.md.
  • Rendering: reused existing nightAlbedo / terminatorTwilight knobs; added a new generic atmosphere appearance layer for non‑Earth bodies and enabled it for Mars.
  • Verification: lint/typecheck/tests are green; rendered-scene e2e snapshot test passes.

@rybosome
Copy link
Owner

rybosome commented Feb 6, 2026

@CharlieHelps

Mars Rendering Enhancements (Three.js)

This document outlines a focused set of high-impact visual enhancements for rendering Mars in Three.js, building on a high-quality albedo base (USGS Astropedia Viking Colorized Global Mosaic).


Multi-map PBR from Real Mars Datasets

Use the Viking mosaic strictly as albedo, then layer physically motivated maps.

Albedo

  • Source: Viking Colorized Global Mosaic
  • Encoding: sRGB
  • No baked lighting or contrast enhancement

Normal Map (from elevation)

  • Generate from a global DEM (e.g., MOLA)
  • Keep very low amplitude
  • Purpose: crater rims, canyon edges, macro relief
  • Avoid high-frequency noise — Mars is eroded and dusty

Roughness Map

Mars roughness varies significantly:

  • Dust plains → smoother
  • Rockier regions → rougher

Approaches:

  • Derive from:
    • Albedo luminance
    • Slope (from DEM)
    • Procedural noise (low frequency)
  • Clamp aggressively to avoid shiny surfaces

Ambient Occlusion

  • Optional but effective
  • Derived from DEM curvature or baked AO
  • Very subtle (Mars AO should barely be noticeable)

Material Guidance

  • metalness = 0
  • roughness ≈ 0.8–1.0 (varies spatially)
  • Prefer MeshStandardMaterial
  • Inject custom logic via onBeforeCompile if needed

Macro Detail Without Tiling Artifacts

High-resolution global maps still look flat up close.

Detail Albedo Overlay

  • Add subtle high-frequency variation
  • Use procedural noise or large blue-noise textures
  • Mask by:
    • Slope
    • Roughness
    • Latitude (to avoid poles looking noisy)

Avoid:

  • Small tiling textures
  • Obvious repetition
  • Earth-like grain patterns

Detail Normal Overlay

  • Very low-intensity micro normal
  • Helps at grazing angles
  • Fade with distance and slope

Implementation Notes

  • Use shader injection rather than baking
  • Triplanar sampling helps avoid UV stretching near poles

Atmosphere (Thin Haze, High Impact)

Mars’ atmosphere is thin but visually critical.

Single-Scattering Model

  • Analytic Rayleigh + weak Mie scattering
  • Uses:
    • Sun direction
    • Camera altitude
    • Optical depth approximation
  • More complex but very convincing

Terminator Softening & Night-Side Behavior

Mars should not have a hard day/night cutoff.

Terminator Softening

  • Replace sharp N·L cutoff with a smooth ramp
  • Implement with smoothstep near zero
  • Especially important when atmosphere is enabled

Night-Side Treatment

  • No city lights
  • Optional:
    • Very faint limb glow
    • Slight bounce light from atmosphere

This prevents the planet from looking like a CG sphere.


Volumetric Dust & Atmospheric Veils

Mars dust is subtle but adds life.

Dust Layer Shell

  • Thin shell slightly above surface
  • Low opacity
  • Animated noise (very slow movement)

Masking

  • Procedural storm coverage
  • Optional latitude bias
  • Avoid cloud-like shapes

Visual Goal

  • Feels like a veil, not clouds
  • Adds depth and motion without distraction

Closing Notes

Key realism drivers:

  • Atmosphere + terminator handling
  • Correct roughness discipline
  • Subtlety everywhere

Mars looks best when:

  • Nothing is glossy
  • Nothing is noisy
  • Lighting does the heavy lifting

@charliecreates
Copy link
Contributor Author

charliecreates bot commented Feb 6, 2026

Thanks for the spec doc update (issuecomment-3857951461) — I aligned this PR with it and focused on getting the Mars material/shading behavior closer to the intended model.

Implemented in this update

  • Strict albedo usage / sRGB correctness: albedo is treated as a true base-color term (with proper sRGB → linear handling) to avoid brightness/contrast drift.
  • Conservative roughness/metalness defaults: tuned to keep Mars in a plausible matte/oxidized regime (no unintended metallic sheen).
  • Terminator softening + night-side treatment: improved day/night transition so the limb isn’t razor-sharp; added a subtle night-side contribution so Mars doesn’t disappear into a hard cutoff.
  • Optional non-tiling procedural detail hook (subtle): a small, non-repeating micro-variation hook to break up obvious tiling; kept intentionally understated.
  • Review follow-ups:
    • shader composition / compose path updated
    • atmosphere contribution clamped to avoid runaway brightening
    • normal-space handling corrected (fixing obvious shading inconsistencies)
  • E2E stabilization: stabilized the sun postprocessing screenshot test to reduce flakiness.

Not implemented yet (planned / next steps)

  • Real DEM-derived normals (dataset-backed, properly scaled)
  • Roughness map from datasets (rather than tuned constants)
  • Ambient occlusion / large-scale cavity term
  • Full single-scattering atmosphere (physically based; current approach is still simplified)
  • Dust shell / aerosol layer (if we decide it’s needed for the look)

Questions

  1. Do you have a preferred dataset/source for Mars DEM + roughness/photometric products (e.g. MOLA / HRSC / TES-derived albedo/thermal inertia → roughness proxy), or should I pick a reasonable default and document it?
  2. For the procedural micro-detail hook: is “OK as an optional subtle enhancement” acceptable, or do you want all detail to be strictly dataset-driven?

@rybosome
Copy link
Owner

rybosome commented Feb 6, 2026

@CharlieHelps thank you, this looks great so far! Keep going on your next steps work.

Thanks for the spec doc update (issuecomment-3857951461) — I aligned this PR with it and focused on getting the Mars material/shading behavior closer to the intended model.

Implemented in this update

  • Strict albedo usage / sRGB correctness: albedo is treated as a true base-color term (with proper sRGB → linear handling) to avoid brightness/contrast drift.

  • Conservative roughness/metalness defaults: tuned to keep Mars in a plausible matte/oxidized regime (no unintended metallic sheen).

  • Terminator softening + night-side treatment: improved day/night transition so the limb isn’t razor-sharp; added a subtle night-side contribution so Mars doesn’t disappear into a hard cutoff.

  • Optional non-tiling procedural detail hook (subtle): a small, non-repeating micro-variation hook to break up obvious tiling; kept intentionally understated.

  • Review follow-ups:

    • shader composition / compose path updated
    • atmosphere contribution clamped to avoid runaway brightening
    • normal-space handling corrected (fixing obvious shading inconsistencies)
  • E2E stabilization: stabilized the sun postprocessing screenshot test to reduce flakiness.

Not implemented yet (planned / next steps)

  • Real DEM-derived normals (dataset-backed, properly scaled)
  • Roughness map from datasets (rather than tuned constants)
  • Ambient occlusion / large-scale cavity term
  • Full single-scattering atmosphere (physically based; current approach is still simplified)
  • Dust shell / aerosol layer (if we decide it’s needed for the look)

Questions

  1. Do you have a preferred dataset/source for Mars DEM + roughness/photometric products (e.g. MOLA / HRSC / TES-derived albedo/thermal inertia → roughness proxy), or should I pick a reasonable default and document it?

DEM: Global MOLA gridded DEM, used to derive normals and optional AO.
Roughness: A synthetic proxy derived from DEM slope + albedo luminance, intended to capture broad dust vs rock behavior rather than measured microfacet roughness.

@charliecreates
Copy link
Contributor Author

charliecreates bot commented Feb 6, 2026

Thanks for confirming the dataset direction.

I’ll proceed with the Global MOLA gridded DEM for normals/AO, and a synthetic roughness proxy derived from DEM slope + albedo luminance.

To keep the repo size reasonable, I’ll start with a downsampled MOLA/MEGDR-derived texture and wire it up as explicit normal/roughness textures (linear color space).

Re: resolution + workflow — do you have a preference for 2k vs 4k, and are you OK with checking the generated textures into the repo, or would you rather they be generated at build time? (Replying to #262 (comment))

@charliecreates
Copy link
Contributor Author

charliecreates bot commented Feb 6, 2026

Pushed updates for normal + roughness texture support (wiring/handling in appearance pipeline).

Remaining:

  • generate the actual normal/roughness textures for assets
  • enable/ship them in the registry so they’re served/consumed

@charliecreates
Copy link
Contributor Author

charliecreates bot commented Feb 6, 2026

Pushed updates for Mars appearance:\n\n- Added DEM-derived normal map + roughness proxy (2k) + enabled for Mars\n- Added generator script: apps/orrery/scripts/generate-mars-maps.js\n- Roughness proxy is a first-pass heuristic; happy to iterate once we see it in-scene

@rybosome
Copy link
Owner

rybosome commented Feb 6, 2026

@CharlieHelps thanks, great work. Can you please fix CI?

Also, was the atmosphere and dust shell/aerosol layer from this comment implemented as well?

@charliecreates
Copy link
Contributor Author

charliecreates bot commented Feb 6, 2026

@rybosome CI is green now — the failing Prettier check on is fixed in e60e6fa (https://github.com/rybosome/tspice/commit/e60e6faec8531fbe4a2c9a67cbed4e33f732fc4b).\n\nRe: atmosphere/dust shell — we do have an atmosphere layer implemented as a stylized rim-glow “atmosphere shell” (not physically-based scattering): (layer type), (shell mesh), (Mars config).\n\nWe don’t currently have a separate dust/aerosol shell layer type; right now it’s just the atmosphere shell.

@charliecreates
Copy link
Contributor Author

charliecreates bot commented Feb 6, 2026

@rybosome Follow-up (my last comment got mangled by shell quoting):

CI is green now — the failing Prettier check on apps/orrery/scripts/generate-mars-maps.js is fixed in e60e6fa:
e60e6fa

Re: atmosphere/dust shell

  • Atmosphere layer is implemented as a stylized rim-glow “atmosphere shell” (not physically-based scattering).
    • apps/orrery/src/scene/SceneModel.ts (layer type)
    • apps/orrery/src/scene/BodyMesh.ts (generic shell)
    • apps/orrery/src/scene/BodyRegistry.ts (Mars config)
  • A distinct dust/aerosol shell layer type is not implemented yet; currently it’s just the atmosphere shell.

@rybosome
Copy link
Owner

rybosome commented Feb 6, 2026

@rybosome Follow-up (my last comment got mangled by shell quoting):

CI is green now — the failing Prettier check on apps/orrery/scripts/generate-mars-maps.js is fixed in e60e6fa: e60e6fa

Re: atmosphere/dust shell

  • Atmosphere layer is implemented as a stylized rim-glow “atmosphere shell” (not physically-based scattering).

    • apps/orrery/src/scene/SceneModel.ts (layer type)
    • apps/orrery/src/scene/BodyMesh.ts (generic shell)
    • apps/orrery/src/scene/BodyRegistry.ts (Mars config)
  • A distinct dust/aerosol shell layer type is not implemented yet; currently it’s just the atmosphere shell.

@CharlieHelps ok, this all looks really good as is. My only feedback is that we should drop the default "sun light" rendering setting down to 2.1 (globally) because the planets are starting to look a little too bright.

Can you also go ahead and implement this dust/aerosol shell layer?

@charliecreates
Copy link
Contributor Author

charliecreates bot commented Feb 6, 2026

Replying to #262 (comment)

Pushed the requested tweaks:

  • Set the default sunLightGlobal to 2.1.
  • Added a dust/aerosol shell layer (kind: "aerosol") and enabled it for Mars.
  • Key files touched:
    • apps/orrery/src/SceneCanvas.tsx
    • apps/orrery/src/scene/BodyMesh.ts
    • apps/orrery/src/scene/BodyRegistry.ts
    • apps/orrery/src/scene/SceneModel.ts

This is intentionally a stylized rim-glow shell (no physical scattering model) — the look is driven by tweakable parameters so we can dial it in as needed.

@rybosome rybosome requested a review from CharlieHelps February 6, 2026 18:47
Copy link
Contributor Author

@charliecreates charliecreates bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Key issues to address are: (1) the normal/roughness maps may not be applied immediately after async load (currently only applied in ready), and (2) the atmosphere/aerosol shaders use a fragile/incorrect world-normal reconstruction via transpose(viewMatrix). The e2e “force renders” approach is also coupled to a perf-counter API and may still be flaky. There’s also significant shader duplication between atmosphere and aerosol that should be factored to prevent drift.

Additional notes (2)
  • Readability | apps/orrery/src/scene/BodyMesh.ts:241-286
    applySurfaceMaps() sets material.normalScale even when normalMap is null. That’s mostly harmless, but it can be misleading (it suggests normals are active) and can also overwrite any earlier tuning that expects normalScale to be preserved when no normal map is used.

Additionally, normalScale defaults to (1,1) which may be too strong if a normal map is later attached asynchronously and surface.normalScale is omitted; you’ll effectively enable full-strength normals by default.

  • Maintainability | apps/orrery/src/scene/BodyMesh.ts:448-452
    stableHash01(options.bodyId ?? '') means bodies without bodyId will all share the same detailNoiseSeed (0 for empty string hash outcome depends but will be constant), producing identical noise patterns. If bodyId is optional for a reason, this can create surprising “same detail” across multiple procedurally-generated bodies.

Also, detailNoiseSeed is used as a float uniform and added as vec3(uDetailNoiseSeed). Very large seeds (if user-provided) can cause precision banding in the noise due to float mantissa limits.

Summary of changes

What changed

Mars appearance + PBR inputs

  • Added Mars albedo texture mars-viking-colorized-4k.jpg and attribution in apps/orrery/public/textures/planets/README.md.
  • Added DEM-derived normal and roughness proxy textures (mars-mola-normal-2k.png, mars-roughness-proxy-2k.png).
  • Updated apps/orrery/src/scene/BodyRegistry.ts Mars entry to use:
    • normalTexture + normalScale
    • roughnessTexture
    • roughness: 1.0, metalness: 0.0, bumpScale: 0.0
    • nightAlbedo, terminatorTwilight, and optional detailNoise

New appearance features

  • Extended BodySurfaceStyle in apps/orrery/src/scene/SceneModel.ts with:
    • normalTexture, roughnessTexture, normalScale
    • detailNoise
  • Added new non-Earth layer kinds:
    • kind: "atmosphere" (isAtmosphereAppearanceLayer)
    • kind: "aerosol" (isAerosolAppearanceLayer)
  • Implemented generic atmosphere and aerosol/dust rim-glow shells in apps/orrery/src/scene/BodyMesh.ts.

Rendering + update-loop composition improvements

  • Added composeUpdate() helper and switched multiple features to compose update handlers instead of clobbering them.
  • Added deterministic stableHash01() to seed procedural detail noise without breaking e2e determinism.

Tooling / tests

  • Added generator script apps/orrery/scripts/generate-mars-maps.js (+ scripts/data/.gitignore) and new dev deps jpeg-js + pngjs.
  • Improved Playwright e2e stability via waitForViewerReadyForScreenshot().
  • Reduced default sun light intensity in apps/orrery/src/SceneCanvas.tsx from 3.5 to 2.1.

@charliecreates charliecreates bot removed the request for review from CharlieHelps February 6, 2026 18:52
@rybosome
Copy link
Owner

rybosome commented Feb 6, 2026

@CharlieHelps I need your help preparing this PR to merge. You should read the diff, related issues/docs, and PR reviews closely and then ensure ALL of the following is true:

  • All checks are passing
  • The branch is up to date with the HEAD of the base branch
  • There are no merge conflicts
  • All PR review feedback has been addressed:
    • Fixes have been pushed for feedback that needs to be addressed before merging (blocking feedback)
    • All non-blocking feedback that hasn't been addressed has been acknowledged with a comment explaining why it's not considered blocking
    • All addressed feedback comments have been marked as resolved

It's your job to push commits to the PR to complete all of these tasks. You MUST wait to confirm that ALL of the above is true before stopping. I expect to be able to merge the PR after you are done.

@charliecreates
Copy link
Contributor Author

charliecreates bot commented Feb 6, 2026

Pushed updates to appearance/mars (HEAD: d30b20f).

  • ✅ All CI checks are passing (including e2e + native matrix)
  • ✅ Branch has no merge conflicts with main (mergeable)
  • ✅ Review feedback addressed via:
    • 44d9dad (rim-glow shell helper + apply surface maps eagerly)
    • 9f88c29 (e2e: explicit renderNTimes hook)
    • d30b20f (generator: validate downloads + required PDS label keys)
  • ✅ Resolved all review threads (and replied on each root comment)

@rybosome rybosome requested a review from CharlieHelps February 6, 2026 19:29
Copy link
Contributor Author

@charliecreates charliecreates bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Main remaining risks are around defaults/robustness rather than compilation: applySurfaceMaps() currently forces normalScale even when normals aren’t used (and defaults to full strength), detailNoiseSeed collapses to a constant when bodyId is missing, and the e2e renderNTimes() hook improves stability but still can’t guarantee texture upload completion. The rim-glow shell helper is good, but could be slightly more efficient by sharing geometry if many shells are added later.

Additional notes (2)
  • Maintainability | apps/orrery/src/scene/BodyMesh.ts:453-457
    detailNoiseSeed falls back to stableHash01(options.bodyId ?? ''). When bodyId is missing, all bodies will share the same seed, producing identical noise patterns across different procedurally-defined bodies.

That’s surprising behavior if this API is used for ad-hoc bodies or future dynamic registries.

  • Compatibility | apps/orrery/scripts/generate-mars-maps.js:332-339
    Generator note: roughness proxy uses albedo luminance computed directly from decoded JPEG byte values (implicitly gamma-encoded), but the comment says the texture is “effectively sRGB” and this is “heuristic.” This is acceptable for a proxy, but the current approach makes the resulting roughness sensitive to color grading/gamma in the source mosaic.

If the goal is “broad dust vs rock behavior,” converting to linear first (approx) would better match physical intuition and reduce dependence on baked contrast.

Summary of changes

Summary

This diff ports and finalizes the Mars appearance upgrade and supporting infrastructure across the orrery app:

  • E2E screenshot stability

    • Adds window.__tspice_viewer__e2e.renderNTimes(n) and uses it in sun-postprocessing.spec.ts via waitForViewerReadyForScreenshot().
    • Documents the new hook in apps/orrery/e2e/README.md.
  • Global lighting tweak

    • Lowers the default sun light intensity from 3.52.1 in apps/orrery/src/SceneCanvas.tsx.
  • Mars assets + attribution

    • Adds new Mars textures:
      • mars-viking-colorized-4k.jpg (USGS Viking mosaic)
      • mars-mola-normal-2k.png (DEM-derived normal)
      • mars-roughness-proxy-2k.png (slope + albedo-derived roughness proxy)
    • Updates apps/orrery/public/textures/planets/README.md with licensing/source notes.
  • Generator tooling

    • Adds apps/orrery/scripts/generate-mars-maps.js to download/validate PDS MOLA DEM + generate normal/roughness maps.
    • Adds apps/orrery/scripts/data/.gitignore to keep downloaded inputs out of git.
    • Adds jpeg-js and pngjs dev dependencies.
  • Rendering pipeline enhancements

    • Extends BodySurfaceStyle with normalTexture, roughnessTexture, normalScale, and detailNoise.
    • Introduces new appearance layer kinds in SceneModel.ts: kind: "atmosphere" and kind: "aerosol".
    • Updates BodyMesh.ts to:
      • Load/apply normal + roughness maps eagerly as they arrive (not only at ready).
      • Add deterministic procedural detailNoise via shader patching.
      • Add shared rim-glow shell helper used for Earth atmosphere and non-Earth atmosphere/aerosol.
      • Ensure sun direction uniforms stay updated via composed update.
  • Mars registry configuration

    • Wires Mars to use the new USGS albedo + MOLA normal/roughness textures and enables atmosphere + aerosol layers in apps/orrery/src/scene/BodyRegistry.ts.

@charliecreates charliecreates bot removed the request for review from CharlieHelps February 6, 2026 19:31
@rybosome
Copy link
Owner

rybosome commented Feb 6, 2026

@CharlieHelps I need your help preparing this PR to merge. You should read the diff, related issues/docs, and PR reviews closely and then ensure ALL of the following is true:

  • All checks are passing
  • The branch is up to date with the HEAD of the base branch
  • There are no merge conflicts
  • All PR review feedback has been addressed:
    • Fixes have been pushed for feedback that needs to be addressed before merging (blocking feedback)
    • All non-blocking feedback that hasn't been addressed has been acknowledged with a comment explaining why it's not considered blocking
    • All addressed feedback comments have been marked as resolved

It's your job to push commits to the PR to complete all of these tasks. You MUST wait to confirm that ALL of the above is true before stopping. I expect to be able to merge the PR after you are done.

@charliecreates charliecreates bot requested a review from rybosome February 6, 2026 20:11
@charliecreates
Copy link
Contributor Author

charliecreates bot commented Feb 6, 2026

Summary of fixes for review:

  • renderNTimes determinism: clamped inputs + added gl.finish() to ensure deterministic render completion.
  • normalScale handling: only apply normalScale when a normalMap is present; default is 0.25.

Status:

  • ✅ All CI checks are passing.
  • ✅ Branch is up-to-date with main and PR is mergeable (no conflicts).
  • ✅ All review threads are resolved (0 unresolved).

Remaining gate:

  • This PR still needs an approval from @rybosome (bots can’t approve their own PRs). Once approved, it should be ready to merge.

@rybosome rybosome requested a review from CharlieHelps February 6, 2026 22:09
Copy link
Contributor Author

@charliecreates charliecreates bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most of the prior review hazards (shader/update composition, normal-space correctness, eager map application) appear addressed, but a few robustness/perf issues remain: detailNoiseSeed collapses to a constant when bodyId is missing, disposeNormalMap() still clobbers normalScale despite the new non-clobber policy in applySurfaceMaps(), rim-glow shells duplicate identical geometry allocations, and the e2e gl.finish() stabilization should be wrapped defensively to avoid platform-specific failures.

Summary of changes

Mars appearance + rendering pipeline enhancements

  • New Mars textures added under apps/orrery/public/textures/planets/:
    • mars-viking-colorized-4k.jpg (USGS Viking colorized mosaic)
    • mars-mola-normal-2k.png (DEM-derived normal map)
    • mars-roughness-proxy-2k.png (DEM slope + albedo luminance roughness proxy)
  • Texture attribution documented in apps/orrery/public/textures/planets/README.md.
  • Generator script added: apps/orrery/scripts/generate-mars-maps.js (downloads PDS MOLA DEM + generates normal/roughness textures), with apps/orrery/scripts/data/.gitignore to keep downloaded inputs out of git.
  • Mars registry config updated (apps/orrery/src/scene/BodyRegistry.ts) to:
    • Use USGS albedo (mars-viking-colorized-4k.jpg)
    • Enable normalTexture, roughnessTexture, normalScale, detailNoise
    • Add new layers: kind: "atmosphere" and kind: "aerosol"

New appearance model capabilities

  • BodySurfaceStyle extended (apps/orrery/src/scene/SceneModel.ts) with:
    • normalTexture, roughnessTexture, normalScale
    • detailNoise block
  • Added new layer types and predicates:
    • AtmosphereAppearanceLayerStyle + isAtmosphereAppearanceLayer()
    • AerosolAppearanceLayerStyle + isAerosolAppearanceLayer()

BodyMesh rendering behavior changes

  • apps/orrery/src/scene/BodyMesh.ts:
    • Adds eager async loading for normalTexture and roughnessTexture via loadTextureCached().
    • Centralizes map assignment in applySurfaceMaps() and applies maps immediately after async load.
    • Adds deterministic procedural detailNoise shader patch.
    • Adds a shared createRimGlowShell() helper for Earth atmosphere and non-Earth atmosphere/aerosol shells.
    • Adds ensureSunDirWorldUpdate() to compose per-frame update behavior.

E2E stability improvements

  • Adds an explicit e2e hook window.__tspice_viewer__e2e.renderNTimes(n) implemented in apps/orrery/src/SceneCanvas.tsx.
  • Updates apps/orrery/e2e/sun-postprocessing.spec.ts to wait for renderNTimes() before screenshots.
  • Documents the hook in apps/orrery/e2e/README.md and types it in apps/orrery/src/e2eHooks/globals.d.ts.

Global lighting tweak

  • Lowers default sun light intensity in apps/orrery/src/SceneCanvas.tsx from 3.5 to 2.1.

Tooling deps

  • Adds jpeg-js and pngjs to apps/orrery/package.json (and lockfile).

@charliecreates charliecreates bot removed the request for review from CharlieHelps February 6, 2026 22:12
@rybosome
Copy link
Owner

rybosome commented Feb 6, 2026

@CharlieHelps I need your help preparing this PR to merge. You should read the diff, related issues/docs, and PR reviews closely and then ensure ALL of the following is true:

  • All checks are passing
  • The branch is up to date with the HEAD of the base branch
  • There are no merge conflicts
  • All PR review feedback has been addressed:
    • Fixes have been pushed for feedback that needs to be addressed before merging (blocking feedback)
    • All non-blocking feedback that hasn't been addressed has been acknowledged with a comment explaining why it's not considered blocking
    • All addressed feedback comments have been marked as resolved

It's your job to push commits to the PR to complete all of these tasks. You MUST wait to confirm that ALL of the above is true before stopping. I expect to be able to merge the PR after you are done.

@charliecreates
Copy link
Contributor Author

charliecreates bot commented Feb 6, 2026

Update pushed in f2f9a1c addressing the latest review notes:

  • Safer deterministic detailNoiseSeed fallback when bodyId is missing (avoids copy-pasted “random” detail)
  • disposeNormalMap() no longer clobbers normalScale, keeping behavior consistent with applySurfaceMaps()
  • renderNTimes() GPU sync (gl.finish()) is now handled defensively (no hard failure if unsupported/throws)

Status:

  • Branch is up to date on appearance/mars @ f2f9a1c
  • PR is mergeable (no conflicts)
  • All checks passing
  • All review threads have been replied-to + resolved

@rybosome when you’re happy with this set, could you give final approval so this can be merged?

@rybosome rybosome requested a review from CharlieHelps February 6, 2026 23:10
Copy link
Contributor Author

@charliecreates charliecreates bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most of the prior blocking review hazards (shader/update composition, normal-space correctness, eager map application, and e2e render hook) are addressed well. Remaining concerns are mostly robustness and future-proofing: detailNoiseSeed fallback via JSON.stringify(options.appearance) is brittle/expensive, renderNTimes() needs more defensive guards around WebGL sync primitives, and createRimGlowShell() can avoid repeated identical geometry allocations. None of these are guaranteed to break today, but they are likely maintenance/perf traps as the appearance system grows.

Additional notes (4)
  • Maintainability | apps/orrery/e2e/sun-postprocessing.spec.ts:28-43
    waitForViewerReadyForScreenshot() always waits for renderNTimes to exist, but it doesn’t enforce any minimum meaningful render count at the call site. If a caller passes { renders: 0 } (or NaN) it will still call through; renderNTimes() clamps internally, but the test helper should be explicit about its own contract.

Also, consider adding a comment clarifying why the third test uses renders: 8 (it reads like a magic number).

  • Maintainability | apps/orrery/src/SceneCanvas.tsx:1164-1212
    renderNTimes() does a WebGL2 fenceSync and then loops up to 30 frames / 500ms waiting. This is better than gl.finish(), but it can still introduce nondeterminism across slow CI hosts (timing/RAF scheduling variability) and may mask real readiness signals.

At minimum, consider making the behavior observable from the test side (e.g. returning whether the fence signaled) so failures can be diagnosed, or add a hard error when GPU sync never signals in CI (optional).

  • Maintainability | apps/orrery/src/scene/BodyMesh.ts:246-294
    applySurfaceMaps() sets material.normalScale when a normal map is present, defaulting to 0.25. That’s good, but normalScale is not clamped.

A malformed registry value (negative or very large) can create extreme shading artifacts or even NaNs in some shader paths. You’re already clamping many other appearance inputs—this should be similarly defensive.

  • Readability | apps/orrery/src/scene/BodyRegistry.ts:203-252
    In Mars registry config, you set roughness: 1.0 while also providing a roughnessTexture. In Three.js, roughness multiplies the map, so with the roughness proxy already near ~0.9–1.0 this likely compresses variation and may make the map’s contribution hard to see.

If the intent is “mostly matte but still spatially varying,” consider using roughness as a global multiplier less than 1 to reintroduce headroom, or adjust the generator’s base/amp accordingly. Right now it’s easy for future tuning to become confusing: is the visual change coming from the map, the scalar, or the generator constants?

Summary of changes

Summary

This PR upgrades Mars rendering and supporting tooling/tests across the orrery app.

Rendering pipeline

  • Extends BodySurfaceStyle with PBR map inputs:
    • normalTexture, roughnessTexture, and normalScale
    • optional procedural detailNoise block
  • Adds new appearance layers in SceneModel.ts:
    • kind: "atmosphere" (isAtmosphereAppearanceLayer())
    • kind: "aerosol" (isAerosolAppearanceLayer())
  • Updates BodyMesh.ts to:
    • async-load and eagerly apply albedo/normal/roughness maps via loadTextureCached()
    • centralize assignment via applySurfaceMaps()
    • add deterministic detailNoise shader patching (with // tspice:* idempotency markers)
    • add reusable createRimGlowShell() for atmosphere/aerosol shells
    • compose per-frame updates via ensureSunDirWorldUpdate()

Mars assets + registry

  • Adds new Mars textures under apps/orrery/public/textures/planets/:
    • mars-viking-colorized-4k.jpg
    • mars-mola-normal-2k.png
    • mars-roughness-proxy-2k.png
  • Updates BodyRegistry.ts Mars entry to use the new textures plus tuned parameters (roughness, nightAlbedo, terminatorTwilight, detailNoise, and new atmosphere/aerosol layers).
  • Updates texture attribution docs in public/textures/planets/README.md.

Tooling

  • Adds generator script apps/orrery/scripts/generate-mars-maps.js:
    • downloads/validates PDS MOLA DEM inputs
    • generates normal + roughness-proxy PNGs
    • uses an sRGB→linear conversion for albedo luminance
  • Adds apps/orrery/scripts/data/.gitignore to keep downloaded PDS inputs out of git.
  • Adds dev deps jpeg-js and pngjs.

E2E stabilization + defaults

  • Adds window.__tspice_viewer__e2e.renderNTimes(n) and documents it.
  • Updates sun postprocessing Playwright spec to call renderNTimes() before screenshots.
  • Lowers global default SUN_LIGHT_INTENSITY_DEFAULT from 3.52.1.

@charliecreates charliecreates bot removed the request for review from CharlieHelps February 6, 2026 23:13
@rybosome
Copy link
Owner

rybosome commented Feb 6, 2026

Most of the prior blocking review hazards (shader/update composition, normal-space correctness, eager map application, and e2e render hook) are addressed well. Remaining concerns are mostly robustness and future-proofing: detailNoiseSeed fallback via JSON.stringify(options.appearance) is brittle/expensive, renderNTimes() needs more defensive guards around WebGL sync primitives, and createRimGlowShell() can avoid repeated identical geometry allocations. None of these are guaranteed to break today, but they are likely maintenance/perf traps as the appearance system grows.

Additional notes (4)

  • Maintainability | apps/orrery/e2e/sun-postprocessing.spec.ts:28-43
    waitForViewerReadyForScreenshot() always waits for renderNTimes to exist, but it doesn’t enforce any minimum meaningful render count at the call site. If a caller passes { renders: 0 } (or NaN) it will still call through; renderNTimes() clamps internally, but the test helper should be explicit about its own contract.

Also, consider adding a comment clarifying why the third test uses renders: 8 (it reads like a magic number).

  • Maintainability | apps/orrery/src/SceneCanvas.tsx:1164-1212
    renderNTimes() does a WebGL2 fenceSync and then loops up to 30 frames / 500ms waiting. This is better than gl.finish(), but it can still introduce nondeterminism across slow CI hosts (timing/RAF scheduling variability) and may mask real readiness signals.

At minimum, consider making the behavior observable from the test side (e.g. returning whether the fence signaled) so failures can be diagnosed, or add a hard error when GPU sync never signals in CI (optional).

  • Maintainability | apps/orrery/src/scene/BodyMesh.ts:246-294
    applySurfaceMaps() sets material.normalScale when a normal map is present, defaulting to 0.25. That’s good, but normalScale is not clamped.

A malformed registry value (negative or very large) can create extreme shading artifacts or even NaNs in some shader paths. You’re already clamping many other appearance inputs—this should be similarly defensive.

  • Readability | apps/orrery/src/scene/BodyRegistry.ts:203-252
    In Mars registry config, you set roughness: 1.0 while also providing a roughnessTexture. In Three.js, roughness multiplies the map, so with the roughness proxy already near ~0.9–1.0 this likely compresses variation and may make the map’s contribution hard to see.

If the intent is “mostly matte but still spatially varying,” consider using roughness as a global multiplier less than 1 to reintroduce headroom, or adjust the generator’s base/amp accordingly. Right now it’s easy for future tuning to become confusing: is the visual change coming from the map, the scalar, or the generator constants?

Summary of changes

@CharlieHelps please prepare a detailed report with your findings on all of the issues mentioned. This should include:

  • a summary of the issue
  • a suggested fix, including a code snippet
  • an analysis of the impact/consequences of adopting this snippet
  • how to either make this change in a way that is consistent with the rest of the codebase, or how the rest of the codebase should adjust to maintain consistency with this change

@charliecreates
Copy link
Contributor Author

charliecreates bot commented Feb 6, 2026

Responding to #262 (comment) — here’s a breakdown of each concern with a concrete fix direction + impact analysis.

1) detailNoiseSeed fallback uses JSON.stringify(options.appearance)

Where: apps/orrery/src/scene/BodyMesh.ts (~460)

Summary

Current code:

const detailNoiseSeedInput = options.bodyId ?? JSON.stringify(options.appearance)
const detailNoiseSeed = detailNoise?.seed ?? stableHash01(detailNoiseSeedInput)

Using JSON.stringify(options.appearance) is:

  • brittle (key order / incidental fields change the seed; harder to reason about “why did the noise change?”),
  • potentially expensive / GC-heavy (stringifying a nested options object),
  • risky if appearance ever gains non-serializable fields.

Suggested fix (snippet)

Prefer a small, intentionally-chosen seed key with a clear stability contract. E.g.:

function getDetailNoiseSeedInput(options: {
  bodyId?: string
  appearance?: { id?: string }
}) {
  // Best: stable per-body identifier.
  if (options.bodyId) return options.bodyId

  // Next-best: explicit appearance id (stable if config defines it).
  if (options.appearance?.id) return `appearance:${options.appearance.id}`

  // Last resort: deterministic but intentionally limited “seed key” (avoid whole-object stringify).
  return 'appearance:anonymous'
}

const detailNoiseSeedInput = getDetailNoiseSeedInput(options)
const detailNoiseSeed = detailNoise?.seed ?? stableHash01(detailNoiseSeedInput)

If you do need “appearance-derived uniqueness”, use a stable, cheap projection (only the fields that should affect noise), not the full object:

const seedKey = [
  options.appearance?.surface?.detailNoise?.scale,
  options.appearance?.surface?.detailNoise?.octaves,
  options.appearance?.surface?.detailNoise?.persistence,
].join('|')

Impact / consequences

  • Pros: less CPU/GC; fewer accidental seed changes; easier to debug visual diffs.
  • Cons: if callers omit bodyId and also don’t provide an appearance.id, multiple bodies could share a seed. (That’s arguably the right tradeoff unless uniqueness is explicitly requested.)

Consistency notes

  • This matches the rest of the appearance system’s trend: values are either explicit (seed provided) or derived from stable ids, not derived from incidental object shape.
  • If we want deterministic-but-unique defaults, consider making bodyId required wherever BodyMesh is constructed from registry bodies.

2) renderNTimes() needs more defensive guards + observability

Where: apps/orrery/src/SceneCanvas.tsx (~1164–1212)

Summary

The current implementation is already bounded (30 frames / 500ms) and avoids gl.finish(), which is good. The remaining issues are:

  • the test side cannot tell if the WebGL2 fence actually signaled,
  • failure modes like WAIT_FAILED, context loss, or fenceSync() returning null are silent,
  • the WebGL2 wait loop can become “mysteriously flaky” on slow CI, and we’ll have no structured info.

Suggested fix (snippet)

Return a small result object and harden the WebGL2 path:

type RenderNTimesResult = {
  renders: number
  usedWebGL2Fence: boolean
  fenceSignaled: boolean
  waitedFrames: number
  waitedMs: number
}

const renderNTimes = async (n: number): Promise<RenderNTimesResult> => {
  const count = Math.max(1, Number.isFinite(n) ? Math.floor(n) : 1)
  for (let i = 0; i < count; i++) {
    three.renderOnce()
    await nextAnimationFrame()
  }

  const gl = three.renderer.getContext()
  const isWebGL2 = typeof WebGL2RenderingContext !== 'undefined' && gl instanceof WebGL2RenderingContext
  if (!isWebGL2) {
    gl.flush()
    await nextAnimationFrame()
    await nextAnimationFrame()
    return { renders: count, usedWebGL2Fence: false, fenceSignaled: true, waitedFrames: 0, waitedMs: 0 }
  }

  const gl2 = gl as WebGL2RenderingContext
  const sync = gl2.fenceSync(gl2.SYNC_GPU_COMMANDS_COMPLETE, 0)
  if (!sync) {
    return { renders: count, usedWebGL2Fence: true, fenceSignaled: false, waitedFrames: 0, waitedMs: 0 }
  }

  const start = performance.now()
  const MAX_FRAMES = 30
  const MAX_MS = 500
  let waitedFrames = 0
  let fenceSignaled = false

  try {
    for (; waitedFrames < MAX_FRAMES; waitedFrames++) {
      const status = gl2.clientWaitSync(sync, gl2.SYNC_FLUSH_COMMANDS_BIT, 0)
      if (status === gl2.ALREADY_SIGNALED || status === gl2.CONDITION_SATISFIED) {
        fenceSignaled = true
        break
      }
      if (status === gl2.WAIT_FAILED) break
      if (performance.now() - start > MAX_MS) break
      await nextAnimationFrame()
    }
  } finally {
    gl2.deleteSync(sync)
  }

  return {
    renders: count,
    usedWebGL2Fence: true,
    fenceSignaled,
    waitedFrames,
    waitedMs: performance.now() - start,
  }
}

Then the Playwright helper can choose to fail-fast in CI with a good error message:

const r = await api.renderNTimes(renders)
if (process.env.CI && r.usedWebGL2Fence && !r.fenceSignaled) {
  throw new Error(`renderNTimes: fence did not signal (waited ${r.waitedFrames} frames / ${r.waitedMs}ms)`)
}

Impact / consequences

  • Pros: better debuggability; less “silent flake”; easier to tune thresholds as CI characteristics change.
  • Cons: slightly more code + a small API change for the __tspice_viewer__e2e contract (call sites may need to ignore the return value).

Consistency notes

  • This matches how other e2e helpers already surface missing hooks via explicit throw new Error(...).

3) createRimGlowShell() re-allocates identical geometry repeatedly

Where: apps/orrery/src/scene/BodyMesh.ts (~712+)

Summary

Every call does:

  • new THREE.SphereGeometry(1, 48, 24)
  • geo.rotateX(...)
    Even though the geometry is identical for all shells and only scaled per-mesh. For scenes with many bodies (each potentially adding multiple shells), this becomes avoidable CPU + memory churn.

Suggested fix (snippet)

Cache a shared unit sphere geometry at module scope and do not dispose it per-body:

const RIM_GLOW_SPHERE_GEOMETRY = (() => {
  const geo = new THREE.SphereGeometry(1, 48, 24)
  geo.rotateX(Math.PI / 2)
  return geo
})()

const createRimGlowShell = (...) => {
  const geo = RIM_GLOW_SPHERE_GEOMETRY
  // no extraGeometriesToDispose.push(geo)
  ...
}

Optional follow-up: keep a base ShaderMaterial and clone() it to reuse compiled programs while still keeping per-shell uniforms isolated.

Impact / consequences

  • Pros: less allocation; faster creation of many bodies; lower GC pressure.
  • Cons: shared geometry lifetime becomes “app lifetime” (fine for a single-page viewer; if you need full teardown, store it in a geometry cache owned by the viewer root and dispose on unmount).

Consistency notes

  • This matches existing caching patterns used for textures (loadTextureCached(...)) — shared immutable assets live longer and are not disposed per-instance.

4) waitForViewerReadyForScreenshot() should enforce a meaningful render contract + explain renders: 8

Where: apps/orrery/e2e/sun-postprocessing.spec.ts (~28–43)

Summary

Today the helper accepts { renders?: number } but doesn’t validate the value. Passing { renders: 0 } / NaN will still call through; renderNTimes() clamps internally, but the helper should be explicit about its own contract.

Also, the third test’s { renders: 8 } reads like a magic number without context.

Suggested fix (snippet)

Clamp or throw at the helper boundary:

const waitForViewerReadyForScreenshot = async (page: any, opts?: { renders?: number }) => {
  ...
  const rendersRaw = opts?.renders ?? 2
  const renders = Math.max(1, Number.isFinite(rendersRaw) ? Math.floor(rendersRaw) : 2)
  ...
  await api.renderNTimes(renders)
}

And replace magic number with a named constant + comment:

const SUN_POSTPROCESS_SETTLE_RENDERS = 8
// Selective bloom / postprocess pipelines can take a few frames to fully settle in CI
// (RT allocation + async texture uploads + scheduling variability).
await waitForViewerReadyForScreenshot(page, { renders: SUN_POSTPROCESS_SETTLE_RENDERS })

Impact / consequences

  • Pros: fewer accidental footguns; tests become self-documenting.
  • Cons: negligible.

Consistency notes

  • This is consistent with other helpers that validate inputs rather than relying on downstream clamping.

5) applySurfaceMaps() should clamp normalScale

Where: apps/orrery/src/scene/BodyMesh.ts (~246–294)

Summary

normalScale can come from registry config. Extreme or malformed values (negative or huge) can create severe shading artifacts (and in worst cases NaNs downstream).

Suggested fix (snippet)

Clamp to a reasonable range and ensure finiteness:

const clampNormalScale = (v: number, fallback = 0.25) => {
  const x = Number.isFinite(v) ? v : fallback
  // Allow negative (inverts normals), but cap magnitude.
  return THREE.MathUtils.clamp(x, -2, 2)
}

if (nextUseNormalMap) {
  const nextScale = normalScale ?? 0.25
  if (typeof nextScale === 'number') {
    const s = clampNormalScale(nextScale)
    material.normalScale.set(s, s)
  } else {
    material.normalScale.set(clampNormalScale(nextScale.x), clampNormalScale(nextScale.y))
  }
}

Impact / consequences

  • Pros: prevents outlier configs from producing pathological renders; makes appearance inputs safer to tune.
  • Cons: if someone intentionally wanted normalScale > 2, they’ll be capped (but values that high are usually not desirable anyway).

Consistency notes

  • This follows the “defensive clamp” pattern already used for other appearance inputs (the system is clearly moving toward being resilient to bad registry values).

6) Mars roughness: 1.0 combined with roughnessTexture is confusing

Where: apps/orrery/src/scene/BodyRegistry.ts (Mars block ~203–216)

Summary

In Three.js, roughness multiplies roughnessMap. With a roughness proxy texture already near ~0.9–1.0, keeping the scalar at 1.0 can make map variation feel “compressed”, and it’s harder for future tuning to know whether changes come from the map or the scalar.

Suggested fix (snippet)

Option A (clarity-only): keep values but add a comment explaining the multiplier relationship and expected value ranges.

Option B (tuning headroom): lower the global multiplier slightly:

roughness: 0.85, // global multiplier; roughnessTexture provides spatial variation
roughnessTexture: { url: 'textures/planets/mars-roughness-proxy-2k.png' },

Impact / consequences

  • Pros: clearer mental model; easier future tuning; potentially more visible spatial roughness variation.
  • Cons: may slightly change the look; you’ll likely want to re-baseline snapshot(s) if this is adopted.

Consistency notes

  • If we adopt Option B, it’s worth documenting the intended range for roughness proxy textures (e.g. “author maps assuming scalar ~0.8–0.9”). That keeps the registry values consistent across bodies.

@rybosome rybosome marked this pull request as draft February 8, 2026 05:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Appearance: Mars

2 participants