@kite3d/engine 0.17.0 → 0.18.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/dist/version.js CHANGED
@@ -1,4 +1,4 @@
1
- const n = "0.17.0", e = {
1
+ const n = "0.18.0", e = {
2
2
  version: n
3
3
  }, o = e.version;
4
4
  export {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kite3d/engine",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "The open Kite3D game runtime, project format, scripting helpers, and game plugins.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -0,0 +1,182 @@
1
+ export const generatorParamTypes = [
2
+ 'boolean',
3
+ 'number',
4
+ 'integer',
5
+ 'string',
6
+ 'select',
7
+ 'vector',
8
+ 'color',
9
+ 'json',
10
+ ] as const
11
+
12
+ export type GeneratorParamType = typeof generatorParamTypes[number]
13
+
14
+ export type GeneratorParamOption = string | number | {
15
+ value: string | number
16
+ label: string
17
+ }
18
+
19
+ export interface GeneratorParamDefinition {
20
+ type?: GeneratorParamType
21
+ label?: string
22
+ help?: string
23
+ default?: unknown
24
+ options?: GeneratorParamOption[]
25
+ min?: number
26
+ max?: number
27
+ step?: number
28
+ }
29
+
30
+ export interface GeneratorParamSchemaEntry extends Omit<GeneratorParamDefinition, 'type'> {
31
+ type: GeneratorParamType
32
+ }
33
+
34
+ export type GeneratorParamSchema = Record<string, GeneratorParamSchemaEntry>
35
+
36
+ export function parseGeneratorParamsSchema(
37
+ exported: unknown,
38
+ module: string,
39
+ warn: (message: string) => void = console.warn,
40
+ ): GeneratorParamSchema {
41
+ if (exported === undefined) return {}
42
+ if (!isRecord(exported)) {
43
+ warn(`[kite3d] Ignoring invalid generator params export from ${module}: expected an object`)
44
+ return {}
45
+ }
46
+
47
+ const schema: GeneratorParamSchema = {}
48
+ for (const [key, value] of Object.entries(exported)) {
49
+ const parsed = parseEntry(value)
50
+ if (typeof parsed === 'string') {
51
+ warn(`[kite3d] Ignoring invalid generator param "${key}" from ${module}: ${parsed}`)
52
+ continue
53
+ }
54
+ schema[key] = parsed
55
+ }
56
+ return schema
57
+ }
58
+
59
+ export function inferGeneratorParam(value: unknown): GeneratorParamSchemaEntry {
60
+ if (typeof value === 'boolean') return {type: 'boolean'}
61
+ if (typeof value === 'number') return {type: 'number'}
62
+ if (isNumberVector(value)) return {type: 'vector'}
63
+ if (typeof value === 'string') return {type: 'string'}
64
+ return {type: 'json'}
65
+ }
66
+
67
+ export function validateGeneratorParam(value: unknown, entry: GeneratorParamSchemaEntry): string | undefined {
68
+ const name = entry.label || 'Value'
69
+ switch (entry.type) {
70
+ case 'boolean':
71
+ return typeof value === 'boolean' ? undefined : `${name} must be true or false.`
72
+ case 'number':
73
+ case 'integer': {
74
+ if (typeof value !== 'number' || !Number.isFinite(value)) return `${name} must be a number.`
75
+ if (entry.type === 'integer' && !Number.isInteger(value)) return `${name} must be an integer.`
76
+ if (entry.min !== undefined && value < entry.min) return `${name} must be at least ${entry.min}.`
77
+ if (entry.max !== undefined && value > entry.max) return `${name} must be at most ${entry.max}.`
78
+ return undefined
79
+ }
80
+ case 'string':
81
+ return typeof value === 'string' ? undefined : `${name} must be text.`
82
+ case 'select': {
83
+ if (typeof value !== 'string' && typeof value !== 'number') {
84
+ return `${name} must be one of the available options.`
85
+ }
86
+ if (entry.options?.length && !entry.options.some((option) => Object.is(optionValue(option), value))) {
87
+ return `${name} must be one of the available options.`
88
+ }
89
+ return undefined
90
+ }
91
+ case 'vector':
92
+ return isNumberVector(value) ? undefined : `${name} must be an array of 2 to 4 numbers.`
93
+ case 'color':
94
+ return typeof value === 'string' && /^#[\da-f]{6}$/i.test(value)
95
+ ? undefined
96
+ : `${name} must be a six-digit hex color.`
97
+ case 'json':
98
+ return isJsonValue(value) ? undefined : `${name} must be a JSON value.`
99
+ }
100
+ }
101
+
102
+ function parseEntry(value: unknown): GeneratorParamSchemaEntry | string {
103
+ if (!isRecord(value)) return 'expected an object'
104
+ if (value.type !== undefined && !generatorParamTypes.includes(value.type as GeneratorParamType)) {
105
+ return `unknown type ${JSON.stringify(value.type)}`
106
+ }
107
+ if (value.label !== undefined && typeof value.label !== 'string') return 'label must be a string'
108
+ if (value.help !== undefined && typeof value.help !== 'string') return 'help must be a string'
109
+ if (value.options !== undefined && !isOptions(value.options)) return 'options must contain strings, numbers, or labeled values'
110
+ for (const bound of ['min', 'max', 'step'] as const) {
111
+ if (value[bound] !== undefined && !isFiniteNumber(value[bound])) return `${bound} must be a finite number`
112
+ }
113
+ if (isFiniteNumber(value.step) && value.step <= 0) return 'step must be greater than zero'
114
+ if (isFiniteNumber(value.min) && isFiniteNumber(value.max) && value.min > value.max) {
115
+ return 'min must not be greater than max'
116
+ }
117
+
118
+ const type = value.type as GeneratorParamType | undefined
119
+ ?? (value.options !== undefined ? 'select' : inferGeneratorParam(value.default).type)
120
+ if ((value.min !== undefined || value.max !== undefined || value.step !== undefined)
121
+ && type !== 'number' && type !== 'integer') {
122
+ return 'min, max, and step require a number or integer type'
123
+ }
124
+ if (value.options !== undefined && type !== 'select') return 'options require the select type'
125
+
126
+ const entry: GeneratorParamSchemaEntry = {
127
+ type,
128
+ ...(value.label !== undefined ? {label: value.label} : {}),
129
+ ...(value.help !== undefined ? {help: value.help} : {}),
130
+ ...(Object.prototype.hasOwnProperty.call(value, 'default') ? {default: value.default} : {}),
131
+ ...(value.options !== undefined ? {options: value.options} : {}),
132
+ ...(isFiniteNumber(value.min) ? {min: value.min} : {}),
133
+ ...(isFiniteNumber(value.max) ? {max: value.max} : {}),
134
+ ...(isFiniteNumber(value.step) ? {step: value.step} : {}),
135
+ }
136
+ if (Object.prototype.hasOwnProperty.call(entry, 'default')) {
137
+ const error = validateGeneratorParam(entry.default, entry)
138
+ if (error) return `invalid default: ${error}`
139
+ }
140
+ return entry
141
+ }
142
+
143
+ function isOptions(value: unknown): value is GeneratorParamOption[] {
144
+ return Array.isArray(value) && value.every((option) => {
145
+ if (typeof option === 'string' || typeof option === 'number') return true
146
+ return isRecord(option)
147
+ && (typeof option.value === 'string' || typeof option.value === 'number')
148
+ && typeof option.label === 'string'
149
+ })
150
+ }
151
+
152
+ function optionValue(option: GeneratorParamOption): string | number {
153
+ return typeof option === 'object' ? option.value : option
154
+ }
155
+
156
+ function isNumberVector(value: unknown): value is number[] {
157
+ return Array.isArray(value)
158
+ && value.length >= 2
159
+ && value.length <= 4
160
+ && value.every(isFiniteNumber)
161
+ }
162
+
163
+ function isFiniteNumber(value: unknown): value is number {
164
+ return typeof value === 'number' && Number.isFinite(value)
165
+ }
166
+
167
+ function isJsonValue(value: unknown, seen = new Set<object>()): boolean {
168
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return true
169
+ if (typeof value === 'number') return Number.isFinite(value)
170
+ if (!value || typeof value !== 'object' || seen.has(value)) return false
171
+ seen.add(value)
172
+ const valid = Array.isArray(value)
173
+ ? value.every((item) => isJsonValue(item, seen))
174
+ : Object.getPrototypeOf(value) === Object.prototype
175
+ && Object.values(value).every((item) => isJsonValue(item, seen))
176
+ seen.delete(value)
177
+ return valid
178
+ }
179
+
180
+ function isRecord(value: unknown): value is Record<string, unknown> {
181
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
182
+ }
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ export * from './paths.ts'
7
7
  export * from './sceneSerialization.ts'
8
8
  export * from './authoring.ts'
9
9
  export * from './authoringValidation.ts'
10
+ export * from './generatorParams.ts'
10
11
  export * from './plugins/HtmlUiComponent.ts'
11
12
  export * from './plugins/GeneratorComponent.ts'
12
13
  export * from './plugins/cannon/CannonPhysicsPlugin.ts'
@@ -6,6 +6,11 @@ import {
6
6
  type ThreeViewer,
7
7
  } from 'threepipe'
8
8
  import {getAuthoringMetadata, setAuthoringMetadata, type AuthoringMetadata} from '../authoring.ts'
9
+ import {
10
+ parseGeneratorParamsSchema,
11
+ type GeneratorParamDefinition,
12
+ type GeneratorParamSchema,
13
+ } from '../generatorParams.ts'
9
14
 
10
15
  export interface GeneratorParams {
11
16
  [key: string]: unknown
@@ -20,6 +25,7 @@ export interface GeneratorContext {
20
25
 
21
26
  export interface GeneratorModule {
22
27
  default?: (context: GeneratorContext) => unknown | Promise<unknown>
28
+ params?: Record<string, GeneratorParamDefinition>
23
29
  }
24
30
 
25
31
  export interface GeneratorViewerOptions {
@@ -42,6 +48,7 @@ export class GeneratorComponent extends Object3DComponent {
42
48
 
43
49
  module = ''
44
50
  params: GeneratorParams = {}
51
+ schema: GeneratorParamSchema = {}
45
52
  private runRevision = 0
46
53
 
47
54
  static configureViewer(viewer: ThreeViewer, options: GeneratorViewerOptions): void {
@@ -78,6 +85,7 @@ export class GeneratorComponent extends Object3DComponent {
78
85
  base: config.base,
79
86
  revision: ++generatorImportRevision,
80
87
  isCurrent: () => revision === this.runRevision,
88
+ onSchema: (schema) => { this.schema = schema },
81
89
  }).then(() => {
82
90
  if (revision === this.runRevision) viewer.setDirty(this)
83
91
  }).finally(() => config.pending.delete(task))
@@ -122,6 +130,7 @@ export interface RunGeneratorOptions extends Omit<GeneratorContext, 'engine'> {
122
130
  base: URL
123
131
  revision?: number
124
132
  isCurrent?: () => boolean
133
+ onSchema?: (schema: GeneratorParamSchema) => void
125
134
  }
126
135
 
127
136
  export async function runGenerator({
@@ -132,13 +141,16 @@ export async function runGenerator({
132
141
  base,
133
142
  revision = 0,
134
143
  isCurrent,
144
+ onSchema,
135
145
  }: RunGeneratorOptions): Promise<IObject3D[]> {
136
146
  removeGeneratedChildren(node)
147
+ onSchema?.({})
137
148
  if (!module) return []
138
149
  const source = ensureGeneratorMetadata(node)
139
150
  const moduleUrl = resolveGeneratorModule(module, base)
140
151
  if (revision) moduleUrl.searchParams.set('kite3d-generator', String(revision))
141
152
  const loaded = await importGeneratorModule(moduleUrl.href)
153
+ if (!isCurrent || isCurrent()) onSchema?.(parseGeneratorParamsSchema(loaded.params, module))
142
154
  if (typeof loaded.default !== 'function') {
143
155
  throw new Error(`Generator module must have a default generate function: ${module}`)
144
156
  }
@@ -1,4 +1,5 @@
1
1
  import {parse, ParseError} from 'jsonc-parser'
2
+ import type {GeneratorParamSchema} from '../generatorParams.ts'
2
3
 
3
4
  export const settingsKey = 'kite3d'
4
5
  export const assetUrlPrefix = `/${settingsKey}/`
@@ -12,6 +13,7 @@ export interface ProjectGeneratorState {
12
13
  nodeIndex: number
13
14
  nodeName: string
14
15
  params: Record<string, unknown>
16
+ schema: GeneratorParamSchema
15
17
  }
16
18
 
17
19
  export interface AssetsJSONManifest {
@@ -165,6 +167,7 @@ export function readProjectGeneratorStates(text: string): ProjectGeneratorState[
165
167
  nodeIndex,
166
168
  nodeName: typeof node.name === 'string' ? node.name : `Node ${nodeIndex}`,
167
169
  params: isRecord(component.state.params) ? component.state.params : {},
170
+ schema: {},
168
171
  })
169
172
  }
170
173
  }