# Client-side 3D charting for Libertas protocol data

This is the V1 chart contract for schema authors and compatible clients.
Schema support and renderer availability are distinct: a client that cannot
display a chart follows the [chart failure policy](2d.md#10-validation-and-fallback).
This reference does not certify feature parity for every distributed build.

<!-- rule: chart3d.presentation.exact-versions class=hard enforced=no tasks=client-runtime -->
Presentation uses the schema, exact dependency versions, and locale document
associated with the returned value; unrelated installed versions do not
redefine that value. See the [schema reference](../schema.md).

The existing two-dimensional contract remains independently normative in
[Client-side charting for Libertas protocol data](2d.md).
This document adds a parallel, opt-in 3D namespace. Nothing here changes the
meaning, validation, canonicalization, layout, or rendering of `Chart`,
`ChartChannel`, `ChartScale`, or `ChartGuide`.

## 1. Purpose and compatibility boundary

<!-- rule: chart3d.app-owns-calculation class=hard enforced=no tasks=charts-3d -->
A Libertas protocol response or subscription-data payload may carry a
server-prepared three-dimensional chart. The App remains responsible for every
domain calculation, including sampling functions, calculating contours,
projecting data, grouping series, and selecting topology. The client resolves
scales, constructs the declared primitives, lays out guides, presents an
accessible camera, and preserves the ordinary typed-data view.

The language-neutral schema uses five attributes:

| Attribute | Placement | Purpose |
| --- | --- | --- |
| `Chart3D` | A chart-data `List`, unit-carrier `Table`, or composition `Table` | Select one 3D primitive mark or composition. |
| `Chart3DChannel` | A direct scalar field of a 3D chart row | Map the field to geometry, appearance, topology, or structural roles. |
| `Chart3DScale` | A field with one scale-bearing `Chart3DChannel` role | Override automatic scale behavior or share a scale. |
| `Chart3DGuide` | A direct scalar field of a 3D chart row | Configure, suppress, or source an axis, legend, or facet heading. |
| `Chart3DView` | A unit or composition root, subject to physical-scene ownership | Set projection, initial camera, and independently enabled camera interactions. |

The boundary is strict:

- <!-- rule: chart3d.data.no-mixed-node class=hard enforced=yes tasks=charts-3d title="A node never carries both a `Chart3D*` and a `Chart*` attribute" --> a node cannot carry both a `Chart3D*` attribute and a `Chart*` attribute;
- a visible result, carrier, composition, or Union wrapper cannot mix
  two-dimensional and three-dimensional chart children (see [composition children](#32-compositions)
  and [Union wrappers](#33-protocol-union-wrappers));
- <!-- rule: chart3d.boundary.scale-id-namespace class=hard enforced=no tasks=charts-3d --> a `Chart3DScale.id` never resolves to a `ChartScale.id`, even when their
  spelling is identical;
- <!-- rule: chart3d.boundary.no-aliases class=hard enforced=no tasks=client-runtime --> clients never interpret `Chart3DChannel`, `Chart3DScale`, `Chart3DGuide`, or
  `Chart3DView` as aliases of their two-dimensional counterparts; and
- <!-- rule: chart3d.boundary.pre-3d-unchanged class=hard enforced=no tasks=client-runtime --> a schema without `Chart3D*` attributes has exactly its pre-3D behavior and
  final schema representation.

<!-- rule: chart3d.fallback.older-clients class=hard enforced=no tasks=charts-3d -->
An older client does not recognize `Chart3D` as `Chart`, so it never enters its
two-dimensional renderer. An unsupported 3D presentation follows the shared
chart failure policy; the protocol response itself remains accepted.

<!-- rule: chart3d.data.no-hub-handling class=hard enforced=no tasks=charts-3d -->
The Hub has no chart renderer and needs no 3D-specific message, persistence,
cache, or runtime-value handling. It transports the validated attributes and
existing typed values through the ordinary schema and protocol paths.



## 2. Source and generated syntax

TypeScript source uses:

```typescript
/**
 * Samples in three dimensions.
 * @LibertasChart3D(point)
 * @LibertasChart3DView(projection=perspective, azimuth=45,
 *                      elevation=30, zoom=1, fov=45, orbit=true)
 */
export type Samples = Sample[];

export interface Sample {
  /** @LibertasChart3DChannel(x, tooltip) */
  x: number;
  /** @LibertasChart3DChannel(y, tooltip) */
  y: number;
  /** @LibertasChart3DChannel(z, tooltip) */
  z: number;
}
```

Rust source uses:

```rust
#[derive(LibertasExport, LibertasAvroDecode)]
pub struct Sample {
    #[libertas_chart3d_channel(x, tooltip)]
    pub x: f64,
    #[libertas_chart3d_channel(y, tooltip)]
    pub y: f64,
    #[libertas_chart3d_channel(z, tooltip)]
    pub z: f64,
}

#[libertas_chart3d(point)]
#[libertas_chart3d_view(
    projection = perspective,
    azimuth = 45,
    elevation = 30,
    zoom = 1,
    fov = 45,
    orbit = true
)]
pub type Samples = Vec<Sample>;
```

The generated attributes are ordinary schema attributes:

```json
{
  "Attributes": [
    { "Name": "Chart3D", "Value": "point" },
    {
      "Name": "Chart3DView",
      "Value": "projection=perspective,azimuth=45,elevation=30,zoom=1,fov=45,orbit=true"
    }
  ]
}
```

<!-- rule: chart3d.syntax.canonical-once class=hard enforced=yes tasks=charts-3d title="Each node carries at most one of each Chart3D attribute; the nearest replaces the whole value" -->
Every attribute value is closed and canonical. Source-language whitespace and
quoting are removed, duplicate roles are deduplicated where specified, and
properties are written in the fixed orders below. Each schema node may contain
at most one attribute of each of the five names. Attribute inheritance uses the
ordinary nearest-declaration rule: the nearest value replaces the complete
same-named value rather than merging individual roles or properties.

<!-- rule: chart3d.syntax.kind-not-type class=hard enforced=yes tasks=charts-3d title="Chart3DScale source uses kind; a source type property is rejected" -->
`Chart3DScale` uses source property `kind`; the parser result uses `type`,
which Studio compiles into the package's [Display Template](../presentation.md#display-template) as a
`scale` hint and omits from the final schema. A source declaration using
`type` is invalid. Numeric properties use the same
finite shortest-round-tripping binary64 canonical spelling as `ChartScale`.

## 3. Placement and authoritative data

### 3.1 Unit charts

A unit chart is a complete snapshot with this effective shape:

```text
List<Table Row> carrying Chart3D=<mark>
```

<!-- rule: chart3d.data.unit-shape class=hard enforced=yes tasks=charts-3d title="A 3D unit chart is a List of row Tables whose direct scalar fields carry the channels" -->
The `List` carries `Chart3D`. Direct scalar fields of `Row` carry
`Chart3DChannel` and optional `Chart3DScale` and `Chart3DGuide`. An App duplicates
a logical value into separate fields when it needs independently scaled roles.

<!-- rule: chart3d.data.hidden-row-allowed class=hard enforced=no tasks=charts-3d -->
A row field may carry `Hidden=true`. It is chart data, not presentation: the
client keeps it in the retained row value, so a custom chart request can
CopyFrom it. <!-- rule: chart3d.data.hidden-row-field class=hard enforced=yes tasks=charts-3d title="A Hidden row field carries no `Chart3DChannel`, `Chart3DScale` or `Chart3DGuide`" -->
Chart presentation data is never Hidden: a row field carrying `Hidden=true` cannot carry `Chart3DChannel`,
`Chart3DScale`, or `Chart3DGuide`; Studio rejects that chart metadata.

A mark may instead be applied to a carrier table:

```text
Table UnitCarrier carrying Chart3D=<mark> {
    data: List<Table Row>
    transactionContext: any value carrying Hidden=true
    ...
}
```

<!-- rule: chart3d.data.carrier class=hard enforced=yes tasks=charts-3d title="A carrier Table holds exactly one non-hidden List field; every other field is Hidden" -->
`data` is the exactly one non-hidden direct field and is the real chart data.
It does not repeat `Chart3D`; the enclosing table owns the mark. Every other
direct field carries `Hidden=true`. Hidden values may use any otherwise valid
Libertas type. They do not become marks, channels, guide content, topology,
tooltips, interaction identity, accessibility content, or chart resource
usage. The client retains the complete carrier so a later typed request or
`CopyFrom` operation can reuse transaction context.

### 3.2 Compositions

A composition has this effective shape:

```text
Table Composition carrying Chart3D=<composition> {
    childA: List<Table RowA> carrying Chart3D=<mark>
    childB: List<Table RowB> carrying Chart3D=<mark>
    transactionContext: any value carrying Hidden=true
    ...
}
```

<!-- rule: chart3d.compose.children class=hard enforced=yes tasks=charts-3d title="Every visible 3D composition child carries Chart3D, at least one; others are Hidden" -->
Every non-hidden direct child must independently carry `Chart3D`. At least one
such child is required. Other direct fields must carry `Hidden=true`. Children
are traversed in schema order.

### 3.3 Protocol Union wrappers

<!-- rule: chart3d.data.union-wrapper class=hard enforced=yes tasks=charts-3d title="A wrapping Union variant holds exactly one chart field; every sibling carries Hidden" -->
A `Response` or `SubscriptionData` Union variant may carry `Chart3D` itself, or
it may be an unannotated carrier table containing exactly one non-hidden direct
field whose effective type carries `Chart3D`. Every sibling must carry
`Hidden=true`.

<!-- rule: chart3d.data.presentation-root class=hard enforced=no tasks=client-runtime -->
Clients never recursively search for an arbitrary annotated descendant and
never choose among several chart fields. The effective selected variant or its
one permitted direct chart field is the only presentation root. More than one
visible chart field, one visible non-chart field beside a chart, or a mixture
of `Chart` and `Chart3D` invalidates chart presentation.

<!-- rule: chart3d.data.source-tree-authoritative class=hard enforced=no tasks=charts-3d -->
The complete selected variant remains the authoritative source tree even when
the renderer projects its one chart field. Hidden context is never cloned away.

<!-- rule: chart3d.data.title-from-variant class=hard enforced=no tasks=charts-3d -->
The selected `Response` or `SubscriptionData` variant's localized name and
description supply the overall chart title and description, even when the
chart payload is a child field. Child charts within a composition retain their
own localized names and descriptions.

### 3.4 Complete snapshots

<!-- rule: chart3d.snapshot.replace class=hard enforced=no tasks=charts-3d -->
Every response, and every complete subscription snapshot, replaces the prior
chart snapshot. A subscription report may instead extend or update the working
value through the shared mechanisms described in the
[2D snapshot rule](2d.md#32-complete-snapshots). Clients do not otherwise
join, patch, or merge rows, grids, series, or topology. An empty list is a valid empty chart
when its schema is valid. A data-changing interaction sends an ordinary typed
protocol request and receives another complete snapshot. Snapshot replacement
does not imply presentation-camera reset; Section 8.2 defines camera continuity.

## 4. `Chart3D`

<!-- rule: chart3d.mark.one-token class=hard enforced=yes tasks=charts-3d title="Chart3D holds exactly one mark or composition token" -->
`Chart3D` contains exactly one lowercase token:

```text
mark        = "point" | "line" | "bar" |
              "surface" | "wireframe" | "stem"
composition = "layer" | "hconcat" | "vconcat"
chart3d     = mark | composition
```

### 4.1 Primitive marks

<!-- rule: chart3d.mark.xyz-required class=hard enforced=yes tasks=charts-3d title="Every primitive 3D mark requires one x, one y and one z binding" -->
Every primitive requires one `x`, one `y`, and one `z` binding.

| Mark | Additional geometry | Meaning |
| --- | --- | --- |
| <!-- rule: chart3d.mark.point class=hard enforced=yes tasks=charts-3d title="`point` forbids secondary and topology channels" --> `point` | Forbids `x2,y2,z2,u,v` | A three-dimensionally positioned symbol. `size` produces Bubble 3D. |
| <!-- rule: chart3d.mark.line class=hard enforced=yes tasks=charts-3d title="`line` forbids secondary and topology channels" --> `line` | Forbids `x2,y2,z2,u,v` | One or more ordered 3D polylines. |
| `bar` | Banded/value, sparse-column, or complete explicit-cuboid form | An axis-aligned 3D bar or column with independently controlled base extents. |
| <!-- rule: chart3d.mark.surface class=hard enforced=yes tasks=charts-3d title="`surface` and `wireframe` take paired `u,v` or none, and no secondary endpoints" --> `surface` | Continuous `x,y,z`; optional paired `u,v`; no secondary endpoints | Filled rectangular-grid or parameter-grid surface. |
| `wireframe` | Same topology as `surface` | Surface grid edges without filled faces. |
| `stem` | Optional `z2`; forbids `x2,y2,u,v` | A segment from `(x,y,z2)` to `(x,y,z)`, using the resolved z-scale base plane when `z2` is absent. |

<!-- rule: chart3d.mark.structural-roles class=hard enforced=no tasks=charts-3d -->
`tooltip`, `detail`, `key`, `row`, and `column` are available to every mark.
`order` is meaningful for `line` and for deterministic independent-mark paint
and hit-test ties. Surface and wireframe topology is owned by `x,y` or `u,v`,
not by list order; an `order` binding does not change mesh connectivity.

#### Point and line

<!-- rule: chart3d.line.paths class=hard enforced=no tasks=charts-3d -->
`point` and `line` use the mapped location `(x,y,z)`. A null or non-finite
required coordinate suppresses a point and breaks a line path. Line rows are
partitioned first by facet and then by the composite raw `detail` identity.
List order is the default vertex order; an `order` binding sorts ascending
within each path with source order retained for ties. <!-- rule: chart3d.line.unordered-needs-order class=hard enforced=yes tasks=charts-3d title="A 3D `line` on an Unordered List must declare `order`" --> An `Unordered` source
list carrying `line` must declare `order`.

<!-- rule: chart3d.point.axis-kinds class=hard enforced=no tasks=charts-3d -->
Each of x, y, and z may independently be continuous, categorical, or an
explicitly discrete numeric/temporal position. A categorical point axis uses a
point scale unless explicitly banded; the renderer never coerces its localized
label back into geometry.

Point and stem-tip symbols may be camera-facing or volumetric. The complete
portable identity catalog and automatic categorical assignment order is:

```text
circle,square,triangle,diamond,sphere,cube,pyramid,cylinder
```

<!-- rule: chart3d.point.shape-tokens class=hard enforced=no tasks=charts-3d -->
The first four are camera-facing symbols; the final four are plot-space 3D
primitives. `circle` is the default. An automatic shape scale assigns categories
in canonical raw-domain order, cycles through this list, and repeats from
`circle` only after exhausting it. An identity shape must be one of these
tokens; it is never a platform class, path, model, image, or URL. One client
cannot substitute a native-only shape or change this assignment order.

<!-- rule: chart3d.point.size class=hard enforced=no tasks=charts-3d -->
`size` is a finite non-negative relative magnitude. Zero suppresses a point or
stem-tip marker without removing its row from tooltips or accessibility. An
automatic size scale preserves authored zero and chooses a responsive positive
output range for positive values from the plot dimensions, available display
area, and accessibility settings. The schema imposes no fixed projected-radius
range. Numeric identity size is likewise finite and non-negative. For a
camera-facing symbol it controls projected size; for a volumetric symbol it
controls plot-space diameter and therefore participates in perspective. The
resolved shape determines that interpretation consistently on every client.
This permits both screen-stable Bubble 3D symbols and volume-calibrated solid
symbols without making either model mandatory.

#### Bar

<!-- rule: chart3d.bar.forms class=hard enforced=yes tasks=charts-3d title="A 3D bar is an explicit cuboid, a continuous-z sparse column, or the two-band shorthand" -->
A bar has one of three deterministic forms. Classification is unambiguous:
all six positions select the explicit cuboid; otherwise a continuous z selects
the normal x/y-base sparse column; every remaining valid shorthand must satisfy
the legacy two-band/one-value rule. A z-oriented legacy-shaped row therefore
has the same geometry as a normal sparse column and follows the normal form.

The legacy banded/value form has:

- two primary axes with effective `band` scales; and
- one continuous value axis, optionally with its matching secondary endpoint.

<!-- rule: chart3d.bar.legacy-baseline class=hard enforced=no tasks=charts-3d -->
The value axis may be x, y, or z. When its secondary is present, both endpoints
are explicit and either order is valid. Otherwise the client uses the resolved
scale base plane: zero when zero is representable and included in the resolved
domain, or the lower resolved domain boundary otherwise. The sole continuous
axis identifies the value axis, so this form may point along x, y, or z without
another orientation property.

The normal sparse-column form uses x and y as base positions and z as the value
axis:

```text
x[,x2],y[,y2],z[,z2]
```

<!-- rule: chart3d.bar.sparse-column class=hard enforced=no tasks=charts-3d -->
Each base axis may be continuous or discrete. A band base starts with its full
domain-slot bandwidth. An explicit point-scale base, or a continuous base
without a secondary, derives a centered interval from adjacent distinct mapped
positions in the same facet/detail group. Its available width is the smallest
positive mapped gap to either neighbor, or the sole gap at an endpoint. An
isolated position uses one tenth of the mapped axis length. Scale `padding` is
then applied to that band, point, or inferred continuous interval. A continuous
base with a matching secondary instead uses the authored interval, and padding
has no effect on it. `z2` is the authored baseline. Without `z2`, the baseline
is the same resolved scale base plane described above. Positive log domains,
all-positive or all-negative linear domains, and temporal domains remain valid
without `z2`; they use their lower resolved boundary.

The explicit-cuboid form requires all six positions:

```text
x,x2,y,y2,z,z2
```

<!-- rule: chart3d.bar.cuboid class=hard enforced=yes tasks=charts-3d title="An explicit cuboid uses six continuous positions whose secondaries match their primaries" -->
Every axis is continuous; each secondary has the same effective scalar class,
unit, and scale as its primary. Endpoint order is not semantic. No baseline or
width is invented.

The sparse-column form may contain any subset of the three secondaries. They
are independent: `x2` and `y2` author continuous base extents, while `z2`
authors the baseline. In the banded/value form, only the sole continuous value
axis may have a secondary; band bases cannot. The explicit-cuboid form requires
all three. See [secondary-channel compatibility](#51-multiplicity) and the
[bar forms](#bar).

<!-- rule: chart3d.bar.size-shape class=hard enforced=no tasks=charts-3d -->
`size` on a bar is a finite non-negative per-row multiplier applied to both base
extents after explicit/inferred extent and padding resolution; zero produces a
degenerate bar without removing its source row, and the multiplier never
changes the value endpoint or baseline. Automatic shape scales use the portable
bar-shape order `box,cylinder,pyramid,sphere`, cycling only after all four.
Identity bar shapes use the same four-token catalog; the primitive is aligned
between the baseline and value endpoint and scaled to the two resolved base
extents. The default is `box`.

#### Stem

<!-- rule: chart3d.stem.geometry class=hard enforced=yes tasks=charts-3d title="`stem` takes only `z2` as a secondary, no topology, and a continuous `z`" -->
`stem` uses `(x,y,z)` as its tip and `(x,y,z2)` as its base. `x2` and `y2` are
never accepted. The z binding is continuous. With no `z2`, the base is the
resolved z-scale base plane: zero when representable and included, otherwise
the lower resolved domain boundary. With `z2`, both z fields are compatible
continuous values. The x and y positions retain the general continuous or
discrete position compatibility from Section 5.2. `size`, `shape`, and fill
roles add a marker at the tip; omitting them produces the path alone.

### 4.2 Appearance applicability

Appearance is closed rather than renderer-dependent:

| Mark | Accepted appearance roles |
| --- | --- |
| `point` | `color,fill,stroke,opacity,fillOpacity,strokeOpacity,size,shape,strokeWidth,strokeDash` |
| `line` | `color,stroke,opacity,strokeOpacity,strokeWidth,strokeDash` |
| `bar` | `color,fill,stroke,opacity,fillOpacity,strokeOpacity,size,shape,strokeWidth,strokeDash` |
| `surface` | `color,fill,stroke,opacity,fillOpacity,strokeOpacity,strokeWidth,strokeDash` |
| `wireframe` | `color,stroke,opacity,strokeOpacity,strokeWidth,strokeDash` |
| `stem` | `color,fill,stroke,opacity,fillOpacity,strokeOpacity,size,shape,strokeWidth,strokeDash` |

<!-- rule: chart3d.appearance.closed class=hard enforced=yes tasks=charts-3d title="Each 3D mark accepts only the appearance roles in its table row" -->
An appearance role outside its row is invalid rather than ignored. Existing
V1 paint palettes, identity-token validation, opacity ranges, stroke-width
ranges, dash tokens, theme contrast rules, the
[Display Template palette](2d.md#display-template-palette), and the
requirement that paint not be the sole carrier of meaning apply unchanged, but through the independent
`Chart3DChannel` and `Chart3DScale` attributes.

<!-- rule: chart3d.appearance.color-default class=hard enforced=no tasks=charts-3d -->
`color` supplies the mark-default paint: point fill and boundary, line,
wireframe, and stem stroke, or bar and surface fill. Explicit `fill` or `stroke`
overrides that component. Stroke metadata alone never creates a boundary when
neither `stroke` nor an applicable `color` resolves one.

<!-- rule: chart3d.appearance.stem-marker class=hard enforced=no tasks=charts-3d -->
For `stem`, `size` and `shape` describe a marker at the tip, while `fill` and
`fillOpacity` describe that marker's interior. The stem segment remains the
stroke component. A renderer with no native impulse-marker primitive constructs
the same result from a segment and point marker; that lowering is not visible
in schema or interaction semantics.

### 4.3 Compositions

| Token | Meaning |
| --- | --- |
| `layer` | Draw every child in one depth-tested 3D scene. |
| `hconcat` | Place independent 3D scenes left to right. |
| `vconcat` | Place independent 3D scenes top to bottom. |

<!-- rule: chart3d.compose.layer-children class=hard enforced=yes tasks=charts-3d title="A 3D layer holds units or layers, never a direct concat" -->
A `layer` child is a unit chart or another `layer`. A direct concat child is
invalid because it does not share one physical scene. Nested layers flatten
depth-first in schema order. Concat children may be units, layers, or further
concats, allowing explicit grids.

<!-- rule: chart3d.render.depth-ties class=hard enforced=no tasks=client-runtime -->
Depth normally determines visibility. When two fragments have equal mapped
depth, the later flattened child wins, then the later canonical row. A renderer
may use a bounded render-only depth bias to implement that tie; it must not
change data coordinates, scales, domains, tooltip values, hit identities, or
requests. This rule makes server-emitted contour lines layered after a surface
stable rather than subject to platform-specific z-fighting.

<!-- rule: chart3d.facet.scenes class=hard enforced=no tasks=client-runtime -->
`row` and `column` create facets. Every facet is an independent camera scene
using the unit or enclosing layer's view. A layer may lower compatible children
into one facet grid only when their ordered raw facet domains are identical;
otherwise it uses independent grids or follows the chart failure policy in §12.6.

## 5. `Chart3DChannel`

<!-- rule: chart3d.channel.canonical-order class=hard enforced=no tasks=charts-3d -->
The value is a comma-separated set of roles. Studio deduplicates and emits them
in this order:

```text
x,x2,y,y2,z,z2,u,v,
color,fill,stroke,opacity,fillOpacity,strokeOpacity,
size,shape,strokeWidth,strokeDash,
tooltip,detail,order,key,row,column
```

The vocabulary is:

| Group | Roles | Meaning |
| --- | --- | --- |
| Position | `x,x2,y,y2,z,z2` | Three primary coordinates and paired endpoints. |
| Surface topology | `u,v` | Parameter-grid adjacency; not visual scales. |
| Paint | `color,fill,stroke` | Default paint or explicit component paint. |
| Paint strength | `opacity,fillOpacity,strokeOpacity` | Overall or component alpha. |
| Symbol/stroke | `size,shape,strokeWidth,strokeDash` | Point/bar geometry or path appearance. |
| Inspection | `tooltip` | Values included in the typed inspector. |
| Structure | `detail,order,key` | Series grouping, path/paint order, and stable row identity. |
| Facet | `row,column` | Small-multiple partitioning. |

### 5.1 Multiplicity

<!-- rule: chart3d.channel.singletons class=hard enforced=yes tasks=charts-3d title="Only `tooltip` and `detail` may repeat across fields of one 3D unit" -->
All roles except `tooltip` and `detail` are singletons within a unit.
`tooltip` and `detail` may occur on multiple fields; multiple details form a
composite raw series key in schema field order.

<!-- rule: chart3d.channel.multi-role class=hard enforced=yes tasks=charts-3d title="A field with `Chart3DScale` has exactly one scale-bearing role, and never both `u` and `v`" -->
A field may drive multiple scale-bearing roles when every one uses its implicit
scale; for example, `z,color,tooltip` can position and color by the same raw
value. A field-level `Chart3DScale` still requires exactly one scale-bearing
role because that attribute has no target property with which to disambiguate
the scale. A `source=scale` or `source=none` guide does have a `target`, so it
may select one matching role on a multi-role field. A field may also combine
scaled roles with non-scale roles such as `tooltip`, `detail`, `order`, `key`,
or one topology role. Thus `x,u,tooltip` is valid, and a field-level
`Chart3DScale` unambiguously configures x because u is not scale-bearing. A
field may not carry both `u` and `v`.

<!-- rule: chart3d.channel.secondary-topology class=hard enforced=yes tasks=charts-3d title="Secondaries need their primary and own no scale; `u` and `v` own no scale or guide" -->
`x2`, `y2`, and `z2` use the corresponding primary scale. A secondary cannot
carry `Chart3DScale` or exist without its primary. `u` and `v` never own a
scale or guide; a field carrying either topology role cannot declare
`Chart3DScale` for that topology role. A scale on the same field may configure
one other scale-bearing role such as x, y, or z.

### 5.2 Scalar compatibility

<!-- rule: chart3d.channel.scalar-compat class=hard enforced=yes tasks=charts-3d title="Each 3D role accepts only the scalar types in the compatibility table" -->
V1 imports the typed scalar identities, formatting, categorical ordering, and
provenance rules from the 2D contract. Compatibility is closed:

| Role | Permitted effective scalar values |
| --- | --- |
| `x,y,z` | Continuous or explicitly discrete Number, DateOnly Number, DateTime, TimeOnly, or a categorical V1 chart scalar |
| `x2,y2,z2` | A continuous ordinary Number, DateOnly Number, DateTime, or TimeOnly compatible with its primary |
| `u,v` | Finite Number without DateOnly or a topology-owned visual scale |
| `row,column` | Categorical, or an allowed explicitly banded discrete numeric/temporal scalar |
| `color,fill,stroke` | Continuous Number, categorical value, or validated identity String |
| `opacity,fillOpacity,strokeOpacity,size,strokeWidth` | Ordinary Number |
| `shape,strokeDash` | Categorical or validated identity String; an allowed discrete integral Number may opt into ordinal behavior |
| `tooltip,detail,order,key` | Any V1 chart scalar |

<!-- rule: chart3d.channel.surface-continuous class=hard enforced=yes tasks=charts-3d title="`surface` and `wireframe` need continuous `x`, `y` and `z` positions" -->
`surface` and `wireframe` additionally require continuous `x,y,z`; their
geometry cannot use categorical, ordinal, band, or point position scales.
An omitted bar or stem endpoint uses the resolved value-scale base plane and
therefore remains valid for numeric, logarithmic, or temporal continuous axes.

<!-- rule: chart3d.channel.unit-format class=hard enforced=yes tasks=charts-3d title="Paired endpoints and shared 3D scales use the same PhysicalUnit and Format" -->
Physical units and `Format` remain presentation metadata on the same typed raw
value. V1 performs no unit conversion. Paired endpoints and shared scales have
identical effective PhysicalUnit and compatible Format requirements.

### 5.3 Identity, order, and missing values

<!-- rule: chart3d.identity.raw-values class=hard enforced=no tasks=charts-3d -->
Grouping, uniqueness, category domains, series identity, and key identity use
canonical raw typed values, never localized labels. Floating negative zero is
canonicalized to positive zero for identity. Non-finite values cannot become
keys or topology indices.

<!-- rule: chart3d.identity.unique-keys class=hard enforced=no tasks=charts-3d -->
A non-null `key` is unique within its unit, facet, and detail tuple. Duplicate
keys invalidate 3D presentation. Without a key, a source row index is identity
only for the current complete snapshot.

<!-- rule: chart3d.identity.null-values class=hard enforced=no tasks=charts-3d -->
Null or non-finite required point/bar/stem coordinates suppress that row. They
break line paths. For surface and wireframe they remove the vertex and every
incident face or edge. Null optional appearance uses the theme-native default.
Null tooltips are omitted.

## 6. `Chart3DScale`

<!-- rule: chart3d.scale.extends-chart-scale class=hard enforced=no tasks=charts-3d -->
`Chart3DScale` starts with the `ChartScale` property and type vocabulary, adds
portable logarithm-base control, automatic-domain growth, and 3D bar-base
`padding`, and participates only in a `Chart3D` tree:

```text
property-order = id,type,logBase,min,max,mid,zero,reverse,
                 growMin,growMax,padding

type = linear | log | symlog | sqrt | time | utc |
       ordinal | band | point | identity
```

<!-- rule: chart3d.scale.properties class=hard enforced=yes tasks=charts-3d title="Chart3DScale keys are unique; `logBase` only with `log`; `padding` only on a bar base" -->
The generated grammar is a nonempty comma-delimited list of unique
`key=value` properties in property order. `id` is a canonical schema
identifier. `zero` and `reverse` are Boolean. Numeric or temporal bounds use
the existing type-specific canonical representation. `logBase` is a finite
number greater than 1, is valid only with `type=log`, and defaults to 10.
`padding` is a finite number in `0 <= padding <= 1`. It is valid only on a band,
point, or continuous position scale used as a `bar` base. It removes that
fraction from the resolved domain-slot or neighbor-derived interval, centered
equally on both sides; `padding=1` intentionally produces a degenerate
zero-width base rather than invalidating the schema. The default bar-base
padding is `0.3`. An explicit matching secondary endpoint is authoritative, so
padding has no effect on that authored extent.

<!-- rule: chart3d.scale.growth class=hard enforced=yes tasks=charts-3d title="`growMin` and `growMax` are non-negative and only on a continuous `x`, `y` or `z` scale" -->
`growMin` and `growMax` are independent finite non-negative fractions for an
automatic continuous primary x, y, or z domain. They default to zero and have
no artificial upper bound. After resolving the ordinary non-degenerate domain,
the client maps it into the scale's continuous coordinate, subtracts
`growMin * span` from an automatic lower side, adds `growMax * span` to an
automatic upper side, and maps back. This gives logarithmic axes multiplicative
growth while preserving positivity. An explicit `min` or `max` remains
authoritative on that side; the corresponding growth property is accepted but
has no effect. Growth is not bar width and is independent of `padding`.

Absent scale types infer as follows:

- <!-- rule: chart3d.scale.infer-number class=hard enforced=no tasks=charts-3d --> ordinary Number and TimeOnly positions use `linear`;
- <!-- rule: chart3d.scale.infer-temporal class=hard enforced=no tasks=charts-3d --> DateOnly and DateTime positions use `time`;
- <!-- rule: chart3d.scale.infer-categorical class=hard enforced=no tasks=charts-3d --> categorical point/line/point-cloud positions use `point`;
- <!-- rule: chart3d.scale.infer-bar-base class=hard enforced=no tasks=charts-3d --> discrete bar bases use `band`, continuous sparse-column bases keep their
  continuous position scale;
- <!-- rule: chart3d.scale.infer-paint class=hard enforced=no tasks=charts-3d --> categorical paint and shape use `ordinal`; and
- <!-- rule: chart3d.scale.infer-numeric-appearance class=hard enforced=no tasks=charts-3d --> numeric paint, opacity, size, and stroke width use `linear`.

<!-- rule: chart3d.scale.identity class=hard enforced=yes tasks=charts-3d title="`identity` scales apply only to literal appearance values, never positions, facets or topology" -->
`identity` is not valid for `x,y,z`, facets, or topology. It is valid only for
the same bounded literal appearance values admitted by the 2D V1 contract and
has no implicit guide.

<!-- rule: chart3d.scale.domains class=hard enforced=yes tasks=charts-3d title="3D scale bounds, `zero`, log and sqrt restrictions match `ChartScale`" -->
Automatic domains use every finite, type-valid contributing value before
faceting and viewport culling. Secondary values contribute through their
primary scale. Explicit min/max/mid, zero inclusion, transform restrictions,
DateOnly and DateTime bounds, TimeOnly range, log positivity, sqrt
non-negativity, and categorical ordering are identical to `ChartScale`.

### 6.1 Shared scales

<!-- rule: chart3d.shared-scale.auto-layer class=hard enforced=yes tasks=charts-3d title="Unnamed `x`, `y`, `z` scales in one 3D layer share automatically and must be compatible" -->
Outside a physical layer, an unnamed scale is local to one unit. Within one
flattened `layer`, compatible unnamed x, y, or z position scales automatically
share by role and union their domains. This is the ordinary multiple-series
case and requires no artificial scale identifier. A categorical point scale
and band scale are compatible in such a group: the common scale is banded when
any contributing bar requires a band, and point/line coordinates use band
centers. The same point-to-band promotion applies to an explicitly named
positional shared-scale group.

<!-- rule: chart3d.shared-scale.explicit-id class=hard enforced=yes tasks=charts-3d title="3D scales sharing an `id` must be compatible and must not conflict on domain or `logBase`" -->
A nonempty `id` explicitly shares a scale inside the containing `Chart3D`
composition. An explicit different id opts out of automatic layer sharing.
Contributors must have the same position/appearance family, effective scalar
and presentation class, compatible inferred or explicit type, PhysicalUnit,
Format, and output-range class. Explicit domain and logarithm-base properties
cannot conflict. Domain contributions union independently of composition
traversal order. Shared automatic `growMin` and `growMax` resolve independently
to the greatest requested fraction, so adding a series never removes requested
margin. `padding` remains local to each bar binding and may differ between
series sharing the same position scale.

<!-- rule: chart3d.shared-scale.no-concat class=hard enforced=no tasks=charts-3d -->
A same-spelled `ChartScale.id` is unrelated. A scale never crosses a concat
pane. Compatible scales in one flattened layer may share one guide.

## 7. `Chart3DGuide`

The grammar is named, closed, and has no position property:

```text
canonical-order = target,source,title,domain,ticks,labels,grid,tickCount,tickStep

target = x | y | z |
         color | fill | stroke |
         opacity | fillOpacity | strokeOpacity |
         size | shape | strokeWidth | strokeDash |
         detail | row | column

source = scale | value | span | none
component = auto | none
```

<!-- rule: chart3d.guide.properties class=hard enforced=yes tasks=charts-3d title="Chart3DGuide needs `target` and `source`; `tickCount` and `tickStep` are exclusive x/y/z scale options" -->
`target` and `source` are required. Optional `title`, `domain`, `ticks`,
`labels`, and `grid` values are `auto` or `none`. `tickCount` is a positive safe
integer maximum for generated major ticks. `tickStep` is a positive finite
major-tick interval in the target scale's native interval convention: raw
mapped numeric units for continuous axes, exponent stride for log, seconds for
DateTime and TimeOnly, and days for DateOnly. On a band or point scale it is a
positive safe-integer stride through the ordered category domain. `tickCount`
and `tickStep` are mutually exclusive and are valid only for x/y/z with
`source=scale`. Keys cannot repeat.

### 7.1 Placement

<!-- rule: chart3d.guide.scale-placement class=hard enforced=yes tasks=charts-3d title="A scale or none guide sits on the field owning its target primary role" -->
`source=scale` and ordinary `source=none` are declared on the field owning the
matching primary scale-bearing role. The target must equal one scale-bearing
role on that field and disambiguates a field with multiple implicit roles. The
detail exception is described below. A secondary endpoint and `u/v` cannot own
a guide.

<!-- rule: chart3d.guide.label-field class=hard enforced=yes tasks=charts-3d title="`value` and `span` guides sit on a guide-label field without scale-bearing roles" -->
`source=value` and `source=span` are declared on a direct scalar guide-label
field. That field has no scale-bearing `Chart3DChannel` and no `Chart3DScale`,
though it may additionally carry `tooltip`, `detail`, or `key`.

- <!-- rule: chart3d.guide.value-candidates class=hard enforced=no tasks=charts-3d --> `value` for `x,y,z` creates candidates at the target coordinate of eligible
  rows.
- <!-- rule: chart3d.guide.span class=hard enforced=yes tasks=charts-3d title="A 3D `span` guide targets `x`, `y` or `z` and needs the matching secondary" --> `span` is valid only for `x,y,z` and requires the corresponding
  `x2,y2,z2` binding in the same unit. It describes the explicit continuous
  interval; a band extent or invented zero is not an authored span.
- <!-- rule: chart3d.guide.detail-value class=hard enforced=yes tasks=charts-3d title="A `detail` value guide sits on a `detail` field; one detail guide per unit" --> `value` with `target=detail` is declared on one field carrying `detail`. Its
  candidates are the complete composite detail tuples, not just that field.
  The client joins the localized component labels in schema order using its
  localized list grammar. At most one detail guide exists in a unit.
- <!-- rule: chart3d.guide.detail-none class=hard enforced=no tasks=charts-3d --> `none` with `target=detail` suppresses an otherwise requested detail guide.
- <!-- rule: chart3d.guide.appearance-sources class=hard enforced=yes tasks=charts-3d title="Appearance and facet 3D guides accept only source `scale` or `none`" --> appearance and facet targets accept only `scale` or `none`.

### 7.2 Components and inference

| Guide | Targets | Components |
| --- | --- | --- |
| 3D axis | `x,y,z` | `title,domain,ticks,labels,grid` |
| Value/span axis labels | `x,y,z` | `title,domain,ticks,labels,grid` |
| Appearance legend | Appearance targets | `title,ticks,labels` |
| Series legend | `detail` | `title,ticks,labels` |
| Facet heading | `row,column` | `title,labels` |
| Suppressed | Every target | None |

<!-- rule: chart3d.guide.components class=hard enforced=yes tasks=charts-3d title="A 3D guide declares only its kind's components; `source=none` declares none" -->
Unsupported properties are errors rather than inert. `source=none` permits no
optional component property. A value/span guide defaults `title=none` and its
other supported components to `auto`. Other guide components default to
`auto`; unsupported components default to `none`.

<!-- rule: chart3d.guide.inference class=hard enforced=no tasks=charts-3d -->
Without an explicit declaration, every non-identity primary binding has
`source=scale` with defaults. Identity appearance has `source=none`. One or more
`detail` bindings infer one composite `source=value` series legend with default
components. An explicit detail guide configures that legend or suppresses it
with `source=none`; it never creates separate legends for individual fields of
the composite detail tuple.

<!-- rule: chart3d.guide.axis-layout class=hard enforced=no tasks=client-runtime -->
3D position axes are placed on camera-visible plot-box edges by a deterministic
client layout; the schema does not expose unstable edge or screen-side choices.
`domain` is the axis line, `ticks` and `labels` are typed scale candidates, and
`grid` extends them over the applicable plot-box faces. Appearance legends use
the responsive full-screen orientation policy in Section 12.5; previews retain
the inherited bottom-shell form. Facet headings remain attached to their pane
grid in every orientation.

### 7.3 Position-guide cadence and thinning

<!-- rule: chart3d.cadence.ladder class=hard enforced=no tasks=client-runtime -->
An automatic continuous x, y, or z `source=scale` guide—that is, one without
`tickStep`—adopts the complete deterministic cadence contract in
[Section 6.4.5 of the general chart specification](2d.md#645-scale-ticks-labels-and-typed-formatting).
Quantitative guides use its closed `{1,2,5} * 10^k` ladder, temporal guides use
its closed typed ladder, and every probe retains its `4096`-candidate bound. The
resolved domain endpoints bound an ordinary aligned tier but do not enter it
unless that lattice generates their exact identity. If no ordinary tier wins,
the priorities-plus-resolved-endpoints terminal tier follows. A nondegenerate
automatic tier with only one candidate cannot win over that terminal endpoint
pair; only a degenerate domain has one automatic raw candidate.

<!-- rule: chart3d.cadence.settled-pitch class=hard enforced=no tasks=client-runtime -->
Cadence selection occurs only after the camera, plot box, and viewport have
settled. A quantitative guide projects every candidate onto the final
camera-visible axis edge and selects the first ladder tier whose adjacent
projected anchors meet the general fixed pitch: `64` logical points for a
screen-horizontal edge, the greater of `40` logical points and `1.4` times the
effective caption-font line height for a screen-vertical edge, or `40` logical
points when labels are suppressed. For a quantitative transform, an
edge-length-derived candidate count alone is not sufficient.

<!-- rule: chart3d.cadence.temporal-probe class=hard enforced=no tasks=client-runtime -->
A temporal guide instead derives its fixed-pitch capacity from that settled
projected edge and uses the general ICU-free nominal-width probe. It constructs
the exact UTC or local calendar run only once, after selecting the cadence.
Calendar or daylight-saving variation may make that run denser than its nominal
probe geometry; the selected-run thinning rule below handles the difference
without selecting another cadence or replacing it with terminal endpoints.
Neither probe performs localization, text formatting, shaping, wrapping, or
measurement, and the temporal probe does not construct a calendar, resolve a
time zone, or invoke ICU.

<!-- rule: chart3d.cadence.tickstep-tickcount class=hard enforced=no tasks=charts-3d -->
`tickStep` is an authored semantic cadence and therefore bypasses the automatic
ladder. It generates only values aligned to that interval in the native
convention defined above; it does not synthesize a non-aligned domain endpoint.
`tickCount` cannot accompany `tickStep`. Without `tickStep`, `tickCount` only
lowers the automatic tier's viewport-derived candidate capacity; it never asks
for exactly that many values, invents evenly spaced samples, or replaces the
required nondegenerate terminal endpoint pair. Thus `tickCount=1` does not turn
a nondegenerate automatic guide into a one-label summary: if its essential
terminal candidates cannot fit, ordinary guide fallback applies.

<!-- rule: chart3d.cadence.one-plan class=hard enforced=no tasks=client-runtime -->
One settled camera-and-viewport state owns one immutable cadence plan. Camera
gesture frames transform that plan with the marks and do not iterate the
ladder; a changed projected-geometry key selects a replacement only when the
camera or viewport settles. Locale and final label width never select a
cadence. After selection, the client constructs exactly one bounded visible
candidate run and formats the complete run deterministically through the typed
rules in the general contract, including its cadence-derived whole-run numeric
precision guard.
It never formats candidates one at a time or formats a rejected tier.

<!-- rule: chart3d.cadence.thinning class=hard enforced=no tasks=client-runtime -->
Final localized text can still require visual thinning. The client tests the
complete anchored power-of-two tiers with strides `1,2,4,8,...`, anchored at
candidate index zero and always retaining the last semantic candidate. It
selects the first tier whose final projected label boxes fit with at least four
logical points of separation. A removed candidate's enabled label, tick, and
grid paint are removed atomically; retained accessibility text remains
unabridged and exposes that exact selected semantic run rather than rebuilding
a default-capacity run independently of the settled camera. This paint-only
thinning never changes or reprobes the selected automatic cadence,
never changes an authored `tickStep`, and never substitutes a different
candidate ladder merely because fewer labels remain visible.

### 7.4 Other numeric guide runs

<!-- rule: chart3d.cadence.legend-run class=hard enforced=no tasks=client-runtime -->
A continuous numeric appearance `source=scale` guide adopts the general
continuous-legend cadence on its exact 160-logical-point rail. Candidate
selection uses the applicable closed automatic ladder and its bounds before
any localization or formatting. The client then formats the complete selected
candidate run, including every retained essential endpoint, as one set. An
appearance legend cannot derive its semantic cadence from an incidental plot
axis or format each rail label independently.

<!-- rule: chart3d.cadence.numeric-format class=hard enforced=no tasks=client-runtime -->
Numeric `source=value` and `source=span` guides retain their authored row
candidate identities and therefore do not invoke an automatic scale ladder.
They still format each complete bounded candidate run as one set. For every 3D
numeric guide run, a nonempty authored `Format` is authoritative. Otherwise the
client uses the general cadence-derived whole-run precision rule: authored
`tickStep`, selected automatic quantitative cadence, associated scale cadence,
or the normalized smallest positive adjacent gap, in that order as applicable.
It never starts from a generic 15-significant-digit default and never chooses
precision independently per label. The declared value remains in its authored
`PhysicalUnit`; after the one numeric formatting pass, the client attaches that
provided unit with the general CLDR rule and performs neither automatic unit
conversion nor a second rounding pass. A client must not use a per-value
platform-default number conversion for visible or accessibility guide text.

### 7.5 Layer ownership

<!-- rule: chart3d.guide.shared-merge class=hard enforced=yes tasks=charts-3d title="Explicit guides on one shared 3D scale group must be identical" -->
Explicitly shared compatible scale IDs and automatically shared unnamed layer
position scales merge their implicit guides. Identical explicit guide
declarations in a shared group merge and replace the group default; conflicting
declarations are invalid. Different-ID scales remain independent.

<!-- rule: chart3d.guide.one-axis-per-layer class=hard enforced=yes tasks=charts-3d title="A 3D layer shows at most one `x`, one `y` and one `z` guide" -->
After shared-guide resolution, one physical 3D layer permits at most one
non-suppressed x guide, one y guide, and one z guide. Because the grammar has no
axis-position property, multiple visible axes in one family are ambiguous.
Independent layered position scales are permitted only when every extra guide
is explicitly suppressed. Appearance legends remain independently resolvable
under the existing guide resource and layout policy.

<!-- rule: chart3d.guide.no-text class=hard enforced=no tasks=charts-3d -->
Guide text comes from existing typed/localized sources. No `Chart3DGuide`
property contains user-visible text.

## 8. `Chart3DView`

<!-- rule: chart3d.view.unique-canonical class=hard enforced=yes tasks=charts-3d title="`Chart3DView` holds unique named properties in canonical order" -->
`Chart3DView` is optional and contains unique named properties in this
canonical order:

```text
projection,azimuth,elevation,zoom,fov,orbit,
aspectX,aspectY,aspectZ,targetX,targetY,targetZ,pan,zoomEnabled
```

The closed value contract is:

| Property | Values | Default |
| --- | --- | --- |
| <!-- rule: chart3d.view.projection class=hard enforced=yes tasks=charts-3d title="`projection` is `perspective` (default) or `orthographic`" --> `projection` | `perspective` or `orthographic` | `perspective` |
| <!-- rule: chart3d.view.aspect class=hard enforced=yes tasks=charts-3d title="`aspectX`, `aspectY`, `aspectZ` are positive ratios, default `1,1,1`" --> `aspectX,aspectY,aspectZ` | Positive finite plot-box ratios | `1,1,1` |
| <!-- rule: chart3d.view.target class=hard enforced=yes tasks=charts-3d title="`targetX`, `targetY`, `targetZ` are finite normalized coordinates, default `0.5`" --> `targetX,targetY,targetZ` | Finite normalized plot-space coordinates | `0.5,0.5,0.5` |
| <!-- rule: chart3d.view.azimuth class=hard enforced=yes tasks=charts-3d title="`azimuth` is any finite degrees, canonicalized modulo 360, default `45`" --> `azimuth` | Any finite degrees; canonicalized modulo 360 | `45` |
| <!-- rule: chart3d.view.elevation class=hard enforced=yes tasks=charts-3d title="`elevation` is any finite degrees, clamped by the client to [-90,90], default `30`" --> `elevation` | Any finite degrees; effective client value clamped to `[-90,90]` | `30` |
| <!-- rule: chart3d.view.zoom class=hard enforced=yes tasks=charts-3d title="`zoom` is a positive finite scalar, default `1`" --> `zoom` | Any positive finite scalar | `1` |
| <!-- rule: chart3d.view.fov class=hard enforced=yes tasks=charts-3d title="`fov` is vertical degrees with 0 < fov < 180, default `45`" --> `fov` | Finite vertical degrees with `0 < fov < 180` | `45` for perspective |
| <!-- rule: chart3d.view.orbit class=hard enforced=yes tasks=charts-3d title="`orbit` is a Boolean, default `true`" --> `orbit` | `true` or `false` | `true` |
| <!-- rule: chart3d.view.pan class=hard enforced=yes tasks=charts-3d title="`pan` is a Boolean, default `true`" --> `pan` | `true` or `false` | `true` |
| <!-- rule: chart3d.view.zoom-enabled class=hard enforced=yes tasks=charts-3d title="`zoomEnabled` is a Boolean, default `true`" --> `zoomEnabled` | `true` or `false` | `true` |

<!-- rule: chart3d.view.canonicalization class=hard enforced=no tasks=charts-3d -->
Azimuth canonicalization produces the equivalent value in
`-180 <= azimuth < 180`; for example, `180` becomes `-180`. Elevation remains
an arbitrary finite schema value for wire compatibility, but the client clamps
the effective authored/default elevation and every transient update to the
closed `[-90,90]` degree interval before camera evaluation. Values below `-90`
therefore behave as `-90`, and values above `90` behave as `90`; the authored
schema value itself is not rewritten. An orthographic view accepts `fov` for
portable shared declarations but ignores it. Omitted properties use the
defaults without being inserted into final schema JSON.

### 8.1 Placement

<!-- rule: chart3d.view.placement class=hard enforced=yes tasks=charts-3d title="A layer root owns its one `Chart3DView`; concat roots give inherited defaults" -->
A standalone unit may carry one `Chart3DView`. In a `layer`, the outer physical
layer root is the sole view owner; child units and nested layers cannot declare
competing views. An `hconcat` or `vconcat` root may carry a default view inherited
by each descendant physical scene that has no nearer view. A child unit or outer
child layer may replace that complete default, and a nested concat may establish
a nearer default for its descendants. This is view inheritance, not camera-state
sharing: every concat child remains an independently interactive scene.

<!-- rule: chart3d.view.invalid-elsewhere class=hard enforced=yes tasks=charts-3d title="`Chart3DView` on a row field, hidden field, function or layer child is rejected" -->
A `Chart3DView` anywhere else, including a row field, hidden field, function,
or a child of a physical layer, is invalid.

### 8.2 Camera semantics

<!-- rule: chart3d.view.coordinates class=hard enforced=no tasks=charts-3d -->
The data coordinate system is right-handed with positive z as up. After scale
mapping, x, y, and z span a fitted plot box whose world dimensions have the
normalized `aspectX:aspectY:aspectZ` ratio. The default remains a cube. Target
coordinates use this normalized post-scale space: each axis cube runs from 0
to 1, the default `0.5,0.5,0.5` is its center, and finite targets outside the
box are permitted.

<!-- rule: chart3d.view.poles class=hard enforced=no tasks=client-runtime -->
`azimuth` rotates around the positive-z axis and `elevation` moves above or
below the x/y plane. Before evaluating the camera, implementations clamp the
effective elevation to the closed `[-90,90]` degree interval. The exact poles
are valid camera states; clients do not substitute an epsilon such as `89` or
`89.9`. At `+90` or `-90`, the client retains authored or transient azimuth and
selects the same deterministic projected-positive-y, then positive-x,
camera-up fallback on every platform. Continuing an outward vertical orbit at
a pole leaves elevation at that pole. Reversing direction moves away
immediately without hysteresis, pole reflection, a 180-degree azimuth change,
or camera roll.

<!-- rule: chart3d.view.zoom-fit class=hard enforced=no tasks=charts-3d -->
At `zoom=1`, the complete resolved plot box fits in the available scene after title,
guide, and safe-area layout. Perspective uses `fov` as its vertical field of
view. Orthographic projection ignores perspective foreshortening and derives
its fitted extent from the same box. Near and far clipping planes are bounded
renderer-derived values and are not schema inputs.

<!-- rule: chart3d.view.capability-gates class=hard enforced=no tasks=client-runtime -->
When `orbit=true`, a `view.orbit` action updates only transient camera azimuth
and elevation, clamping the resulting elevation to `[-90,90]`. When
`zoomEnabled=true`, a `view.zoom` action updates transient positive zoom without
a fixed schema-level cap. When `pan=true`, a `view.pan` action updates the
transient target in normalized plot space. `orbit=false` fixes rotation;
`pan=false` fixes the target; `zoomEnabled=false` disables every user zoom
action while retaining the authored initial `zoom`. These three properties are
independent capability gates, not a selected interaction mode. They do not
change projection or initial camera meaning. `view.reset` returns the complete
camera to the effective authored/default view, including its clamped effective
elevation. Section 12 defines the one universal mapping from touch, visible
controls, keyboard input, and accessibility actions to this vocabulary.

<!-- rule: chart3d.view.snapshot-continuity class=hard enforced=no tasks=charts-3d -->
A complete data snapshot does not by itself reset presentation state. The
client preserves transient camera orientation, zoom, and target while the
presentation-root identity and effective `Chart3DView` remain compatible. It
resets only on an explicit user reset, an authored view change, an incompatible
presentation root, or a non-finite retained target. Presentation-root identity
is the selected response field path plus its effective chart/composition schema,
not the current row contents.

<!-- rule: chart3d.view.camera-presentation-only class=hard enforced=no tasks=client-runtime -->
Camera motion never alters scale domains, topology, source identity, tooltip
values, accessibility order, or request data.

### 8.3 Universal client action vocabulary

<!-- rule: chart3d.actions.vocabulary class=hard enforced=no tasks=client-runtime -->
Every Android and iOS renderer exposes the following semantic actions. The
names are normative for cross-platform fixtures and localization mapping, but
they are client-internal: they are not schema attributes, protocol messages, or
serialized camera data. Every datum or camera action also receives its explicit
physical-scene identity; it never resolves an implicit last-touched scene.

| Action | Meaning |
| --- | --- |
| `inspect.at` | Inspect the nearest eligible visible datum at a screen point. |
| `inspect.clear` | Clear transient datum inspection. |
| `datum.next`, `datum.previous` | Traverse eligible source rows independently of occlusion. |
| `view.orbit` | Apply a continuous screen-space drag to azimuth and clamped elevation without camera roll. |
| `view.orbit.step(left/right/up/down)` | Rotate the visible scene by one discrete screen-relative step. |
| `view.pan` | Apply a continuous screen-plane translation to the camera target. |
| `view.pan.step(left/right/up/down)` | Move the visible scene by one discrete screen-relative step. |
| `view.zoom` | Apply a continuous positive zoom factor about the current target. |
| `view.zoom.step(in/out)` | Apply one discrete zoom factor. |
| `view.reset` | Restore projection, orientation, target, and zoom to the effective authored/default view. |
| `fullscreen.open`, `fullscreen.close` | Push or pop the existing full-screen Chart Explorer destination. |

<!-- rule: chart3d.actions.steps class=hard enforced=no tasks=client-runtime -->
A discrete orbit step is `15` degrees and clamps its resulting elevation to
`[-90,90]`. A discrete pan step inverse-projects a screen displacement equal to
`10` percent of the physical scene viewport width or height at the current
camera-target plane, using the deterministic camera-right/up basis from
Section 8.2, then moves the visible scene in the requested screen direction. A
discrete zoom-in step multiplies zoom by `1.25`; zoom-out divides by `1.25`. A
client stops before producing a non-positive or non-finite zoom or any other
non-finite camera value. Continuous gesture sensitivity and damping may remain
platform-native, but direction and action meaning cannot differ.

<!-- rule: chart3d.actions.labels class=hard enforced=no tasks=client-runtime -->
Client UI uses localized, nontechnical wording: **Rotate view**, **Move view**,
**Zoom in**, **Zoom out**, **Reset view**, **Explore 3D chart**, **Back**,
**Next data point**, and **Previous data point**. `orbit` and `pan` remain schema
and internal terms and are not exposed as untranslated user-facing labels.

<!-- rule: chart3d.view.no-mode class=hard enforced=no tasks=charts-3d -->
There is no camera-operation `mode`, interaction `mode`, selected
rotate-versus-move state, or corresponding schema/wire variable. Pointer count
selects the continuous action, and each tap-only or accessibility control names
its action explicitly. The existing preview-versus-full-screen navigation state
alone determines whether direct chart manipulation is active.

## 9. Surface and wireframe topology

<!-- rule: chart3d.topology.explicit class=hard enforced=no tasks=charts-3d -->
Surface topology is explicit and resource-safe. The client never triangulates
an arbitrary point cloud, runs Delaunay construction, infers nearest neighbors,
or executes a formula.

<!-- rule: chart3d.topology.groups class=hard enforced=no tasks=charts-3d -->
Rows are partitioned first by facet and then by the composite raw `detail`
identity. Each group independently defines one grid.

### 9.1 Rectangular height grid

When `u` and `v` are absent:

- x and y are the two topology coordinates as well as visual positions;
- their distinct finite raw values are sorted by canonical typed ordering;
- <!-- rule: chart3d.grid.unique-pairs class=hard enforced=no tasks=charts-3d --> every present ordered x/y pair has at most one source row;
- <!-- rule: chart3d.grid.holes class=hard enforced=no tasks=charts-3d --> a missing pair is an explicit hole, while a duplicate pair invalidates the
  complete 3D chart; and
- <!-- rule: chart3d.grid.non-uniform class=hard enforced=no tasks=charts-3d --> x and y spacing may be non-uniform.

<!-- rule: chart3d.grid.null-z class=hard enforced=no tasks=charts-3d -->
This form represents an ordinary height map `z=f(x,y)` without copying x/y into
separate index fields. A row with a null or non-finite z retains its unique grid
slot but contributes no finite vertex, so its incident cells form a hole under
Section 9.3.

### 9.2 Parameter grid

When `u` and `v` are present:

- <!-- rule: chart3d.uv.numbers class=hard enforced=no tasks=charts-3d title="`u` and `v` come together and are ordinary Numbers" --> both are required finite ordinary Numbers (see [scalar compatibility](#52-scalar-compatibility)
  and [surface requirements](#41-primitive-marks));
- <!-- rule: chart3d.uv.domains class=hard enforced=no tasks=charts-3d --> each distinct raw domain is sorted by canonical numeric ordering and may be
  negative, fractional, non-uniform, or sparse;
- <!-- rule: chart3d.uv.unique class=hard enforced=no tasks=charts-3d --> `(u,v)` is unique within the facet/detail group;
- <!-- rule: chart3d.uv.positions class=hard enforced=no tasks=charts-3d --> x, y, and z remain arbitrary continuous positions and need not form a height
  function; and
- <!-- rule: chart3d.uv.holes class=hard enforced=no tasks=charts-3d --> a missing `(u,v)` pair creates a hole rather than causing neighbor inference.

<!-- rule: chart3d.uv.app-computes class=hard enforced=no tasks=charts-3d -->
This form represents non-separable, curvilinear, folded, or parameterized
surfaces. The App computes the sample coordinates; `u,v` describe only
adjacency.

### 9.3 Cells

Let `u[i],u[i+1]` and `v[j],v[j+1]` be consecutive entries in the two
canonically sorted distinct topology domains. The corresponding cell is:

```text
a = (u[i],   v[j])
b = (u[i+1], v[j])
c = (u[i+1], v[j+1])
d = (u[i],   v[j+1])
```

<!-- rule: chart3d.cells.triangulation class=hard enforced=no tasks=client-runtime -->
A cell exists only when all four vertices are present and have finite mapped
x/y/z. `surface` triangulates it as `(a,b,c)` and `(a,c,d)`, fixing the diagonal
across platforms. Faces are visible from either side; winding does not remove
data. `wireframe` draws only the four grid edges and never exposes the internal
triangulation diagonal.

<!-- rule: chart3d.cells.lookup class=hard enforced=no tasks=client-runtime -->
Implementations find cells through checked row/index lookup. They do not
allocate an unchecked `u-count * v-count` matrix. Repeated edges are painted
once per grid group. Missing or invalid vertices remove incident cells and
break wire paths.

<!-- rule: chart3d.cells.list-order class=hard enforced=no tasks=charts-3d -->
List order does not affect cells. It remains the stable source order for
tooltips, accessibility, and equal hit-test ties.

## 10. Supported families

<!-- rule: chart3d.family.recipes class=judgment enforced=no tasks=charts-3d -->
The supported family names are recipes over the closed primitive set, not
additional tokens:

| Family | V1 representation |
| --- | --- |
| 3D scatter | `point` with `x,y,z` |
| 3D line | `line` with `x,y,z`; `detail` partitions paths and `order` orders vertices |
| 3D bars/columns | `bar` using x/y sparse or discrete bases plus z value, the any-orientation two-band shorthand, or a complete explicit cuboid |
| Bubble 3D | `point` plus numeric `size` |
| Surface/height map | `surface` using the rectangular x/y grid |
| Wireframe | `wireframe` using either grid form |
| <!-- rule: chart3d.family.contours class=judgment enforced=no tasks=charts-3d --> Contours on surface | A `layer` with `surface` followed by server-emitted `line` children; contour identity/level uses typed `detail` and tooltip fields |
| Non-uniform surface grid | Non-uniform x/y coordinates, or arbitrary finite ordered `u,v` domains for a curvilinear grid |
| <!-- rule: chart3d.family.waterfall class=judgment enforced=no tasks=charts-3d --> Waterfall/spectrum 3D | `line` with one `detail` identity per trace and an `order` field along each spectrum |
| Impulse/stem 3D | `stem`, optionally layered with `point` tips |
| <!-- rule: chart3d.family.math-surface class=judgment enforced=no tasks=charts-3d --> Arbitrary mathematical surface | App code evaluates and samples the function, then emits `surface`; parametric functions use `u,v` |
| 3D point cloud | `point`, optionally with `size,color,detail,key,tooltip` |
| Multiple 3D series | `detail` groups for homogeneous marks or `layer` for independently typed units and mark kinds |

<!-- rule: chart3d.family.no-client-calculation class=hard enforced=no tasks=charts-3d -->
There are intentionally no `bubble`, `contour`, `pointCloud`, `waterfall`, or
formula-expression tokens. Contours are not calculated by the client. An App
that changes a formula or sampling density returns another complete typed
snapshot.

## 11. Localization and visible text

3D semantics have no schema-document dependency:

```text
schema + ScriptValue snapshot -> semantic Chart3DSpec
semantic Chart3DSpec + resolved document? + client resources -> presentation
```

<!-- rule: chart3d.l10n.schema-only class=hard enforced=no tasks=client-runtime -->
Geometry, topology, grouping, scales, keys, and interaction identities use only
schema and raw values. The existing localized package document supplies
optional presentation:

| Existing source | 3D use |
| --- | --- |
| Type `DisplayName` and `Description` | Scene title, help, accessibility context |
| Field/member localized `Name` and `Description` | Axis/legend/tooltip captions and help |
| Enumeration member document | Localized category label and help |
| Libertas object resolver | Dynamic names for Device, VirtualDevice, User, Endpoint, or LanDevice identities |
| Package `StringResources` | Render `FormattedText` Bytes through LMF1 |
| Client localization catalog | Section 8.3 control terms, errors, fallback, camera actions, and accessibility announcement grammar |

<!-- rule: chart3d.l10n.no-wording class=hard enforced=no tasks=charts-3d -->
No `Chart3D*` property contains user-visible wording.

Every visibly rendered row value preserves its semantic source type:

- a closed App vocabulary is an Enumeration;
- App-authored dynamic or parameterized text is `FormattedText` Bytes;
- a Libertas system identity remains its typed object reference;
- only genuine literal domain/user text, such as a proper name or free-form
  annotation, is a plain String; and
- a key-only or guide-less identity appearance String is not rendered text.

<!-- rule: chart3d.l10n.missing-labels class=hard enforced=no tasks=charts-3d -->
Localized labels never replace raw identity, change topology, merge categories,
or affect ordering. A missing optional label is omitted. A missing essential
category/object/formatted-text label invokes the chart failure policy in §12.6
for the complete 3D presentation rather than exposing an internal token.

## 12. Interaction, accessibility, and fallback

### 12.1 Inspection and protocol boundary

<!-- rule: chart3d.interaction.hit-test class=hard enforced=no tasks=client-runtime -->
Hit testing uses final visible, depth-tested geometry. The nearest visible
fragment wins; equal-depth ties use flattened layer and row order. A line or
wire edge resolves to one of its original endpoint rows. A surface cell resolves
to its nearest contributing source vertex. The renderer never synthesizes a
request row.

<!-- rule: chart3d.interaction.inspection-boundary class=hard enforced=no tasks=charts-3d -->
In Chart Explorer, a tap on eligible visible geometry invokes `inspect.at`; a
tap on empty scene space invokes `inspect.clear`. Movement beyond the native
touch-slop threshold cancels the pending tap. Inspection and tooltips retain the
original `ScriptValue`. The projected chart also retains its complete response,
carrier, or selected Union variant so hidden transaction fields remain
available to typed `NextRequest` and `CopyFrom` handling. Inspection is
transient presentation state for the current snapshot; the tap itself never
sends a protocol request.

<!-- rule: chart3d.interaction.camera-no-request class=hard enforced=no tasks=charts-3d -->
Camera movement is presentation-only. It does not send a protocol request,
change the selected row, modify source values, or create a persistent App
state. A data-changing control uses the normal typed protocol interaction and
replaces the snapshot.

### 12.2 Preview and full-screen ownership

<!-- rule: chart3d.preview.gesture-neutral class=hard enforced=no tasks=client-runtime -->
The application-shell rules in
[Runtime endpoint presentation](../endpoint-presentation.md) apply to both
2D and 3D charts. A 3D preview is a live but gesture-neutral summary. Its plot
registers no inspection, orbit, pan, pinch, zoom, double-tap, or long-press
recognizer. A tap on the preview or its localized **Explore 3D chart** action
invokes `fullscreen.open` and always pushes full-screen Chart Explorer. Ordinary
touches that begin over the preview therefore remain available to the containing
workspace scroll.

<!-- rule: chart3d.explorer.ownership class=hard enforced=no tasks=client-runtime -->
Only full-screen Chart Explorer owns direct 3D manipulation, inspection,
tooltips, and camera controls. The application shell's normal Back affordance
invokes `fullscreen.close`, pops exactly that chart destination, and reveals the
retained previous chart or workspace. It is not a second chart control or a
separate Close button. There is no engaged inline presentation, interaction
mode, or mode variable.

<!-- rule: chart3d.explorer.scene-pointer class=hard enforced=no tasks=client-runtime -->
Within Chart Explorer, a pointer sequence belongs to the physical scene in
whose final plot bounds it begins. Concat children and facets remain independent
scenes; the gesture changes only that scene's transient camera. A layer has one
physical scene and therefore one camera. Touches outside a scene remain
available to the Chart Explorer shell and its outer scroller.

### 12.3 Required direct-touch bindings

<!-- rule: chart3d.touch.bindings class=hard enforced=no tasks=client-runtime -->
With screen-reader touch exploration inactive, clients use exactly this V1
mapping inside a full-screen physical scene:

| Touch input | Semantic action |
| --- | --- |
| Tap visible eligible geometry | `inspect.at` for the nearest depth-tested datum. |
| Tap empty scene space | `inspect.clear`. |
| One-finger drag | `view.orbit`; the visible scene follows the drag until elevation reaches its closed pole. |
| Two-finger centroid translation | `view.pan`; the visible scene follows the centroid. |
| Pinch or spread | `view.zoom`; pinch reduces and spread increases zoom about the current target. |
| Simultaneous two-finger translation and span change | Apply enabled `view.pan` and `view.zoom` components together. |

<!-- rule: chart3d.touch.separation class=hard enforced=no tasks=client-runtime -->
One-finger orbit never changes the target or zoom. Two-finger translation never
changes azimuth/elevation. Pinch/spread never changes the target, so an
off-center pinch cannot bypass `pan=false`. The two-finger twist component is
ignored because V1 has no camera roll.

<!-- rule: chart3d.touch.no-other-gestures class=hard enforced=no tasks=client-runtime -->
Double tap, touch-and-hold, edge-origin gestures, and gestures with three or
more fingers have no Chart3D V1 action. In particular, double tap never resets
the camera, and a client dispatches a recognized single tap without delaying it
to await a possible second tap. Touch-and-hold may acquire a future ordinary
context-menu meaning, but it cannot be required for inspection or camera
control. Operating-system navigation and assistive-technology gestures take
priority and clients do not install system-gesture exclusion regions for a
chart. A renderer-library default that assigns any of these inputs differently
must be overridden.

### 12.4 Gesture arbitration and continuity

<!-- rule: chart3d.gesture.arbitration class=hard enforced=no tasks=client-runtime -->
Clients use native touch slop and gesture recognizers and do not capture a
sequence merely because its first pointer went down. Control hit targets,
assistive technology, and system-edge navigation resolve before chart gestures.
For a sequence that reaches the chart:

- <!-- rule: chart3d.gesture.slop class=hard enforced=no tasks=client-runtime --> exceeding touch slop cancels a pending tap before camera movement begins;
- <!-- rule: chart3d.gesture.second-pointer class=hard enforced=no tasks=client-runtime --> the arrival of a second pointer atomically ends one-finger orbit, cancels a
  pending tap, and establishes a new centroid and span without a camera jump;
- <!-- rule: chart3d.gesture.rebase class=hard enforced=no tasks=client-runtime --> any pointer-count change rebases the remaining two-finger transform; after a
  two-finger transform falls back to one pointer, orbit does not resume until
  every pointer has lifted and a new sequence begins;
- <!-- rule: chart3d.gesture.third-pointer class=hard enforced=no tasks=client-runtime --> the arrival of a third pointer cancels chart recognition without changing the
  last finite camera state, and recognition cannot restart until every pointer
  has lifted;
- <!-- rule: chart3d.gesture.disabled-capability class=hard enforced=no tasks=client-runtime --> `orbit=false` ignores and does not consume one-finger drag rather than
  remapping it to pan; `pan=false` ignores the translation component without
  consuming or remapping it; and `zoomEnabled=false` ignores the span-change
  component without consuming or remapping it;
- <!-- rule: chart3d.gesture.both-disabled class=hard enforced=no tasks=client-runtime --> a sequence with both pan and zoom disabled remains available to an eligible
  ancestor recognizer; and
- <!-- rule: chart3d.gesture.cancellation class=hard enforced=no tasks=client-runtime --> cancellation or interruption commits the last finite camera state, produces
  no inertia after cancellation, and never resets the view or emits a request.

<!-- rule: chart3d.gesture.inertia class=hard enforced=no tasks=client-runtime -->
Native damping or post-release inertia is allowed for an otherwise completed
orbit or pan, remains presentation-only, stops on the next input, and is
disabled when the platform's reduced-motion preference is active. Reset uses an
immediate or short non-spatial transition under reduced motion. Auto-rotation
remains excluded.

### 12.5 Visible and accessible controls

<!-- rule: chart3d.controls.scene-local class=hard enforced=no tasks=client-runtime -->
Preview exposes only its Configure action when otherwise applicable and the
action that opens Chart Explorer. Each independently interactive physical scene
owns one scene-local control set in full-screen Chart Explorer; a root, concat,
or facet control never silently targets the last-touched scene. Under the rules
in Section 4.3, an unfaceted unit or layer has one scene, while every resolved
facet is a separate scene. For a root with one physical scene, its scene-local
chrome is also the root chrome.

<!-- rule: chart3d.controls.viewport-class class=hard enforced=no tasks=client-runtime -->
Full-screen scene chrome responds to the current usable viewport after safe
areas, system bars, and persistent application chrome. Let its finite positive
width and height be `W,H`. `H > W` is portrait; `H <= W` is landscape or
square. Clients use this measured viewport classification rather than device
orientation notifications, the assigned plot-box aspect ratio, or a stale
pre-rotation size. Rotation or window resizing re-evaluates only placement; it
does not recreate the physical scene, reset its camera or inspection, replace
its settled guide semantics, or reset either outer scroll position.

<!-- rule: chart3d.controls.portrait class=hard enforced=no tasks=client-runtime -->
In portrait, one full-width scene-local control strip immediately precedes the
physical scene. The client first attempts one complete row. If it does not fit,
the same fixed-order controls remain in one conventional horizontal control
strip; it scrolls only on its control axis and does not wrap into multiple tall
rows. A directional group remains intact and no control is hidden behind a
mode, selector, disclosure, or modal surface. The physical scene follows that
strip at its assigned size. Its appearance and detail guides follow the scene
in the inherited full-width bottom form.

<!-- rule: chart3d.controls.landscape-rail class=hard enforced=no tasks=client-runtime -->
In landscape or square, the client first attempts one trailing scene-local
utility rail beside the physical scene. The rail contains the complete control
set first. Below the controls, complete appearance and detail guide blocks use
their ordinary horizontal form and greedily pack in semantic order from the
physical leading edge to the trailing edge, then wrap downward. The controls
retain their fixed reading order and wrap only between complete groups.
Position guides remain on their deterministic camera-visible plot-box edges,
and facet row/column headings remain attached to their pane grid; neither
enters the utility rail.

<!-- rule: chart3d.controls.rail-admission class=hard enforced=no tasks=client-runtime -->
The client measures the localized controls-width rail with the active
accessibility font and platform touch targets. The controls may use that rail
when the remaining physical scene retains a finite positive viewport and
satisfies the existing `M = min(200,C)` final post-guide plot-width rule, where
`C` is the plot frame remaining after the utility rail and its gap have been
removed. A positive `C < 200` is valid when the final plot retains all of `C`.
Guides use the same right rail whenever it can contain at least one complete
localized guide block without clipping, shrinking or eliding it. Only when it
cannot fit one complete guide do the guides alone fall back to their full-width
horizontal flow below the physical-scene/control row; admitted controls remain
in the trailing rail. In either placement, guide height never reduces the
physical scene's normally assigned full-screen height. Overflow increases the
one outer vertical Chart Explorer scroll extent; it never creates a private
guide scroller or overlays the scene. Concat children and facets apply this
decision independently to their own final pane width and scene-local ownership.

<!-- rule: chart3d.controls.order class=hard enforced=no tasks=client-runtime -->
Each scene's safe control area contains, in fixed reading order when applicable:
the **Rotate view** directional group, the **Move view** directional group,
**Zoom out**, **Reset view**, then **Zoom in**. Each directional group contains
explicit left, right, up, and down buttons mapped to its corresponding `.step`
action. A disabled capability removes its complete directional or zoom group
and leaves no empty slot. **Reset view** is always present and remains operable
as an idempotent action while the transient camera already equals the effective
authored/default view. The application shell's Back affordance remains outside
every scene-local control set.

<!-- rule: chart3d.controls.touch-targets class=hard enforced=no tasks=client-runtime -->
Every visible control has a minimum touch target of `44 x 44` points on iOS and
`48 x 48` density-independent pixels on Android and remains inside platform
safe and system-gesture insets. Controls and guides never overlay the physical
scene. They do not reduce its assigned height; only an admitted landscape
utility rail may consume its cross-axis width after the required post-guide
plot width has been proven.

<!-- rule: chart3d.controls.no-selector class=hard enforced=no tasks=client-runtime -->
The directional groups are directly visible tap-only controls. A client never
replaces them with a Rotate/Move selector, disclosure, modal surface, or hidden
last-used action, so they add no control mode or presentation state. Direction
labels describe movement of the visible scene on screen, not internal camera
motion.

The same localized actions are exposed through the platform accessibility API:

- <!-- rule: chart3d.a11y.rotate-actions class=hard enforced=no tasks=client-runtime --> Rotate view left, right, up, and down when `orbit=true`;
- <!-- rule: chart3d.a11y.move-actions class=hard enforced=no tasks=client-runtime --> Move view left, right, up, and down when `pan=true`;
- <!-- rule: chart3d.a11y.zoom-actions class=hard enforced=no tasks=client-runtime --> Zoom in and Zoom out when `zoomEnabled=true`;
- <!-- rule: chart3d.a11y.reset-action class=hard enforced=no tasks=client-runtime --> Reset view for the explicitly identified physical scene, including while it
  is already at its effective authored/default view;
- <!-- rule: chart3d.a11y.datum-traversal-actions class=hard enforced=no tasks=client-runtime --> Next data point and Previous data point for virtualized source traversal; and
- <!-- rule: chart3d.a11y.explore-back class=hard enforced=no tasks=client-runtime --> Explore 3D chart in preview and the normal Back action in Chart Explorer.

<!-- rule: chart3d.a11y.equivalent-input class=hard enforced=no tasks=client-runtime -->
Screen readers own their normal touch gestures; direct-touch passthrough is not
the only way to operate a chart. Visible buttons and accessibility actions call
the same semantic action implementation, and switch control, voice control,
keyboard, and pointer activation remain equivalent. Keyboard operation uses
the focused named controls and introduces no hidden shortcut or mode. Wheel or
trackpad magnification over a physical scene invokes `view.zoom` when enabled.

<!-- rule: chart3d.a11y.announcement class=hard enforced=no tasks=client-runtime -->
After a discrete camera action, or after a continuous gesture and any permitted
inertia have reached their final settled state, the client announces exactly
one bounded localized summary rather than announcing every animation frame.
The summary identifies the physical scene and contains projection, azimuth,
elevation, and zoom. When pan changed the target, the same completion
announcement includes its normalized x/y/z target as locale-formatted
percentages. Angle values are rounded to the nearest degree and zoom is shown
with at most two locale-formatted fractional digits.

Accessibility additionally provides:

- <!-- rule: chart3d.a11y.descriptions class=hard enforced=no tasks=client-runtime --> localized scene, series, facet, axis, and tooltip descriptions;
- <!-- rule: chart3d.a11y.row-traversal class=hard enforced=no tasks=client-runtime --> a virtualized traversal of eligible source rows independent of occlusion;
- <!-- rule: chart3d.a11y.typed-values class=hard enforced=no tasks=client-runtime --> raw typed values formatted through the same locale, Format, PhysicalUnit,
  object resolver, and LMF1 pipeline as visible output;
- <!-- rule: chart3d.a11y.nonvisual class=hard enforced=no tasks=client-runtime --> a nonvisual indication for every meaning otherwise encoded through paint,
  size, shape, or depth.

<!-- rule: chart3d.a11y.not-sole-carrier class=judgment enforced=no tasks=charts-3d -->
Depth, perspective, lighting, or color can never be the only carrier of domain
meaning.

### 12.6 Fallback

<!-- rule: chart3d.fallback.shared-policy class=hard enforced=no tasks=charts-3d -->
The shared [chart failure policy](2d.md#10-validation-and-fallback) applies to
all 3D presentation failures, including unknown future marks, channels,
scale/view properties, guide tokens, and unsupported valid features.

## 13. Security and resources

<!-- rule: chart3d.limits.no-schema-caps class=hard enforced=no tasks=charts-3d -->
All metadata and data are untrusted, but `Chart3D` adds no fixed schema-validity
cap for composition depth, unit count, facets, guides, tooltip fields, source
rows, point-cloud points, surface vertices, cells, or line vertices. Neither an
ECharts-GL nor a SciChart-compatible shape becomes invalid merely because it
crosses a Libertas-only count. The enclosing ScriptValue transport owns payload
admission, and each client applies its ordinary configurable memory, work, text,
and native-renderer budgets. Exceeding a runtime budget causes cancellation and
invokes the chart failure policy in §12.6; it never retroactively makes the schema or
protocol response invalid.

<!-- rule: chart3d.limits.cancellation class=hard enforced=no tasks=client-runtime -->
A superseded source, schema, locale, size, or camera query cancels pending work.
Visible level of detail does not change source domains, tooltips, requests,
accessibility, or the ordinary data view. Non-finite geometry is suppressed;
recoverable allocation or rendering failures follow the chart failure policy in §12.6.
Diagnostics remain bounded and do not dump complete payloads.

<!-- rule: chart3d.limits.no-executable class=hard enforced=no tasks=charts-3d -->
The contract admits no executable expressions, arbitrary field paths, HTML,
URLs, remote images, shader text, material programs, platform objects, model
assets, client-side joins, client topology inference, or arbitrary JSON option
blobs.

## 14. Validation

<!-- rule: chart3d.validation.layers class=hard enforced=no tasks=charts-3d -->
Studio checks the full schema contract after language extraction and dependency
resolution. Clients check the effective schema and returned values when
presenting a chart. Invalid or unsupported presentation follows
[the chart failure policy](#126-fallback); it does not alter the response payload.

## 15. Cross-platform rendering and Hub behavior

<!-- rule: chart3d.render.shared-semantics class=hard enforced=no tasks=client-runtime -->
Android and iOS may use different native graphics libraries, but they share:

- schema parsing and validation;
- mark geometry and bar/stem lowering;
- scale inference, domains, grouping, ordering, and topology;
- fixed grid triangulation and wire edges;
- flattened layer order and equal-depth ties;
- guide inference, suppression, and single-visible-position-axis rules;
- view defaults, numeric normalization, aspect, target, projection, reset,
  orbit, pan, zoom, discrete steps, and snapshot-continuity semantics;
- gesture-neutral preview, full-screen-only manipulation, gesture-to-action
  assignment, pointer-count arbitration, control vocabulary, and the absence of
  any rotate/pan mode;
- raw row ownership for hit testing and typed requests;
- localization and accessibility meaning;
- resource accounting and cancellation; and
- the shared chart failure policy in §12.6, with its implementation choice.

<!-- rule: chart3d.render.may-differ class=hard enforced=no tasks=client-runtime -->
Theme-native fonts, antialiasing, lighting that carries no data meaning,
continuous gesture sensitivity, damping, and permitted inertia may differ.
Gesture-to-action assignment, visible-scene direction, discrete step sizes,
capability gating, control meaning, geometry, scale meaning, topology, identity,
literal colors, camera defaults, and supported/unsupported classification may
not differ.



## 16. Deliberate exclusions

V1 excludes:

- client-evaluated mathematical expressions or arbitrary functions;
- client-calculated contours, regression, aggregation, bins, stacks, or
  interpolation;
- Delaunay/nearest-neighbor or arbitrary triangle-mesh inference;
- volume, voxel, isosurface, geographic-globe, and 3D model rendering;
- camera roll, authored motion paths, or auto-rotation;
- inline chart manipulation, App-authored gesture mappings or sensitivities,
  double-tap camera reset, and any interaction/rotate/pan mode variable;
- executable shader/material programs and unverified external texture or image
  URLs;
- screen-space annotations or 3D text marks;
- persistent selection and linked cross-filter expressions;
- chart-specific incremental mesh/data patch formats; and
- mixing `Chart` and `Chart3D` inside one presented result.

<!-- rule: chart3d.exclusion.compute-in-app class=judgment enforced=no tasks=charts-3d -->
If an App can compute a feature and return explicit typed rows and existing 3D
primitives, it does so. A future schema extension is warranted only for a
portable presentation or interaction capability that cannot be expressed
safely that way.

## 17. Design references

The vocabulary, supported geometry, and constraints were compared against
platform interaction/accessibility guidance and the official ECharts-GL and
SciChart contracts. In particular:

- Apple's [gesture guidance](https://developer.apple.com/design/human-interface-guidelines/gestures/)
  reserves system gestures and favors familiar direct manipulation, while its
  [accessibility guidance](https://developer.apple.com/design/human-interface-guidelines/accessibility)
  requires simple alternatives and supports the `44`-point iOS target used here;
- Android's [gesture guidance](https://developer.android.com/develop/ui/compose/touch-input/pointer-input/understand-gestures)
  defines native tap/drag/transform arbitration, while its
  [accessibility guidance](https://developer.android.com/design/ui/mobile/guides/foundations/accessibility)
  requires alternate actions and supports the `48`-dp Android target used here;
- the W3C [input-modality guidance](https://www.w3.org/WAI/WCAG22/Understanding/input-modalities.html)
  documents the need for single-pointer alternatives to complex gestures and
  dragging; the visible directional and zoom controls provide those alternatives;

- ECharts-GL's
  [Cartesian bar layout](https://github.com/ecomfe/echarts-gl/blob/master/src/chart/bar3D/cartesian3DLayout.js)
  accepts continuous sparse base positions and configurable widths;
- both ECharts-GL's
  [3D logarithmic-axis contract](https://apache.googlesource.com/echarts-website/+/b3f88a6049c8206c87aa08c5bec7cfbb11b8fc94/en/documents/option-gl-parts/option-gl.yAxis3D.json#64)
  and SciChart's
  [LogarithmicAxis3D contract](https://www.scichart.com/documentation/js/v5/typedoc/classes/logarithmicaxis3d.html#log-base)
  expose a configurable logarithm base;
- ECharts-GL's
  [3D axis defaults](https://github.com/ecomfe/echarts-gl/blob/master/src/component/grid3D/axis3DDefault.js)
  expose `boundaryGap`, while SciChart's
  [Axis3D options](https://www.scichart.com/documentation/js/v5/typedoc/interfaces/ilogarithmicaxis3doptions.html#grow-by)
  expose asymmetric `growBy` domain expansion;
- its
  [3D grid](https://github.com/ecomfe/echarts-gl/blob/master/src/component/grid3D/Grid3DModel.js)
  exposes independent box dimensions, while its
  [view-control model](https://github.com/ecomfe/echarts-gl/blob/master/src/component/common/componentViewControlMixin.js)
  exposes a movable camera center and pan;
- its
  [surface series](https://github.com/ecomfe/echarts-gl/blob/master/src/chart/surface/SurfaceSeries.js)
  accepts sampled equation and arbitrary finite parameter domains, and its
  [surface renderer](https://github.com/ecomfe/echarts-gl/blob/master/src/chart/surface/SurfaceView.js)
  treats invalid vertices as holes rather than rejecting the scene;
- ECharts'
  [legend contract](https://echarts.apache.org/handbook/en/concepts/legend/)
  provides series selection and labeling, while ECharts-GL's
  [3D axis defaults](https://github.com/ecomfe/echarts-gl/blob/master/src/component/grid3D/axis3DDefault.js)
  expose axis ticks, labels, and grid lines;
- SciChart's
  [3D column contract](https://www.scichart.com/documentation/js/v5/3d-charts/chart-types/column-3d-chart/)
  accepts sparse XYZ columns, independent base widths, and multiple shapes;
- SciChart's
  [3D camera contract](https://www.scichart.com/documentation/js/v5/3d-charts/scichart-3d-basics/scichart-surface-camera/)
  exposes world dimensions, target, projection-specific fov, and zoom, while
  its
  [3D modifier contract](https://www.scichart.com/documentation/android/current/articles/chart3d/chartModifier3DAPIs/Chart%20Modifier%203D%20APIs.html)
  provides orbit, pan/free-look, and zoom;
- ECharts-GL's view control can independently disable zoom through zero
  [`zoomSensitivity`](https://echarts.apache.org/en/option-gl.html), while
  SciChart independently installs its
  [3D zoom modifier](https://www.scichart.com/documentation/js/current/PinchZoomModifier3D.html);
- ECharts-GL's
  [point builder](https://github.com/ecomfe/echarts-gl/blob/master/src/chart/common/PointsBuilder.js#L150-L171)
  retains zero marker size and symbol tokens, while SciChart documents its
  [3D point-marker primitives](https://www.scichart.com/documentation/android/current/articles/chart3d/3dChartTypes/PointMarker%203D%20API.html);
- SciChart's
  [3D legend contract](https://www.scichart.com/documentation/js/v5/typedoc/interfaces/ilegendmodifier3doptions.html)
  and
  [3D axis contract](https://www.scichart.com/documentation/js/v5/3d-charts/axis-3d-api/axis-3d-api-overview/)
  cover per-series legends and configurable axis tick generation; and
- SciChart's
  [surface-mesh contract](https://www.scichart.com/documentation/android/current/articles/chart3d/3dChartTypes/Surface%20Mesh%20Series%203D.html)
  covers non-uniform grids, holes, wireframes, and contours, while its
  [free-surface contract](https://www.scichart.com/documentation/android/current/articles/chart3d/3dChartTypes/freeSurfaceSeries3D/Custom%20Surface.html)
  covers arbitrary finite parameter domains.

Those systems establish the portability floor: Libertas does not impose a
semantic restriction that both renderers avoid. Libertas still retains its own
typed payload, server-computed safe data, closed metadata, localization,
fallback, and cross-platform semantics.
