@hyprune/schema 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -142,3 +142,46 @@ definition exists in 0.16 and the graphics properties differ only by the new key
142
142
  action it would take (`""|click|grab|pocket|place|mount|select|path|drop`).
143
143
  - Everything else is inherited from 0.18 unchanged, including the offload cap
144
144
  and device keys; `session.hello` reports `0.19`.
145
+
146
+ ## IPC 0.20: pocket actions and blueprint map levels
147
+
148
+ - `surface.pocket {surfaceId}` and `surface.unpocket {surfaceId}` (surface.control).
149
+ Pocket: the window leaves the world for the Placement queue, its placement kept;
150
+ idempotent. Unpocket: placed at a free spot in front of the player
151
+ (`placed: true`) or left in the pocket, selected for the Placement tool
152
+ (`placed: false`). Results `{revision}` / `{revision, placed}`.
153
+ - `map.levels` (optional, 1..6): `{id, name, minY, maxY, svg, sha256}` per
154
+ blueprint level, sorted by `minY`, in the frame of the map (`worldToMap`).
155
+ - New `extensions/blueprint.schema.json`: the `com.hyprune.blueprint` 0.1 world
156
+ extension (levels with package-relative SVG paths; byte budgets, ordering and
157
+ the `class` vocabulary are stated in its description and enforced by consumers).
158
+ - Everything else is inherited from 0.19 unchanged; `session.hello` reports
159
+ `0.20` and lists the two methods. Inheritance test included.
160
+
161
+ ## World 0.8: placed world objects
162
+
163
+ - World format **0.8** adds `objectAssets` (at most 32) and `objects` (at most
164
+ 256). An asset is a package-relative glTF (`scene`, same extension rules as the
165
+ world's scene.gltf; never lightmapped) with optional declarative node animation
166
+ (at most 16: `spin` or `orbit` about `pivot`/`axis` in asset space, or `bob`
167
+ along `axis` by `amplitude` metres; `period` 0.5–3600 s, optional `phase`), an
168
+ optional static `collision` proxy node and `lighting.mode` (`probe` default, or
169
+ `unlit`). Nodes are referenced by unique glTF node name. `library` (`id@version`)
170
+ is reserved for shared object libraries and rejected in 0.8. An object places an
171
+ asset with a `pose`, uniform `scale` (0.01–100), animation `speed` (0–10, default
172
+ 1), `phase` (0–1) and an optional `ambient` RGB override (0–16).
173
+ - `validate('world')` (from 0.8): unique asset and object ids, known asset
174
+ references, normalised poses, unit axes, per-type fields (spin/orbit need a
175
+ pivot and take no amplitude; bob the reverse), one animation per node, collision
176
+ node not animated, library references rejected. `validate('scene', gltf,
177
+ undefined, '0.8', {world, asset: id})` checks an asset's glTF: extension rules,
178
+ referenced node names exist exactly once, no animated node inside another
179
+ animated subtree, the collision node has a mesh and is outside every animated
180
+ subtree.
181
+ - `v0.8/common.schema.json` is the 0.7 common definitions under the 0.8 `$id`
182
+ (the directory already held IPC 0.8, which keeps its own references).
183
+ Everything else is inherited from 0.7 unchanged. Inheritance test included.
184
+
185
+ ## @hyprune/schema 0.6.0 (2026-10-11)
186
+
187
+ Publishes IPC 0.20 and world format 0.8 to npm.
package/README.md CHANGED
@@ -2,19 +2,19 @@
2
2
 
3
3
  > **Hyprune** is an explorable, world-driven desktop environment for Hyprland: your workspaces become places you can walk through, with live windows on the walls. This repository holds the versioned **schemas** for world packages, IPC and manifests, and their validator. Install Hyprune through the [hyprune](https://github.com/hyprune/hyprune) repository (starter kits, Nix, Arch); learn more at [hyprune.com](https://hyprune.com). Licence: MIT.
4
4
 
5
- Versioned contracts for Hyprune, a world-driven Hyprland desktop: world packages (formats 0.1–0.7), the IPC protocol (0.1–0.19), extension and screen-type manifests and the input keymap. JSON Schema dialect: **2020-12**. Every version is frozen once published; core, the shell and the SDK pin these exact files. Readable reference: [hyprune.com/reference/schema](https://hyprune.com/reference/schema/).
5
+ Versioned contracts for Hyprune, a world-driven Hyprland desktop: world packages (formats 0.1–0.8), the IPC protocol (0.1–0.19), extension and screen-type manifests and the input keymap. JSON Schema dialect: **2020-12**. Every version is frozen once published; core, the shell and the SDK pin these exact files. Readable reference: [hyprune.com/reference/schema](https://hyprune.com/reference/schema/).
6
6
 
7
7
  ## Install
8
8
 
9
9
  ```sh
10
10
  npm i -D @hyprune/schema
11
- npx hyprune-validate world world.json - 0.7 # a world package manifest
12
- npx hyprune-validate scene scene.gltf - 0.7 world.json # its glTF against the world format
11
+ npx hyprune-validate world world.json - 0.8 # a world package manifest
12
+ npx hyprune-validate scene scene.gltf - 0.8 world.json # its glTF against the world format
13
13
  ```
14
14
 
15
15
  ```js
16
16
  import { validate, worldFormats } from '@hyprune/schema';
17
- import schema from '@hyprune/schema/v0.7/world.schema.json' with { type: 'json' };
17
+ import schema from '@hyprune/schema/v0.8/world.schema.json' with { type: 'json' };
18
18
 
19
19
  const result = validate('world', manifest, undefined, worldFormats.at(-1));
20
20
  if (!result.valid) console.error(result.errors.join('\n'));
@@ -118,6 +118,15 @@ optimised scene assets: `KHR_texture_basisu` (KTX2, material textures only),
118
118
  `extensionsRequired` and lightmap textures against the world format. Version
119
119
  gates use `atLeast()`; see RFC-0004 "world 0.7".
120
120
 
121
+ World format **0.8** adds placed world objects: `objectAssets` (package glTF
122
+ assets with optional `spin`/`orbit`/`bob` node animation, a static collision
123
+ proxy and `probe`/`unlit` lighting) and `objects` (instances with pose, scale,
124
+ speed, phase and an optional ambient override). Objects are render-only apart
125
+ from the proxy. Check an asset's glTF with
126
+ `validate('scene', gltf, undefined, '0.8', {world, asset: 'armillary'})` (CLI:
127
+ `npm run validate -- scene objects/armillary.gltf - 0.8 world.json armillary`).
128
+ See RFC-0004 "world 0.8 placed world objects".
129
+
121
130
  IPC **0.16** adds the `upscaler` graphics key (`"fsr1"`, `"bilinear"` or `null` to
122
131
  inherit the preset) to `graphicsConfig`/`graphicsPatch`. Every 0.15 definition is
123
132
  otherwise unchanged; earlier schemas stay frozen. See RFC-0003/0008 amendments.
@@ -139,3 +148,11 @@ bob, reticle style/size/colour and reach limits), widens graphics `fov` to 30-12
139
148
  adds `reticleColor`/`validColor`/`invalidColor` to the overlay style and the
140
149
  required `interaction.reticle {state, action}`. Every other 0.18 definition is
141
150
  unchanged. See the RFC-0006 amendment "IPC 0.19 player settings and reticle".
151
+
152
+ IPC **0.20** adds `surface.pocket`/`surface.unpocket` (surface.control): put a
153
+ world window into the pocket (the Placement queue) and take it out again, placed
154
+ in front of the player or selected for the Placement tool (`placed`). Pocket state
155
+ stays `editor.unplaced.windows`. `spatial.map` gains optional `levels` (1-6
156
+ per-level blueprint SVGs). `extensions/blueprint.schema.json` defines the world
157
+ extension `com.hyprune.blueprint` 0.1 that supplies them. Every other 0.19
158
+ definition is unchanged. See the RFC-0004 and RFC-0005 amendments for IPC 0.20.
package/bin/validate.mjs CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFileSync } from 'node:fs';
3
3
  import { validate } from '../lib/validate.mjs';
4
- const [kind, file, method, version, worldFile, ...extra] = process.argv.slice(2);
5
- if (!kind || !file || extra.length || (worldFile && kind !== 'scene')) {
4
+ const [kind, file, method, version, worldFile, asset, ...extra] = process.argv.slice(2);
5
+ if (!kind || !file || extra.length || ((worldFile || asset) && kind !== 'scene')) {
6
6
  console.error('Usage: hyprune-validate <world|extension|screen-type|ipc> <file.json|-> [response-method|-] [contract-version]\n' +
7
- ' hyprune-validate scene <scene.gltf> - <world-format> <world.json>\n(in this repository: npm run validate -- ...)');
7
+ ' hyprune-validate scene <scene.gltf> - <world-format> <world.json> [object-asset-id]\n(in this repository: npm run validate -- ...)');
8
8
  process.exitCode = 2;
9
9
  } else {
10
10
  try {
@@ -12,7 +12,7 @@ if (!kind || !file || extra.length || (worldFile && kind !== 'scene')) {
12
12
  if (bytes.length > 1_048_576) throw new Error('Document exceeds 1 MiB');
13
13
  const value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
14
14
  const world = worldFile ? JSON.parse(readFileSync(worldFile, 'utf8')) : undefined;
15
- const result = validate(kind, value, method === '-' ? undefined : method, version, { world });
15
+ const result = validate(kind, value, method === '-' ? undefined : method, version, { world, asset });
16
16
  if (result.valid) console.log(`VALID ${kind}: ${file}`);
17
17
  else { console.error(result.errors.join('\n')); process.exitCode = 1; }
18
18
  } catch (error) { console.error(error.message); process.exitCode = 2; }
@@ -0,0 +1,29 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://hyprune.com/schema/extensions/blueprint.schema.json",
4
+ "title": "com.hyprune.blueprint 0.1 world manifest value",
5
+ "description": "Per-level blueprint SVGs in the frame of the package's com.hyprune.map 0.1 (RFC-0004 amendment). Levels are sorted by minY and must not overlap (maxY[i] <= minY[i+1]); each SVG is at most 65536 bytes, all levels together at most 262144 bytes, using the map SVG subset plus a class attribute from: grid floor wall stair opening screen mount area label route waypoint void outline. Consumers enforce those rules.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["version", "levels"],
9
+ "properties": {
10
+ "version": {"const": "0.1"},
11
+ "levels": {
12
+ "type": "array",
13
+ "minItems": 1,
14
+ "maxItems": 6,
15
+ "items": {
16
+ "type": "object",
17
+ "additionalProperties": false,
18
+ "required": ["id", "name", "minY", "maxY", "path"],
19
+ "properties": {
20
+ "id": {"type": "string", "pattern": "^[a-z][a-z0-9-]{0,31}$"},
21
+ "name": {"type": "string", "minLength": 1, "maxLength": 64},
22
+ "minY": {"type": "number"},
23
+ "maxY": {"type": "number"},
24
+ "path": {"type": "string", "minLength": 1, "maxLength": 200, "pattern": "^(?!/)(?!.*(^|/)\\.\\.(/|$))[A-Za-z0-9_./-]+\\.svg$"}
25
+ }
26
+ }
27
+ }
28
+ }
29
+ }
@@ -12,6 +12,12 @@ export interface ValidationResult {
12
12
  export interface ValidateOptions {
13
13
  /** For kind `scene`: the package's parsed world.json (lightmap checks). */
14
14
  world?: unknown;
15
+ /**
16
+ * For kind `scene` from world 0.8: validate `value` as the glTF of this
17
+ * `world.objectAssets[].id` (extension rules, animation and collision node
18
+ * names, nesting) instead of the world's scene.gltf.
19
+ */
20
+ asset?: string;
15
21
  }
16
22
 
17
23
  /**
package/lib/validate.mjs CHANGED
@@ -36,9 +36,35 @@ export function sceneExtensions(version) {
36
36
  const base = ['KHR_materials_unlit','KHR_texture_transform','KHR_materials_emissive_strength'];
37
37
  return atLeast(version, '0.7') ? [...base,'KHR_texture_basisu','EXT_meshopt_compression','KHR_mesh_quantization'] : base;
38
38
  }
39
+ // World 0.8 object asset rules that need the asset's glTF (RFC-0004 "world 0.8"):
40
+ // referenced node names exist once, animated subtrees do not nest, and the
41
+ // collision proxy has a mesh and lies outside every animated subtree.
42
+ function validateObjectAsset(gltf, asset, fail) {
43
+ const nodes = gltf.nodes || [], parents = new Map(), label = `objectAssets ${asset.id}`;
44
+ nodes.forEach((n, i) => { for (const c of n.children || []) parents.set(c, i); });
45
+ const find = name => {
46
+ const hits = nodes.flatMap((n, i) => n.name === name ? [i] : []);
47
+ if (hits.length !== 1) fail(`${label}: node "${name}" ${hits.length ? 'is not unique' : 'not found'} in the asset`);
48
+ return hits.length === 1 ? hits[0] : undefined;
49
+ };
50
+ const ancestors = node => { const out = []; for (let p = parents.get(node), n = 0; p !== undefined && n <= nodes.length; p = parents.get(p), n++) out.push(p); return out; };
51
+ const targets = new Map();
52
+ for (const a of asset.animations || []) { const i = find(a.node); if (i !== undefined) targets.set(i, a.node); }
53
+ for (const [node, name] of targets) for (const p of ancestors(node))
54
+ if (targets.has(p)) fail(`${label}: animation "${name}" is inside animated node "${targets.get(p)}"`);
55
+ if (asset.collision) {
56
+ const i = find(asset.collision.node);
57
+ if (i !== undefined) {
58
+ if (nodes[i].mesh === undefined) fail(`${label}: collision node "${asset.collision.node}" has no mesh`);
59
+ for (const p of [i, ...ancestors(i)]) if (targets.has(p)) fail(`${label}: collision node "${asset.collision.node}" is inside animated node "${targets.get(p)}"`);
60
+ }
61
+ }
62
+ }
39
63
  // scene.gltf rules that depend on the world format: extensionsRequired, and
40
64
  // lightmap textures (world.lightmaps[].texture) stay PNG in every v0 format.
41
- function validateScene(gltf, version, world) {
65
+ // With options.asset, `gltf` is that world 0.8 object asset's glTF instead: the
66
+ // same extension rules, the asset rules above, and no lightmaps (assets are never baked).
67
+ function validateScene(gltf, version, world, assetId) {
42
68
  const errors = [], fail = m => errors.push(m);
43
69
  if (!worldFormats.includes(version)) throw new Error(`Unknown world format: ${version}`);
44
70
  const allowed = new Set(sceneExtensions(version)), required = new Set(gltf.extensionsRequired || []);
@@ -55,6 +81,12 @@ function validateScene(gltf, version, world) {
55
81
  if ((gltf.bufferViews || []).some(v => v.extensions?.EXT_meshopt_compression) && !required.has('EXT_meshopt_compression'))
56
82
  fail(allowed.has('EXT_meshopt_compression') ? 'extensionsRequired: EXT_meshopt_compression must be listed when buffer views use it'
57
83
  : 'bufferViews: EXT_meshopt_compression needs world 0.7');
84
+ if (assetId !== undefined) {
85
+ const asset = atLeast(version, '0.8') ? (world?.objectAssets || []).find(a => a.id === assetId) : undefined;
86
+ if (!asset) fail(`objectAssets: unknown asset ${assetId} for world ${version}`);
87
+ else validateObjectAsset(gltf, asset, fail);
88
+ return { valid: errors.length === 0, errors };
89
+ }
58
90
  for (const lightmap of world?.lightmaps || []) {
59
91
  const texture = textures[lightmap.texture];
60
92
  if (!texture) { fail(`lightmaps: texture ${lightmap.texture} missing from scene`); continue; }
@@ -70,7 +102,7 @@ function validateScene(gltf, version, world) {
70
102
  export function validate(kind, value, method, version = '0.1', options = {}) {
71
103
  if (kind === 'scene') {
72
104
  if (method) throw new Error('Method context is only valid for IPC');
73
- return validateScene(value, version, options.world);
105
+ return validateScene(value, version, options.world, options.asset);
74
106
  }
75
107
  if (!names.includes(kind) || kind === 'common') throw new Error(`Unknown schema: ${kind}`);
76
108
  if (!contractVersions.includes(version)) throw new Error(`Unknown contract: ${version}`);
@@ -172,6 +204,26 @@ export function validate(kind, value, method, version = '0.1', options = {}) {
172
204
  if (Math.abs(Math.hypot(...c.facing)-1) > .001 || Math.abs(axis.reduce((v,n,i)=>v+n*c.facing[i],0)/length) > .001) fail(`${c.id}: facing must be unit and perpendicular to segment`);
173
205
  for (const end of ['top','bottom']) if (Math.hypot(...c.exits[end].map((v,i)=>v-c[end][i])) > 3) fail(`${c.id}: exit too far from segment`);
174
206
  }
207
+ if (atLeast(value.formatVersion, '0.8')) {
208
+ const assets = value.objectAssets || [], objects = value.objects || [];
209
+ unique(assets, 'id', 'objectAssets'); unique(objects, 'id', 'objects');
210
+ const ids = new Set(assets.map(a => a.id));
211
+ for (const a of assets) {
212
+ const label = `objectAssets ${a.id}`, animations = a.animations || [];
213
+ if (a.library !== undefined) fail(`${label}: library references are reserved for a later world format; use a package scene`);
214
+ unique(animations, 'node', `${label} animations`);
215
+ for (const n of animations) {
216
+ if (Math.abs(Math.hypot(...n.axis) - 1) > 0.001) fail(`${label}: animation "${n.node}" axis must be a unit vector`);
217
+ if (n.type === 'bob' ? n.amplitude === undefined : n.pivot === undefined) fail(`${label}: ${n.type} animation "${n.node}" needs ${n.type === 'bob' ? 'an amplitude' : 'a pivot'}`);
218
+ if (n.type === 'bob' ? n.pivot !== undefined : n.amplitude !== undefined) fail(`${label}: ${n.type} animation "${n.node}" takes no ${n.type === 'bob' ? 'pivot' : 'amplitude'}`);
219
+ }
220
+ if (a.collision && animations.some(n => n.node === a.collision.node)) fail(`${label}: collision node "${a.collision.node}" is animated`);
221
+ }
222
+ for (const o of objects) {
223
+ if (!ids.has(o.asset)) fail(`objects ${o.id}: unknown asset ${o.asset}`);
224
+ quat(o.pose.rotation, `objects ${o.id}`);
225
+ }
226
+ }
175
227
  unique(value.attribution, 'path', 'attribution');
176
228
  for (const a of value.attribution) license(a.license, `attribution ${a.path}`);
177
229
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyprune/schema",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "description": "Versioned JSON Schemas for Hyprune world packages and the IPC protocol, with a validator and CLI for world authors",
5
5
  "license": "MIT",
6
6
  "type": "module",