Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
4c162b1
perf(objects): a small body keeps the images of the dataset it left; …
alowpoly Oct 5, 2026
af07148
perf(renderer): a dataset that changes mesh attaches it a slice of fa…
alowpoly Oct 5, 2026
a33bc8d
perf(renderer): a dataset writes its image only on the faces of the m…
alowpoly Oct 5, 2026
5fc0cd1
revert(renderer): a mesh is attached whole again; attached a slice a …
alowpoly Oct 5, 2026
7732587
revert(renderer): a dataset writes its image on every face its slot l…
alowpoly Oct 5, 2026
6a052a0
Merge remote-tracking branch 'origin/main' into perf/dataset-pool-keeps
alowpoly Oct 5, 2026
6cbe26e
fix(mathilde): every dataset draws on one mesh; the NEAR close-ups no…
alowpoly Oct 5, 2026
79eb1e5
Merge remote-tracking branch 'origin/main' into perf/dataset-pool-keeps
alowpoly Oct 5, 2026
8e0e3a5
fix(comet-67p): every dataset draws on the SHAP7 mesh at one atlas si…
alowpoly Oct 5, 2026
f51618b
Merge remote-tracking branch 'origin/main' into perf/dataset-pool-keeps
alowpoly Oct 5, 2026
43fe855
Merge remote-tracking branch 'origin/main' into perf/dataset-pool-keeps
alowpoly Oct 5, 2026
e0dfb09
test(bake): the alternative-mesh profile test reads Kleopatra, whose …
alowpoly Oct 5, 2026
68fd7af
feat(bake): a dataset can be read from another model of the body and …
alowpoly Oct 5, 2026
b507e49
perf(renderer): a layer stack decodes its images before it shows, and…
alowpoly Oct 5, 2026
e36dfa2
docs(performance): picture layers decode before they show and their b…
alowpoly Oct 5, 2026
5344a80
chore(sun): the world files carry comet 67P's new arrival view on top…
alowpoly Oct 5, 2026
ff9f5ee
Merge remote-tracking branch 'origin/main' into perf/dataset-pool-keeps
alowpoly Oct 5, 2026
c5ec51d
test(bake): type the radial-models fixture's alternatives
alowpoly Oct 5, 2026
cac584d
fix(bennu): every dataset draws on one mesh; the six facet maps are r…
alowpoly Oct 5, 2026
f16b993
Merge remote-tracking branch 'origin/main' into perf/dataset-pool-keeps
alowpoly Oct 5, 2026
d4f171f
Merge remote-tracking branch 'origin/main' into perf/dataset-pool-keeps
alowpoly Oct 5, 2026
c593e00
fix(phoebe): every dataset has one atlas size; the ISS photograph no …
alowpoly Oct 5, 2026
272448d
fix(objects): Tethys, Mimas and Vesta draw every dataset at one atlas…
alowpoly Oct 5, 2026
706de7b
Merge remote-tracking branch 'origin/main' into perf/dataset-pool-keeps
alowpoly Oct 5, 2026
9a2b144
Merge remote-tracking branch 'origin/main' into perf/dataset-pool-keeps
alowpoly Oct 5, 2026
1634a51
fix(bake): a face the bindings missed is measured by the next run; En…
alowpoly Oct 5, 2026
47b16a2
fix(objects): Enceladus's elevation atlas takes the size of its other…
alowpoly Oct 5, 2026
3a7f191
Merge remote-tracking branch 'origin/main' into perf/dataset-pool-keeps
alowpoly Oct 5, 2026
158b1f4
fix(objects): Deimos and Phobos draw smaller atlases; a Deimos datase…
alowpoly Oct 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
18 changes: 18 additions & 0 deletions .agents/skills/celestial-skill/references/implementation-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,24 @@ instead of editing a shared list or atlas position.
ranges in one retained scene. Borrelly (`comet-19p`) and Tuttle (`comet-8p`)
use this path. Verify only the selected model is visible and pickable, camera
behavior remains shared, and the combined prepared asset bank meets the budget.
A dataset takes a mesh of its own only when it is itself another shape: a second
shape model, or a picture registered to one. A map of the same body draws on
the body's mesh. Picking a dataset on another mesh swaps every face in one
frame: 0.5 to 1.1 s on an iPad for comet 67P's 1,000 to 2,000 faces when its
model, photographs and VIRTIS maps each had their own (2026-10-05). A map
published on another model of the same body names that model as an alternative
with `"display": "body-mesh"`: each texel is read at the closest point of that
model's surface and drawn on the body's mesh, as Bennu's six facet tables are
from four versions of its shape.
- **One atlas size for a body's datasets:** on a mesh of triangles every face
draws its atlas at the layout's size. A dataset with `textureScale` below 1 is
drawn enlarged, which Safari does with a cropped copy for each face: comet
67P's 1,992 faces took 731 to 842 ms to switch to a half-size atlas and 202 to
236 ms to a full-size one on an iPad (2026-10-05). Set `texelsPerFace` to what
the datasets need and leave `textureScale` out. A banded sphere follows the
same rule with one `textureScale` for its maps: Enceladus's elevation atlas
at full size, 8,320 pixels wide among 2,080 pixel ones, took 568 to 613 ms to
pick and 51 to 68 ms at their size.
- **Observation mosaics:** Triton's `source/preparation/terrestrial.json` uses
the shared terrestrial path (`packages/bake/src/objects/layers/terrestrial/`) for native image geometry,
photometric correction, compositing and gaps. Reuse the capability with the
Expand Down
8 changes: 8 additions & 0 deletions docs/performance/motion-freezes-membership.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ held differently while coasting:
182 ms), and leaving by the slices still there (all at once 42 to 50 ms, 16 a frame eight frames of 34 to 93 ms,
2026-10-04). A stack's opacity in the mix stops at 0.999: Safari painted every slice under a stack again each time its
opacity left or reached 1 (54 ms for 88 slices with the camera still, nothing for 0.99 to 0.98).
A galaxy's or nebula's picture layers follow the same two rules (`image-layers/prepared-image-layer-runtime.ts`,
`universe/universe-catalog-banks.ts`): a bank of sheets stops its own opacity at 0.999 too. A bank of patches draws
at 1, since a group under 1 lost a wedge of the picture at the Homunculus's nearest view, and pays the repaint each
time it crosses 1 (180 to 208 ms for Cassiopeia A's 1,397 patches). A stack the camera turns to, or a bank shown
again, stays hidden until its
images are decoded off the page's thread, then shows whole: decoded inside the paint, NGC 2392's 57 layers made a
frame of 235 ms on a turn and 270 ms on a dataset switch, against 34 and 31 ms decoded first (iPad, 2026-10-05).
Joining such a stack a share a frame was measured and rejected: each joining frame painted the layers already shown.

## Why

Expand Down
14 changes: 13 additions & 1 deletion docs/surface-preparation.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,14 @@ going back to its surface map spent 117 of 219 main-thread samples of its long f
images that stayed resident undrawn waits for a second `decode()`, off that thread, before it is ready
([prepared-residency.ts](../packages/renderer/src/rendering/prepared-residency.ts)).

A small body keeps the images of the dataset it has just left. Its dataset pool is marked to keep images until it is
full, and the last bake step states the pool's byte budget from the image sizes: two selections, the one on screen and
the one before it, never above what Io states for its pages
([prepared-pool-budgets.ts](../packages/bake/src/prepared-presentation/prepared-pool-budgets.ts)). On an iPad, going
back to a dataset of Bennu took 280 ms and 180 ms after its 13-megapixel atlas had been released, and 82 ms and 75 ms
with it kept; a first visit is unchanged. A body whose two selections exceed that ceiling releases what a selection
leaves, as before; since Deimos and Phobos took smaller atlases, no small body does.

How the surface is cut into images matters less. Io and Mercury baked as square pages of four faces, 112 pages a
surface, switched in 158 to 298 ms and 129 to 146 ms at their default views: no gain for Io, and twice Mercury's time
on its one page. Zoomed in, where only the pages in view are decoded, Io took 75 to 108 ms against 108 to 178 ms.
Expand Down Expand Up @@ -564,7 +572,11 @@ its full box. [leaf-box.ts](../packages/bake/src/presentation/leaf-box.ts) holds
217 to 246 ms; with the same box and the size in pixels, in 64 to 67 ms (iPad, 2026-10-04).
- **Steps** grow by √2 from 16 silhouette pixels to the first step at which every leaf holds its full box.
- **Groups.** The presentation bindings measure every leaf in a browser at each `prepare:object-json`, so every generator
shares the rule. Surface leaves join blocks of about eight leaves by direction (`LEAF_BOX_GROUP_LEAVES`), each with a
shares the rule. A leaf a run does not measure is stored with its full box, and the next run puts it back in the
variable form first (`withoutLeafBoxRecords`), so it is measured as soon as it renders. Until 2026-10-05 such a leaf
kept its full box for good: Enceladus, Dione and Rhea had never been measured, and Enceladus's faces sat in 128 px
boxes with images a quarter of that size drawn enlarged, 189 to 289 ms a dataset switch on the iPad against 51 to
72 ms once measured. Surface leaves join blocks of about eight leaves by direction (`LEAF_BOX_GROUP_LEAVES`), each with a
placement like a texture page's; other leaves (rings, shells, cutaways) form groups of eight. The bindings also record
each group's full box area, for the memory estimate below.
- **Runtime** ([prepared-leaf-box-blocks.ts](../packages/renderer/src/rendering/prepared-leaf-box-blocks.ts)): a block's step
Expand Down
15 changes: 8 additions & 7 deletions packages/bake/src/objects/layers/terrestrial/profile.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -49,23 +49,24 @@ test('georeferenced photographs bind quality, physical distances and bounded dis
});

test('an alternative model owns its observation mesh and sampler state', async () => {
const profile = await read('comet-67p');
const alternative = fixtureRecord(alternativeForDataset(profile.geometry.radialTerrainAlternatives, 'osiris'));
// Kleopatra's ZIMPOL photograph is registered to the ADAM model, a second shape beside the one its other datasets draw.
const profile = await read('kleopatra');
const alternative = fixtureRecord(alternativeForDataset(profile.geometry.radialTerrainAlternatives, 'zimpol'));
assert.notEqual(alternative.path, profile.geometry.radialTerrain.path, 'the observation uses a distinct source mesh');
assert.doesNotThrow(() => parseTerrestrialProfile(profile), 'OSIRIS samples its declared alternative mesh');
// The OSIRIS dataset validates against its own model, which must preserve the source mesh like the default.
assert.doesNotThrow(() => parseTerrestrialProfile(profile), 'the photograph samples its declared alternative mesh');
// The photograph validates against its own model, which must preserve the source mesh like the default.
delete fixtureRecord(alternative, 'simplification').method;
assert.throws(() => parseTerrestrialProfile(profile), /source-bound/, 'the alternative mesh must preserve its source');

const observation = { samplePoint() { return null; } };
const base: {observationSurfaces?: Map<string, typeof observation>} = {};
const alternativeRadial: {observationSurfaces?: Map<string, typeof observation>} = {};
const selected = radialModelForDataset([{ datasetIds: ['model'], radial: base }, { datasetIds: ['osiris'], radial: alternativeRadial }], 'osiris');
const selected = radialModelForDataset([{ datasetIds: ['shape'], radial: base }, { datasetIds: ['zimpol'], radial: alternativeRadial }], 'zimpol');
selected.radial.observationSurfaces ??= new Map();
selected.radial.observationSurfaces.set('osiris', observation);
selected.radial.observationSurfaces.set('zimpol', observation);
assert.equal(selected.radial, alternativeRadial);
assert.equal('observationSurfaces' in base, false);
assert.equal(alternativeRadial.observationSurfaces?.get('osiris'), observation);
assert.equal(alternativeRadial.observationSurfaces?.get('zimpol'), observation);
});

test('PDS4 geometry cubes declare their planes and identity and bind a lossless, labelled frame within its transfer limits', async () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import assert from 'node:assert/strict';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { sourceTest } from '@cssearth/objects/node/source-test';
import { fixtureSource } from '../../../sources/fixtures/test-source-fixture.mts';
import { combineRadialModels, loadRadialModels } from './radial-models.ts';
const test = sourceTest();

const tetrahedron = (size: number) => `v ${size} 0 0\nv 0 ${size} 0\nv 0 0 ${size}\nv 0 0 0\nf 1 2 3\nf 1 4 2\nf 2 4 3\nf 3 4 1\n`;
const profile = (path: string) => ({ path, format: 'wavefront-obj', texelsPerFace: 256, grid: { metersPerUnit: 1, expectedVertices: 4, expectedFaces: 4 }, faceBudget: 4,
simplification: { method: 'source-meshoptimizer', targetFaces: 4, maximumErrorMeters: .001 } });

test('a dataset read from another model of the body draws on the body\'s mesh, and one that is another shape keeps its own', async () => {
const directory = await mkdtemp(join(tmpdir(), 'cssearth-radial-models-'));
try {
await writeFile(join(directory, 'body.obj'), tetrahedron(1));
await writeFile(join(directory, 'survey.obj'), tetrahedron(1.01));
await writeFile(join(directory, 'other.obj'), tetrahedron(2));
const source = await fixtureSource(directory, ['body', 'survey', 'other'].map(id => ({ id, path: `${id}.obj`, consumers: ['geometry'] })));
const config = (alternatives: (Record<string, unknown> & { datasetId: string; additionalDatasetIds?: string[]; display?: unknown })[]) => ({ namespace: 'fixture', presentation: { defaultDataset: 'shape' },
raster: { observations: [{ id: 'shape' }, { id: 'photograph' }], scientific: [{ id: 'gravity' }, { id: 'slope' }, { id: 'second-shape' }] },
geometry: { radius: 1, radiusKm: .001, radialTerrain: profile('body.obj'), radialTerrainAlternatives: alternatives } });
const models = await loadRadialModels({ sourceDirectory: directory, source, config: config([
{ datasetId: 'gravity', additionalDatasetIds: ['slope'], display: 'body-mesh', ...profile('survey.obj') },
{ datasetId: 'second-shape', ...profile('other.obj') }]) });
// Two meshes are drawn: the body's, which also carries the two maps of the survey model, and the second shape's.
assert.deepEqual(models.map(model => [model.id, model.datasetIds]), [['shape', ['shape', 'photograph', 'gravity', 'slope']], ['second-shape', ['second-shape']]]);
const [body] = models, sampled = body!.sampling!;
assert.deepEqual([...sampled.keys()], ['gravity', 'slope']);
// The maps are read from the survey model's own surface, not the body's.
assert.notEqual(sampled.get('gravity')!.grid, body!.radial.grid);
assert.equal(sampled.get('gravity')!.grid, sampled.get('slope')!.grid);
assert.equal((sampled.get('gravity')!.config.geometry.radialTerrain as { path: string }).path, 'survey.obj');
const combined = combineRadialModels(models, 'fixture')!;
assert.deepEqual(combined.datasetRanges, [...['shape', 'photograph', 'gravity', 'slope'].map(datasetId => ({ datasetId, start: 0, count: 4 })), { datasetId: 'second-shape', start: 4, count: 4 }]);
// With every other model only read from, the body has one mesh and no ranges to select between.
const single = await loadRadialModels({ sourceDirectory: directory, source, config: config([{ datasetId: 'gravity', additionalDatasetIds: ['slope'], display: 'body-mesh', ...profile('survey.obj') }]) });
assert.equal(single.length, 1);
assert.equal(combineRadialModels(single, 'fixture'), single[0]!.radial);
await assert.rejects(loadRadialModels({ sourceDirectory: directory, source, config: config([{ datasetId: 'gravity', display: 'elsewhere', ...profile('survey.obj') }]) }), /body-mesh/);
} finally { await rm(directory, { recursive: true, force: true }); }
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ type TerrainContext = Parameters<typeof loadRadialTerrain>[0];
const datasetGroups = ['observations', 'scientific', 'observedColors', 'shapeViews', 'surfaceObservations'] as const;
interface ModelConfig {
namespace: string;
geometry: {radius: number; radiusKm: number; radialTerrain?: unknown; radialTerrainAlternatives?: (Record<string, unknown> & {datasetId: string; additionalDatasetIds?: string[]})[]};
geometry: {radius: number; radiusKm: number; radialTerrain?: unknown; radialTerrainAlternatives?: (Record<string, unknown> & {datasetId: string; additionalDatasetIds?: string[]; display?: unknown})[]};
presentation: {defaultDataset: string};
raster: Partial<Record<typeof datasetGroups[number], {id: string}[]>>;
}
Expand All @@ -14,6 +14,8 @@ export interface LoadedRadialModel {
datasetIds: string[];
radial: RadialState;
config: ModelConfig;
/** The models some of these datasets are read from while they draw on this mesh (`display: "body-mesh"`), by dataset. */
sampling?: Map<string, {grid: RadialState['grid']; config: ModelConfig}>;
}
interface CombinedRadial {faces: RadialState['faces']; leaves: RadialState['leaves']; datasetRanges?: {datasetId: string; start: number; count: number}[];}

Expand All @@ -31,12 +33,18 @@ export async function loadRadialModels<T extends ModelConfig>(context: Omit<Terr
alternatives.length && !config.geometry.radialTerrain) throw new TypeError('Invalid alternative surface models.');
const base = await loadRadialTerrain(context);
if (!base) return [];
const models = [{ id: ids[0], datasetIds: datasets.filter(id => !ids.slice(1).includes(id)), radial: base, config }];
for (const { datasetId, additionalDatasetIds: _additional, ...profile } of alternatives) {
const sampling = new Map<string, {grid: RadialState['grid']; config: T}>();
const models: {id: string; datasetIds: string[]; radial: typeof base; config: T; sampling?: typeof sampling}[] = [{ id: ids[0], datasetIds: datasets.filter(id => !ids.slice(1).includes(id)), radial: base, config, sampling }];
for (const { datasetId, additionalDatasetIds: _additional, display, ...profile } of alternatives) {
if (display !== undefined && display !== 'body-mesh') throw new TypeError('An alternative surface model draws on its own mesh, or on the body\'s with display "body-mesh".');
const selected = { ...config, geometry: { ...config.geometry, radialTerrain: profile } };
const radial = await loadRadialTerrain({ ...context, config: selected });
if (!radial) throw new TypeError('Alternative model lacks its terrain source.');
models.push({ id: datasetId, datasetIds: alternativeDatasetIds({ datasetId, additionalDatasetIds: _additional }), config: selected, radial });
const own = alternativeDatasetIds({ datasetId, additionalDatasetIds: _additional });
// The same body in another published model: its datasets are read from it and drawn on the body's mesh, so a
// selection never swaps meshes. A second mesh is for a dataset that is itself another shape.
if (display === 'body-mesh') { models[0]!.datasetIds.push(...own); for (const id of own) sampling.set(id, { grid: radial.grid, config: selected }); continue; }
models.push({ id: datasetId, datasetIds: own, config: selected, radial });
}
return models;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export function parseSolidPreparationSource(input:unknown) {
const extra=shape({schema:choice('cssearth-terrestrial-preparation@2'),kind:choice('solid-observation-body'),
namespace:text,displayName:text,publicBase:text,rings:optional(value=>value),
geometry:shape({radius:number,radiusKm:number,mapUrl:text,polesUrl:text,radialModels:optional(value=>value),
radialTerrain:optional(parseRadialSource),radialTerrainAlternatives:optional(array(value=>Object.assign({},parseRadialSource(value),shape({datasetId:text,additionalDatasetIds:optional(array(text))})(value)))),
radialTerrain:optional(parseRadialSource),radialTerrainAlternatives:optional(array(value=>Object.assign({},parseRadialSource(value),shape({datasetId:text,additionalDatasetIds:optional(array(text)),display:optional(text)})(value)))),
camera:optional(value=>{const camera=shape({framingScale:optional(number)})(value);refuseAuthoredCameraAngles(camera);return camera;})}),
lighting:optional(parseSolidLighting),
presentation:shape({defaultDataset:text}),
Expand Down
5 changes: 4 additions & 1 deletion packages/bake/src/objects/layers/terrestrial/solid-scene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,10 @@ export async function prepareSolidPresentation({ config, scene: plan, material:
const presentation = { schema: PREPARED_PRESENTATION_SCHEMA, camera: plan.camera, sky: plan.sky, sun: plan.sun,
assets: { entries, pools: [preparedResourcePool('mounted', entries),
...(entries.some(entry => entry.pool === 'datasets')
? [preparedResourcePool('datasets', entries, { retention: 'selection', capacity: 4, concurrency: 2 })] : [])],
// A dataset's images stay until the pool is full, so coming back to one repaints from an image already in place; the
// last bake step states the pool's byte budget, or takes the mark off where two selections are too large
// (packages/bake/src/prepared-presentation/prepared-pool-budgets.ts).
? [preparedResourcePool('datasets', entries, { retention: 'selection', capacity: 4, concurrency: 2, eviction: 'capacity' })] : [])],
startup: entries.filter(entry => entry.pool === 'mounted').map(entry => entry.key) },
tree, variants, materials: track ? [track] : [], animations: [],
...(plan.surfaceTriangles ? { surfaceHit: { target: index(body), triangles: plan.surfaceTriangles,
Expand Down
Loading
Loading