lecodes-sdk 0.20.0 → 1.0.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/global.d.ts +48 -5
- package/dist/inject.js +361 -260
- package/dist/types/audio/Bus.d.ts +45 -0
- package/dist/types/audio/Sound.d.ts +28 -0
- package/dist/types/audio/Voice.d.ts +27 -0
- package/dist/types/audio/audio.d.ts +83 -0
- package/dist/types/audio/support.d.ts +1 -0
- package/dist/types/canvas/Canvas.d.ts +2 -0
- package/dist/types/gl/DecalSet.d.ts +148 -0
- package/dist/types/gl/Geometry.d.ts +17 -0
- package/dist/types/gl/Light.d.ts +7 -0
- package/dist/types/gl/Lightmap.d.ts +9 -0
- package/dist/types/gl/Material.d.ts +90 -2
- package/dist/types/gl/Mesh.d.ts +18 -1
- package/dist/types/gl/Model.d.ts +34 -0
- package/dist/types/gl/Particles.d.ts +13 -0
- package/dist/types/gl/Scene.d.ts +23 -0
- package/dist/types/gl/Texture.d.ts +29 -1
- package/dist/types/gl/animation/AnimationClip.d.ts +25 -12
- package/dist/types/gl/animation/DynamicBone.d.ts +173 -0
- package/dist/types/gl/{IK.d.ts → animation/IK.d.ts} +4 -4
- package/dist/types/gl/{Locomotion.d.ts → animation/Locomotion.d.ts} +6 -6
- package/dist/types/gl/animation/core.d.ts +15 -10
- package/dist/types/gl/audio/AudioSource.d.ts +60 -0
- package/dist/types/gl/audio/AudioZone.d.ts +32 -0
- package/dist/types/gl/audio/SceneAudio.d.ts +11 -0
- package/dist/types/gl/{NavAgent.d.ts → nav/NavAgent.d.ts} +4 -4
- package/dist/types/gl/{NavMesh.d.ts → nav/NavMesh.d.ts} +4 -4
- package/dist/types/gl/{CharacterController.d.ts → physics/CharacterController.d.ts} +5 -5
- package/dist/types/gl/{Physics.d.ts → physics/Physics.d.ts} +6 -5
- package/dist/types/gl/{Ragdoll.d.ts → physics/Ragdoll.d.ts} +4 -4
- package/dist/types/gl/{Shape.d.ts → physics/Shape.d.ts} +3 -3
- package/dist/types/gl/{Trigger.d.ts → physics/Trigger.d.ts} +2 -2
- package/dist/types/gl/state.d.ts +0 -1
- package/dist/types/gl/{Terrain.d.ts → terrain/Terrain.d.ts} +6 -6
- package/dist/types/gl/{terrainMesh.d.ts → terrain/terrainMesh.d.ts} +1 -1
- package/dist/types/gl/vehicle/Vehicle.d.ts +300 -0
- package/dist/types/gl/vehicle/Wheel.d.ts +147 -0
- package/dist/types/inject.d.ts +33 -21
- package/dist/types/runtime/files.d.ts +24 -1
- package/dist/types/runtime/input.d.ts +11 -0
- package/dist/types/scene/defineScene.d.ts +10 -3
- package/dist/types/ui/UIImage.d.ts +15 -5
- package/dist/types.json +1 -1
- package/package.json +1 -1
- package/prompts/README.md +142 -142
- package/prompts/dist/2d-game.md +408 -197
- package/prompts/dist/3d-app.md +491 -166
- package/prompts/dist/ar-app.md +373 -163
- package/prompts/dist/design.md +83 -87
- package/prompts/dist/ui-app.md +325 -136
- package/src/audio/Bus.ts +102 -0
- package/src/audio/Sound.ts +96 -0
- package/src/audio/Voice.ts +102 -0
- package/src/audio/audio.ts +161 -0
- package/src/audio/support.ts +6 -0
- package/src/bridges.d.ts +279 -32
- package/src/canvas/Canvas.ts +21 -0
- package/src/compile/__tests__/compile.test.ts +11 -0
- package/src/compile/compileProject.ts +30 -15
- package/src/compile/header.ts +6 -3
- package/src/compile/index.ts +4 -0
- package/src/compile/sceneEditor.ts +42 -1
- package/src/core/Aspect.ts +33 -8
- package/src/g2/Scene2D.ts +7 -0
- package/src/gl/CameraPlace.ts +52 -52
- package/src/gl/DecalSet.ts +360 -0
- package/src/gl/Geometry.ts +348 -279
- package/src/gl/Light.ts +16 -0
- package/src/gl/Lightmap.ts +35 -7
- package/src/gl/Material.ts +173 -4
- package/src/gl/Mesh.ts +120 -83
- package/src/gl/Model.ts +33 -1
- package/src/gl/Node.ts +1 -1
- package/src/gl/Particles.ts +21 -3
- package/src/gl/Scene.ts +41 -7
- package/src/gl/Texture.ts +43 -3
- package/src/gl/animation/AnimationClip.ts +43 -20
- package/src/gl/animation/Animator.ts +4 -3
- package/src/gl/animation/DynamicBone.ts +459 -0
- package/src/gl/{IK.ts → animation/IK.ts} +4 -4
- package/src/gl/{Locomotion.ts → animation/Locomotion.ts} +7 -7
- package/src/gl/animation/core.ts +20 -15
- package/src/gl/audio/AudioSource.ts +113 -0
- package/src/gl/audio/AudioZone.ts +75 -0
- package/src/gl/audio/SceneAudio.ts +26 -0
- package/src/gl/{NavAgent.ts → nav/NavAgent.ts} +5 -5
- package/src/gl/{NavMesh.ts → nav/NavMesh.ts} +8 -8
- package/src/gl/{CharacterController.ts → physics/CharacterController.ts} +5 -5
- package/src/gl/{Physics.ts → physics/Physics.ts} +12 -5
- package/src/gl/{Ragdoll.ts → physics/Ragdoll.ts} +272 -270
- package/src/gl/{Shape.ts → physics/Shape.ts} +3 -3
- package/src/gl/{Trigger.ts → physics/Trigger.ts} +2 -2
- package/src/gl/{physicsEvents.ts → physics/physicsEvents.ts} +1 -1
- package/src/gl/scenarios.ts +291 -291
- package/src/gl/state.ts +1 -1
- package/src/gl/{Terrain.ts → terrain/Terrain.ts} +10 -10
- package/src/gl/{terrainMesh.ts → terrain/terrainMesh.ts} +1 -1
- package/src/gl/vehicle/Vehicle.ts +666 -0
- package/src/gl/vehicle/Wheel.ts +290 -0
- package/src/inject.ts +226 -212
- package/src/runtime/files.ts +32 -2
- package/src/runtime/input.ts +6 -1
- package/src/scene/defineScene.ts +26 -10
- package/src/scene/gizmos.ts +148 -148
- package/src/scene/level.ts +2 -2
- package/src/ui/UIImage.ts +21 -7
- package/dist/types/gl/Gearbox.d.ts +0 -86
- package/dist/types/gl/Vehicle.d.ts +0 -191
- package/dist/types/gl/Wheel.d.ts +0 -95
- package/src/gl/Gearbox.ts +0 -212
- package/src/gl/Vehicle.ts +0 -473
- package/src/gl/Wheel.ts +0 -240
- /package/dist/types/gl/{physicsEvents.d.ts → physics/physicsEvents.d.ts} +0 -0
package/src/compile/index.ts
CHANGED
|
@@ -63,13 +63,17 @@ export { offsetSourceMap, inlineSourceMapComment } from "./sourcemap"
|
|
|
63
63
|
export { buildHeader, detectProjectKind, type HeaderOptions } from "./header"
|
|
64
64
|
export {
|
|
65
65
|
compileProject,
|
|
66
|
+
compileProjectWithMap,
|
|
67
|
+
entriesToFiles,
|
|
66
68
|
type CompileEntry,
|
|
67
69
|
type CompileOptions,
|
|
68
70
|
type CompileFileType,
|
|
71
|
+
type CompileResult,
|
|
69
72
|
} from "./compileProject"
|
|
70
73
|
export {
|
|
71
74
|
loadSceneHarness,
|
|
72
75
|
sceneEditorEntries,
|
|
76
|
+
sceneImportsOf,
|
|
73
77
|
SCENE_ENTRY_PATH,
|
|
74
78
|
SCENE_HARNESS_DIR,
|
|
75
79
|
} from "./sceneEditor"
|
|
@@ -47,10 +47,44 @@ export const loadSceneHarness = (fallbackDir?: string): Record<string, string> =
|
|
|
47
47
|
* the project's file list — `.editor.ts` entries are picked out of it. Compile with
|
|
48
48
|
* `entryOverride: SCENE_ENTRY_PATH`, `header: false`, `format: "iife"`, `editor: true`.
|
|
49
49
|
*/
|
|
50
|
+
/** Resolve a relative import specifier against a project path ("./src/map.scene.ts" + "./map/x.scene"
|
|
51
|
+
* → "./src/map/x.scene"). */
|
|
52
|
+
const resolveRelative = (fromPath: string, spec: string): string => {
|
|
53
|
+
const segs = (p: string) => p.split("/").filter((s) => s !== "" && s !== ".")
|
|
54
|
+
const out = segs(fromPath).slice(0, -1)
|
|
55
|
+
for (const seg of segs(spec)) {
|
|
56
|
+
if (seg === "..") out.pop()
|
|
57
|
+
else out.push(seg)
|
|
58
|
+
}
|
|
59
|
+
return "./" + out.join("/")
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The other scene files a scene file imports as prefabs (`import x from './x.scene'`), as project
|
|
64
|
+
* paths present in `projectPaths`. The edit bundle registers their handles BY PATH
|
|
65
|
+
* (`__lecodesSceneFiles`), so the editor can live-patch a `prefab:` node — add, duplicate, undo —
|
|
66
|
+
* without a compile: the doc sees `prefab: X` as an identifier, the host maps it through the
|
|
67
|
+
* file's import table to this path, and the harness resolves the path to the running handle.
|
|
68
|
+
*/
|
|
69
|
+
export const sceneImportsOf = (sceneText: string, scenePath: string, projectPaths: string[]): string[] => {
|
|
70
|
+
const known = new Set(projectPaths)
|
|
71
|
+
const out: string[] = []
|
|
72
|
+
const re = /^\s*import\s+[A-Za-z_$][\w$]*\s+from\s+["']([^"']+\.scene(?:\.ts)?)["']/gm
|
|
73
|
+
for (const m of sceneText.matchAll(re)) {
|
|
74
|
+
const spec = m[1]
|
|
75
|
+
if (!spec.startsWith(".")) continue
|
|
76
|
+
let p = resolveRelative(scenePath, spec)
|
|
77
|
+
if (!p.endsWith(".ts")) p += ".ts"
|
|
78
|
+
if (known.has(p) && !out.includes(p)) out.push(p)
|
|
79
|
+
}
|
|
80
|
+
return out
|
|
81
|
+
}
|
|
82
|
+
|
|
50
83
|
export const sceneEditorEntries = (
|
|
51
84
|
scenePath: string,
|
|
52
85
|
harness: Record<string, string>,
|
|
53
86
|
projectPaths: string[] = [],
|
|
87
|
+
sceneImports: string[] = [],
|
|
54
88
|
): CompileEntry[] => {
|
|
55
89
|
const entries: CompileEntry[] = Object.entries(harness).map(([ name, text ]) => ({
|
|
56
90
|
path: `${SCENE_HARNESS_DIR}/${name}`,
|
|
@@ -74,15 +108,22 @@ export const sceneEditorEntries = (
|
|
|
74
108
|
const materialImports = materialPaths.map((p, i) => `import __mat${i} from "${p}"\n`).join("")
|
|
75
109
|
const materialRegister = materialPaths.length === 0 ? ""
|
|
76
110
|
: `;(globalThis as any).__lecodesMaterials = { ${materialPaths.map((p, i) => `${JSON.stringify(p)}: __mat${i}`).join(", ")} }\n`
|
|
111
|
+
// The scene's prefab imports, registered by project path (see sceneImportsOf) — the same module
|
|
112
|
+
// instances the scene itself imported, so a live-patched prefab node is the same handle.
|
|
113
|
+
const sceneImportLines = sceneImports.map((p, i) => `import __scn${i} from "${p}"\n`).join("")
|
|
114
|
+
const sceneRegister = sceneImports.length === 0 ? ""
|
|
115
|
+
: `;(globalThis as any).__lecodesSceneFiles = { ${sceneImports.map((p, i) => `${JSON.stringify(p)}: __scn${i}`).join(", ")} }\n`
|
|
77
116
|
entries.push({
|
|
78
117
|
path: SCENE_ENTRY_PATH,
|
|
79
118
|
type: "text",
|
|
80
119
|
text: `import "${SCENE_HARNESS_DIR}/.edit-mode.ts"\n`
|
|
81
120
|
+ materialImports
|
|
121
|
+
+ sceneImportLines
|
|
82
122
|
+ `import "${scenePath}"\n`
|
|
83
123
|
+ editorImports
|
|
84
124
|
+ `import "${SCENE_HARNESS_DIR}/main.ts"\n`
|
|
85
|
-
+ materialRegister
|
|
125
|
+
+ materialRegister
|
|
126
|
+
+ sceneRegister,
|
|
86
127
|
})
|
|
87
128
|
return entries
|
|
88
129
|
}
|
package/src/core/Aspect.ts
CHANGED
|
@@ -96,14 +96,27 @@ type FrameInstaller = (early: PhaseFn, late: PhaseFn) => boolean
|
|
|
96
96
|
type FixedInstaller = (fixed: PhaseFn) => boolean
|
|
97
97
|
let frameInstaller: FrameInstaller | undefined
|
|
98
98
|
let fixedInstaller: FixedInstaller | undefined
|
|
99
|
-
/** @internal Install a render-synced frame source for aspect update(dt) (+ the fixed-step source). Called from an engine layer.
|
|
100
|
-
|
|
99
|
+
/** @internal Install a render-synced frame source for aspect update(dt) (+ the fixed-step source). Called from an engine layer.
|
|
100
|
+
* The FIRST engine layer wins: a 3D game that later builds a Scene2D for a UIImage minimap keeps its dispatch on the
|
|
101
|
+
* 3D engine (the one it presents); the 2D engine's phases stay unwired there, so nothing ticks twice. */
|
|
102
|
+
export const _installAspectFrames = (fn: FrameInstaller, fixed?: FixedInstaller): void => {
|
|
103
|
+
if (frameInstaller) return
|
|
104
|
+
frameInstaller = fn; fixedInstaller = fixed
|
|
105
|
+
}
|
|
101
106
|
|
|
102
107
|
/** @internal Engine-level phase hooks (the Net system, sdk/src/net): FIRST in the early and fixed
|
|
103
108
|
* phases, LAST in the late one, ahead of / behind every game aspect regardless of ordering
|
|
104
109
|
* constraints. Single slot each; raw dt. */
|
|
105
110
|
export const _phaseHooks: { early: PhaseFn | null; fixed: PhaseFn | null; late: PhaseFn | null } = { early: null, fixed: null, late: null }
|
|
106
111
|
|
|
112
|
+
/** @internal Advances at the start of EVERY phase pass — early, each fixed step, late — on the
|
|
113
|
+
* render-synced path and the setLoop fallback alike. The key for "read the engine once per phase"
|
|
114
|
+
* caches (gl/Vehicle's state block): between two passes the engine rewrites state natively (the
|
|
115
|
+
* physics steps, the transform sync), so a value cached under an older stamp is stale. Once per
|
|
116
|
+
* FRAME is not enough — the early and late phases of one frame straddle the physics step, and a
|
|
117
|
+
* value first read in `updateBefore` must not be what `update` sees. */
|
|
118
|
+
export const _phaseStamp = { value: 0 }
|
|
119
|
+
|
|
107
120
|
let attachSeq = 0
|
|
108
121
|
const warned = new Set<string>()
|
|
109
122
|
const warnOnce = (msg: string): void => { if (!warned.has(msg)) { warned.add(msg); console.warn(msg) } }
|
|
@@ -214,7 +227,11 @@ let fixedFallbackAcc = 0
|
|
|
214
227
|
// engine drives it from inside its substep loop when it has the hook; otherwise (older wasm / Apple /
|
|
215
228
|
// Android builds, the setLoop path) the SDK steps its own accumulator right after the early phase -
|
|
216
229
|
// same step, same 4-substep cap, same drop-the-backlog rule as creator-physics' cphysFrameBegin.
|
|
217
|
-
const fixedStep: PhaseFn = (dt) => {
|
|
230
|
+
const fixedStep: PhaseFn = (dt) => {
|
|
231
|
+
_phaseStamp.value++ // a substep moved the bodies natively since the last pass
|
|
232
|
+
_phaseHooks.fixed?.(dt)
|
|
233
|
+
fixedUpdaters.run("updateFixed", dt)
|
|
234
|
+
}
|
|
218
235
|
const fixedFallback = (): void => {
|
|
219
236
|
const step = Time.fixedDt
|
|
220
237
|
fixedFallbackAcc += Time.dt // scaled: paused -> no steps, slow-motion -> fewer steps, never a shorter dt
|
|
@@ -229,12 +246,17 @@ const ensureDispatch = (): void => {
|
|
|
229
246
|
// Both phases scale their OWN raw dt (the engine hands the same value to both; a host or test that
|
|
230
247
|
// drives them apart still gets each phase's dt right). The frame counter / clocks advance once, early.
|
|
231
248
|
const early: PhaseFn = (dt) => {
|
|
249
|
+
_phaseStamp.value++
|
|
232
250
|
Time._beginFrame(dt)
|
|
233
251
|
_phaseHooks.early?.(dt)
|
|
234
252
|
earlyUpdaters.run("updateBefore", Time.dt)
|
|
235
253
|
if (fixedFallbackOn) fixedFallback()
|
|
236
254
|
}
|
|
237
|
-
const late: PhaseFn = (dt) => {
|
|
255
|
+
const late: PhaseFn = (dt) => {
|
|
256
|
+
_phaseStamp.value++ // the physics step + transform sync ran since the early pass
|
|
257
|
+
lateUpdaters.run("update", Time._phaseDt(dt))
|
|
258
|
+
_phaseHooks.late?.(dt)
|
|
259
|
+
}
|
|
238
260
|
// render-synced source if an engine layer installed one; else one host loop (early then late).
|
|
239
261
|
if (!(frameInstaller && frameInstaller(early, late))) {
|
|
240
262
|
setLoop((dt) => { early(dt); late(dt) })
|
|
@@ -266,10 +288,13 @@ const registerUpdater = (inst: Aspect<any, any, any>): void => {
|
|
|
266
288
|
}
|
|
267
289
|
const unregisterUpdater = (inst: Aspect<any, any, any>): void => {
|
|
268
290
|
const a = internals(inst)
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
291
|
+
const phases = a._phases
|
|
292
|
+
if (phases === 0) return
|
|
293
|
+
// `_phases` records which lists the aspect is in (1 early / 2 late / 4 fixed) — only those are
|
|
294
|
+
// scanned, so detaching a late-only aspect never walks the early or fixed lists.
|
|
295
|
+
if (phases & 1) earlyUpdaters.remove(a)
|
|
296
|
+
if (phases & 2) lateUpdaters.remove(a)
|
|
297
|
+
if (phases & 4) fixedUpdaters.remove(a)
|
|
273
298
|
a._phases = 0
|
|
274
299
|
}
|
|
275
300
|
|
package/src/g2/Scene2D.ts
CHANGED
|
@@ -167,6 +167,13 @@ export class Scene2D implements Presentable {
|
|
|
167
167
|
return this
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
/** @internal UIImage source marker (UIImage.toSrc): `UIImage(scene)` shows this scene live — the
|
|
171
|
+
* host draws it into the image node's box every frame with the scene's own camera (a minimap, a
|
|
172
|
+
* portrait, any 2D view inside the UI). Needs no open(); the scene keeps its aspects and animations. */
|
|
173
|
+
_imageSrc(): { scene2d: number } {
|
|
174
|
+
return { scene2d: this.id }
|
|
175
|
+
}
|
|
176
|
+
|
|
170
177
|
/** @internal stable wire descriptor; hosts hand it back via router onChange (`_p` = this). */
|
|
171
178
|
_viewDesc(): object {
|
|
172
179
|
return this._vd ??= { type: "scene2d", sceneId: this.id, _p: this }
|
package/src/gl/CameraPlace.ts
CHANGED
|
@@ -1,52 +1,52 @@
|
|
|
1
|
-
// CameraPlace: "the camera as a place". A scene has ONE camera (`scene.camera`); a CameraPlace marks
|
|
2
|
-
// a node as somewhere that camera can be — with the lens it uses there. Nothing is created engine-
|
|
3
|
-
// side (no second camera, no ABI): it is a pose + a projection record on any node: an empty, an
|
|
4
|
-
// empty mounted on a bone (`mount:`), a node a path aspect moves (a dolly shot), a model node.
|
|
5
|
-
//
|
|
6
|
-
// • In a scene FILE that runs (`open()` / `load()`), the `active: true` place drives
|
|
7
|
-
// `scene.camera` (exactly `scene.camera.follow(node)`); exactly one per file.
|
|
8
|
-
// • Inside a prefab or an `instantiate()`d subtree a place is inert DATA — the hosting scene
|
|
9
|
-
// owns its camera. The host reads it (`inst.nodes.eye.get(CameraPlace)` → world pose + fov)
|
|
10
|
-
// or anchors on it (`inst.alignTo(inst.nodes.eye)`).
|
|
11
|
-
// • From code: `scene.camera.follow(dolly.get(CameraPlace))` — a cutscene shot; `follow(null)`
|
|
12
|
-
// releases.
|
|
13
|
-
// • Edit mode: draws an anchored frustum gizmo (the editor's camera preview reads the same place).
|
|
14
|
-
// See docs/scene-camera-place-plan.md.
|
|
15
|
-
|
|
16
|
-
import { Aspect } from "../core/Aspect"
|
|
17
|
-
import type { FieldMeta } from "../core/fields"
|
|
18
|
-
import { Gizmos } from "../scene/gizmos"
|
|
19
|
-
import type { Camera } from "./Camera"
|
|
20
|
-
import type { Node } from "./Node"
|
|
21
|
-
|
|
22
|
-
export class CameraPlace extends Aspect<"cameraPlace", Node> {
|
|
23
|
-
static readonly aspect = "cameraPlace"
|
|
24
|
-
/** Vertical field of view in degrees (default 60) — smaller is a longer lens. */
|
|
25
|
-
fov = 60
|
|
26
|
-
/** Near clip distance. */
|
|
27
|
-
near = 0.01
|
|
28
|
-
/** Far clip distance = view range. */
|
|
29
|
-
far = 1000
|
|
30
|
-
/** The place that drives `scene.camera` when the file declaring it RUNS. Exactly one per file;
|
|
31
|
-
* ignored inside prefabs / instantiated subtrees (the host scene owns its camera). */
|
|
32
|
-
active = false
|
|
33
|
-
/** Aspect ratio of the frustum gizmo only (the real aspect is the viewport's). */
|
|
34
|
-
static fields: FieldMeta<CameraPlace> = {
|
|
35
|
-
fov: { label: "FOV", min: 1, max: 179, step: 1 },
|
|
36
|
-
near: { min: 0.001, max: 10, step: 0.01 },
|
|
37
|
-
far: { min: 1, max: 100000, step: 1 },
|
|
38
|
-
active: { label: "Active camera" },
|
|
39
|
-
}
|
|
40
|
-
static editor = { rebuild: true }
|
|
41
|
-
|
|
42
|
-
/** Copy this place's projection onto a camera (`camera.follow(place)` does it for you). */
|
|
43
|
-
applyTo(camera: Camera): void {
|
|
44
|
-
camera.setProjection({ fov: this.fov, near: this.near, far: this.far })
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** Edit mode: the frustum marker (−Z = view), anchored on the node so it follows drags live and
|
|
48
|
-
* clicking it selects the node. Play mode never calls this. */
|
|
49
|
-
rebuild(): void {
|
|
50
|
-
Gizmos.frustum(this.fov, { node: this.node, color: this.active ? "#ffffff" : "#9aa3b2" })
|
|
51
|
-
}
|
|
52
|
-
}
|
|
1
|
+
// CameraPlace: "the camera as a place". A scene has ONE camera (`scene.camera`); a CameraPlace marks
|
|
2
|
+
// a node as somewhere that camera can be — with the lens it uses there. Nothing is created engine-
|
|
3
|
+
// side (no second camera, no ABI): it is a pose + a projection record on any node: an empty, an
|
|
4
|
+
// empty mounted on a bone (`mount:`), a node a path aspect moves (a dolly shot), a model node.
|
|
5
|
+
//
|
|
6
|
+
// • In a scene FILE that runs (`open()` / `load()`), the `active: true` place drives
|
|
7
|
+
// `scene.camera` (exactly `scene.camera.follow(node)`); exactly one per file.
|
|
8
|
+
// • Inside a prefab or an `instantiate()`d subtree a place is inert DATA — the hosting scene
|
|
9
|
+
// owns its camera. The host reads it (`inst.nodes.eye.get(CameraPlace)` → world pose + fov)
|
|
10
|
+
// or anchors on it (`inst.alignTo(inst.nodes.eye)`).
|
|
11
|
+
// • From code: `scene.camera.follow(dolly.get(CameraPlace))` — a cutscene shot; `follow(null)`
|
|
12
|
+
// releases.
|
|
13
|
+
// • Edit mode: draws an anchored frustum gizmo (the editor's camera preview reads the same place).
|
|
14
|
+
// See docs/scene-camera-place-plan.md.
|
|
15
|
+
|
|
16
|
+
import { Aspect } from "../core/Aspect"
|
|
17
|
+
import type { FieldMeta } from "../core/fields"
|
|
18
|
+
import { Gizmos } from "../scene/gizmos"
|
|
19
|
+
import type { Camera } from "./Camera"
|
|
20
|
+
import type { Node } from "./Node"
|
|
21
|
+
|
|
22
|
+
export class CameraPlace extends Aspect<"cameraPlace", Node> {
|
|
23
|
+
static readonly aspect = "cameraPlace"
|
|
24
|
+
/** Vertical field of view in degrees (default 60) — smaller is a longer lens. */
|
|
25
|
+
fov = 60
|
|
26
|
+
/** Near clip distance. */
|
|
27
|
+
near = 0.01
|
|
28
|
+
/** Far clip distance = view range. */
|
|
29
|
+
far = 1000
|
|
30
|
+
/** The place that drives `scene.camera` when the file declaring it RUNS. Exactly one per file;
|
|
31
|
+
* ignored inside prefabs / instantiated subtrees (the host scene owns its camera). */
|
|
32
|
+
active = false
|
|
33
|
+
/** Aspect ratio of the frustum gizmo only (the real aspect is the viewport's). */
|
|
34
|
+
static fields: FieldMeta<CameraPlace> = {
|
|
35
|
+
fov: { label: "FOV", min: 1, max: 179, step: 1 },
|
|
36
|
+
near: { min: 0.001, max: 10, step: 0.01 },
|
|
37
|
+
far: { min: 1, max: 100000, step: 1 },
|
|
38
|
+
active: { label: "Active camera" },
|
|
39
|
+
}
|
|
40
|
+
static editor = { rebuild: true }
|
|
41
|
+
|
|
42
|
+
/** Copy this place's projection onto a camera (`camera.follow(place)` does it for you). */
|
|
43
|
+
applyTo(camera: Camera): void {
|
|
44
|
+
camera.setProjection({ fov: this.fov, near: this.near, far: this.far })
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Edit mode: the frustum marker (−Z = view), anchored on the node so it follows drags live and
|
|
48
|
+
* clicking it selects the node. Play mode never calls this. */
|
|
49
|
+
rebuild(): void {
|
|
50
|
+
Gizmos.frustum(this.fov, { node: this.node, color: this.active ? "#ffffff" : "#9aa3b2" })
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
// Projected decals — bullet holes, footprints, dirt, blood. A DecalSet is one node that owns up to
|
|
2
|
+
// `max` decals drawn as ONE native renderable: each decal is a box that projects its image onto the
|
|
3
|
+
// opaque scene inside it (the engine reads the scene depth — creator-gl src/decals.cpp +
|
|
4
|
+
// materials/src/decal.mat), so it wraps floor-wall corners, stairs and curved surfaces without any
|
|
5
|
+
// geometry access. It lands on opaque surfaces only: never on particles, glass or other decals.
|
|
6
|
+
//
|
|
7
|
+
// Coordinates are in the SET's space: a set added at the scene root with no transform takes world
|
|
8
|
+
// coordinates (the usual case); a set parented to a door rides with it and takes door-local ones.
|
|
9
|
+
// Slots are a ring — a full set recycles its oldest decal, so `max` is the on-screen budget.
|
|
10
|
+
|
|
11
|
+
import { Node } from "./Node"
|
|
12
|
+
import { Material } from "./Material"
|
|
13
|
+
import type { Texture } from "./Texture"
|
|
14
|
+
import { Vec3, type Vec3Like } from "../math/vec"
|
|
15
|
+
import { Quat, type QuatLike } from "../math/quat"
|
|
16
|
+
import { Color, type ColorInput } from "../core/color"
|
|
17
|
+
|
|
18
|
+
/** Per-decal look and life — defaults come from the set's options. */
|
|
19
|
+
export type DecalOptions = {
|
|
20
|
+
/** Image width and height on the surface (world units); a number = square. Default 0.2. */
|
|
21
|
+
size?: number | [number, number]
|
|
22
|
+
/** Projection depth (world units): how far in front of and behind the hit point the decal still
|
|
23
|
+
* lands. Default = the smaller of width / height. */
|
|
24
|
+
depth?: number
|
|
25
|
+
/** Atlas cell (row-major from the top-left) for a set with a `sheet`; `"random"` picks one.
|
|
26
|
+
* Default 0. */
|
|
27
|
+
frame?: number | "random"
|
|
28
|
+
/** Tint multiplied into the image. Default white. */
|
|
29
|
+
tint?: ColorInput
|
|
30
|
+
/** 0..1 on top of the tint's alpha. Default 1. */
|
|
31
|
+
opacity?: number
|
|
32
|
+
/** Seconds until the decal is gone; 0 = stays until recycled. Default 0. */
|
|
33
|
+
life?: number
|
|
34
|
+
/** Seconds to fade in after spawning. Default 0. */
|
|
35
|
+
fadeIn?: number
|
|
36
|
+
/** Seconds of fade at the end of `life` (ignored with life 0). Default 0. */
|
|
37
|
+
fadeOut?: number
|
|
38
|
+
/** Opacity multipliers at the image's BOTTOM and TOP edge (−Y / +Y), interpolated along it — a
|
|
39
|
+
* tire mark that darkens as the slide deepens. Default `[1, 1]`. */
|
|
40
|
+
gradient?: [number, number]
|
|
41
|
+
/** Tilt of the image's bottom and top edge, as a unit-space slope (Δy per Δx across the box):
|
|
42
|
+
* the edge pivots on the corner of the side it keeps and cuts INTO the box on the other, so two
|
|
43
|
+
* boxes can meet on one shared line — what `DecalTrail` uses to mitre its joints. `0` = square.
|
|
44
|
+
* Default `[0, 0]`. */
|
|
45
|
+
caps?: [number, number]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type DecalSetOptions = DecalOptions & {
|
|
49
|
+
/** The material — `Material.decal({ map })` by default (`map` below is its shortcut). A custom
|
|
50
|
+
* material must keep decal.mat's vertex contract. */
|
|
51
|
+
material?: Material
|
|
52
|
+
/** Atlas texture for the default material. */
|
|
53
|
+
map?: Texture
|
|
54
|
+
/** Tangent-space normal atlas (same cell grid) → a RELIEF decal that bends the surface's
|
|
55
|
+
* lighting instead of painting a colour (`Material.decal` `normalMap`). Footprints and dents
|
|
56
|
+
* need only this; a bullet hole gives `map` too and its colour multiplies in. */
|
|
57
|
+
normalMap?: Texture
|
|
58
|
+
/** Relief strength for `normalMap`. Default 1. Default material only. */
|
|
59
|
+
bump?: number
|
|
60
|
+
/** Atlas grid of the map: columns, or [columns, rows]. Default 1 (the whole texture). */
|
|
61
|
+
sheet?: number | [number, number]
|
|
62
|
+
/** Slot budget — the most decals alive at once. Default 256. */
|
|
63
|
+
max?: number
|
|
64
|
+
/** Soft fraction (0..1) of the box's half depth at both ends, so an oblique surface leaves the
|
|
65
|
+
* box gently. Default 0.3. Default material only. */
|
|
66
|
+
edge?: number
|
|
67
|
+
/** Surfaces turned more than this away from the projection axis fade out — the cosine of the
|
|
68
|
+
* angle (0.3 ≈ 72°) keeps a floor hit off the wall it meets; 0 = project onto anything.
|
|
69
|
+
* Default 0.3. Default material only. */
|
|
70
|
+
angleFade?: number
|
|
71
|
+
/** HDR boost of the image (0 = none). Default material only. */
|
|
72
|
+
emissive?: number
|
|
73
|
+
name?: string
|
|
74
|
+
/** Coarse draw order, 0 (first) … 7 (last) — see `Mesh.renderPriority`. Default 3: a decal is
|
|
75
|
+
* part of the surface it sits on, so it draws before every other blended thing (meshes 4,
|
|
76
|
+
* particles 5) — the engine's depth sort compares object centres, and a set spread over the
|
|
77
|
+
* level has no useful centre. */
|
|
78
|
+
renderPriority?: number
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** `spawn` placement: where the image sits on the surface. */
|
|
82
|
+
export type DecalSpawnOptions = DecalOptions & {
|
|
83
|
+
/** World direction the image's top points along the surface (projected onto it) — a footprint's
|
|
84
|
+
* travel direction. Default: a random spin. */
|
|
85
|
+
up?: Vec3Like
|
|
86
|
+
/** Extra spin around the normal, radians. Default: random when `up` is not given, else 0. */
|
|
87
|
+
rotation?: number
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** `place` / `update` placement: a full frame, like a node looking INTO the surface (its −Z is the
|
|
91
|
+
* projection direction, +Y the image's top). */
|
|
92
|
+
export type DecalPlacement = DecalOptions & {
|
|
93
|
+
position: Vec3Like
|
|
94
|
+
/** A quaternion, or Euler degrees (YXZ) like `Node.eulerAngles`. Default identity = projecting
|
|
95
|
+
* down −Z. */
|
|
96
|
+
rotation?: QuatLike | Vec3Like
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const RECORD = 27
|
|
100
|
+
const NO_SLOT = 0xFFFFFFFF
|
|
101
|
+
|
|
102
|
+
export class DecalSet extends Node {
|
|
103
|
+
private _material: Material
|
|
104
|
+
private readonly _max: number
|
|
105
|
+
private readonly _cols: number
|
|
106
|
+
private readonly _rows: number
|
|
107
|
+
private readonly _defaults: DecalOptions
|
|
108
|
+
private readonly _rec = new Float32Array(RECORD)
|
|
109
|
+
private readonly _slots = new Map<number, DecalPlacement>()
|
|
110
|
+
private static _warned = false
|
|
111
|
+
|
|
112
|
+
constructor(options: DecalSetOptions = {}) {
|
|
113
|
+
super()
|
|
114
|
+
if (options.name) this.name = options.name
|
|
115
|
+
this._material = options.material ?? Material.decal({
|
|
116
|
+
map: options.map, normalMap: options.normalMap, bump: options.bump,
|
|
117
|
+
edge: options.edge, angleFade: options.angleFade, emissive: options.emissive,
|
|
118
|
+
})
|
|
119
|
+
this._max = Math.max(1, Math.round(options.max ?? 256))
|
|
120
|
+
const sheet = options.sheet ?? 1
|
|
121
|
+
this._cols = Math.max(1, Array.isArray(sheet) ? sheet[0] : sheet)
|
|
122
|
+
this._rows = Math.max(1, Array.isArray(sheet) ? sheet[1] : 1)
|
|
123
|
+
this._defaults = {
|
|
124
|
+
size: options.size ?? 0.2, depth: options.depth, frame: options.frame ?? 0, tint: options.tint,
|
|
125
|
+
opacity: options.opacity ?? 1, life: options.life ?? 0, fadeIn: options.fadeIn ?? 0, fadeOut: options.fadeOut ?? 0,
|
|
126
|
+
}
|
|
127
|
+
if (_creator.createDecalSet) {
|
|
128
|
+
_creator.createDecalSet(this.id, Material.idOf(this._material), this._max)
|
|
129
|
+
this.renderPriority = options.renderPriority ?? 3
|
|
130
|
+
} else if (!DecalSet._warned) {
|
|
131
|
+
DecalSet._warned = true
|
|
132
|
+
console.warn("[decals] this host has no createDecalSet — decals are not drawn")
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
get material(): Material { return this._material }
|
|
137
|
+
/** Decals alive right now. */
|
|
138
|
+
get count(): number { return _creator.decalCount ? _creator.decalCount(this.id) : 0 }
|
|
139
|
+
/** The slot budget the set was created with. */
|
|
140
|
+
get max(): number { return this._max }
|
|
141
|
+
|
|
142
|
+
/** Coarse draw order, 0 … 7 — see `Mesh.renderPriority`. Write-only. */
|
|
143
|
+
set renderPriority(v: number) {
|
|
144
|
+
if (_creator.setRenderPriority) _creator.setRenderPriority(this.id, Math.max(0, Math.min(7, Math.round(v))))
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Stamp a decal on a surface: `point` on it, `normal` out of it (a raycast hit, a foot plant
|
|
148
|
+
* with `Vec3.up`). Returns the slot for `update` / `remove` (−1 when the host draws none). */
|
|
149
|
+
spawn(point: Vec3Like, normal: Vec3Like, options: DecalSpawnOptions = {}): number {
|
|
150
|
+
const n = new Vec3(normal).normalize()
|
|
151
|
+
// Image up on the surface: the hint projected onto the plane, or any perpendicular.
|
|
152
|
+
let up = options.up ? new Vec3(options.up) : null
|
|
153
|
+
if (up) { up = up.sub(n.scale(up.dot(n))); if (up.length() < 1e-4) up = null }
|
|
154
|
+
if (!up) {
|
|
155
|
+
const helper = Math.abs(n.y) < 0.99 ? Vec3.up : Vec3.right
|
|
156
|
+
up = helper.sub(n.scale(helper.dot(n)))
|
|
157
|
+
}
|
|
158
|
+
up = up.normalize()
|
|
159
|
+
// right = up × normal keeps the frame right-handed (right × up = normal), so the image reads
|
|
160
|
+
// unmirrored from the normal's side; then the spin around the normal.
|
|
161
|
+
let right = up.cross(n)
|
|
162
|
+
const spin = options.rotation ?? (options.up ? 0 : Math.random() * Math.PI * 2)
|
|
163
|
+
if (spin !== 0) {
|
|
164
|
+
const c = Math.cos(spin), s = Math.sin(spin)
|
|
165
|
+
const r2 = right.scale(c).add(up.scale(s))
|
|
166
|
+
up = up.scale(c).sub(right.scale(s))
|
|
167
|
+
right = r2
|
|
168
|
+
}
|
|
169
|
+
return this._write(NO_SLOT, right, up, n, new Vec3(point), options)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Place a decal by a full frame (an editor-placed stain, a moving marker). Returns the slot. */
|
|
173
|
+
place(placement: DecalPlacement): number {
|
|
174
|
+
const slot = this._writePlacement(NO_SLOT, placement)
|
|
175
|
+
if (slot >= 0) this._slots.set(slot, placement)
|
|
176
|
+
return slot
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Move / restyle a placed decal; fields left out keep the values it was placed with. Its birth
|
|
180
|
+
* time (the life clock) is kept. */
|
|
181
|
+
update(slot: number, placement: Partial<DecalPlacement>): this {
|
|
182
|
+
const prev = this._slots.get(slot)
|
|
183
|
+
if (!prev) return this
|
|
184
|
+
const next = { ...prev, ...placement }
|
|
185
|
+
this._slots.set(slot, next)
|
|
186
|
+
this._writePlacement(slot, next)
|
|
187
|
+
return this
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
remove(slot: number): this {
|
|
191
|
+
this._slots.delete(slot)
|
|
192
|
+
if (_creator.removeDecal && slot >= 0) _creator.removeDecal(this.id, slot)
|
|
193
|
+
return this
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
clear(): this {
|
|
197
|
+
this._slots.clear()
|
|
198
|
+
if (_creator.clearDecals) _creator.clearDecals(this.id)
|
|
199
|
+
return this
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private _writePlacement(slot: number, p: DecalPlacement): number {
|
|
203
|
+
let q: Quat
|
|
204
|
+
if (p.rotation === undefined) q = Quat.identity
|
|
205
|
+
else if (p.rotation instanceof Quat || (p.rotation as any).w !== undefined || (p.rotation as any).length === 4) q = new Quat(p.rotation as QuatLike)
|
|
206
|
+
else { const e = new Vec3(p.rotation as Vec3Like); q = Quat.fromEuler(e.x, e.y, e.z) }
|
|
207
|
+
const right = q.rotateVec3(Vec3.right)
|
|
208
|
+
const up = q.rotateVec3(Vec3.up)
|
|
209
|
+
const normal = right.cross(up) // +Z of the frame: the node's −Z looks into the surface
|
|
210
|
+
return this._write(slot, right, up, normal, new Vec3(p.position), p)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** @internal One decal's frame → record → host (`DecalTrail` writes its segments through here). */
|
|
214
|
+
_write(slot: number, right: Vec3, up: Vec3, normal: Vec3, centre: Vec3, o: DecalOptions): number {
|
|
215
|
+
const d = this._defaults
|
|
216
|
+
const size = o.size ?? d.size!
|
|
217
|
+
const w = typeof size === "number" ? size : size[0]
|
|
218
|
+
const h = typeof size === "number" ? size : size[1]
|
|
219
|
+
const depth = o.depth ?? d.depth ?? Math.min(w, h)
|
|
220
|
+
const r = this._rec
|
|
221
|
+
r[0] = right.x * w; r[1] = right.y * w; r[2] = right.z * w
|
|
222
|
+
r[3] = up.x * h; r[4] = up.y * h; r[5] = up.z * h
|
|
223
|
+
r[6] = normal.x * depth; r[7] = normal.y * depth; r[8] = normal.z * depth
|
|
224
|
+
r[9] = centre.x; r[10] = centre.y; r[11] = centre.z
|
|
225
|
+
// atlas cell → rect (v grows downward: v0 is the cell's top)
|
|
226
|
+
const cells = this._cols * this._rows
|
|
227
|
+
const frameOpt = o.frame ?? d.frame!
|
|
228
|
+
const frame = frameOpt === "random" ? Math.floor(Math.random() * cells) : ((Math.floor(frameOpt) % cells) + cells) % cells
|
|
229
|
+
const col = frame % this._cols, row = Math.floor(frame / this._cols)
|
|
230
|
+
r[12] = col / this._cols; r[13] = row / this._rows; r[14] = (col + 1) / this._cols; r[15] = (row + 1) / this._rows
|
|
231
|
+
const tint = o.tint ?? d.tint
|
|
232
|
+
const [tr, tg, tb, ta] = tint === undefined ? [1, 1, 1, 1] : Color.toRgba01(tint)
|
|
233
|
+
const opacity = o.opacity ?? d.opacity!
|
|
234
|
+
r[16] = tr; r[17] = tg; r[18] = tb; r[19] = ta * opacity
|
|
235
|
+
r[20] = o.life ?? d.life!; r[21] = o.fadeIn ?? d.fadeIn!; r[22] = o.fadeOut ?? d.fadeOut!
|
|
236
|
+
const caps = o.caps ?? d.caps
|
|
237
|
+
r[23] = caps ? caps[0] : 0; r[24] = caps ? caps[1] : 0
|
|
238
|
+
const gradient = o.gradient ?? d.gradient
|
|
239
|
+
r[25] = gradient ? gradient[0] : 1; r[26] = gradient ? gradient[1] : 1
|
|
240
|
+
if (slot === NO_SLOT) {
|
|
241
|
+
if (!_creator.addDecal) return -1
|
|
242
|
+
const s = _creator.addDecal(this.id, r)
|
|
243
|
+
return s === NO_SLOT ? -1 : s
|
|
244
|
+
}
|
|
245
|
+
if (_creator.updateDecal) _creator.updateDecal(this.id, slot, r)
|
|
246
|
+
return slot
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** A `DecalTrail`'s look: the strip's width, and what every segment carries. */
|
|
251
|
+
export type DecalTrailOptions = Omit<DecalOptions, "size" | "caps" | "gradient"> & {
|
|
252
|
+
/** Width of the strip on the surface, world units. */
|
|
253
|
+
width: number
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// The mitre stops making sense past this turn between two segments (a sharp reversal would need a
|
|
257
|
+
// box longer than the strip is wide); the trail breaks there and starts afresh.
|
|
258
|
+
const TRAIL_MAX_SLOPE = 1.75 // tan 60°
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* A continuous strip of decals along a path — a tire's skid mark, a dragged body, a tread track.
|
|
262
|
+
* Feed it points (`add`) as the thing moves; every new point becomes one box-decal SEGMENT from the
|
|
263
|
+
* previous point, its image's up along the travel, and the joints between segments are MITRED: each
|
|
264
|
+
* box is cut along the bisector it shares with its neighbour (`caps`), so the strip has no overlaps
|
|
265
|
+
* darkening the outside of a bend and no wedges of gap on the inside. The opacity given with each
|
|
266
|
+
* point is interpolated along the segment (`gradient`), so a mark can darken as a slide deepens and
|
|
267
|
+
* fade as it ends. Segments are the set's decals — same ring, same `max` budget: a set of 900 holds
|
|
268
|
+
* 900 segments across every trail drawn from it.
|
|
269
|
+
*
|
|
270
|
+
* The previous segment is REWRITTEN when the next point arrives (its far cap becomes the shared
|
|
271
|
+
* mitre), through `DecalSet.update`'s path — a recycled slot is simply left alone by the engine.
|
|
272
|
+
*/
|
|
273
|
+
export class DecalTrail {
|
|
274
|
+
private _prev: { point: Vec3, opacity: number } | null = null
|
|
275
|
+
private _seg: {
|
|
276
|
+
slot: number, start: Vec3, end: Vec3, dir: Vec3, right: Vec3, normal: Vec3,
|
|
277
|
+
slopeStart: number, a0: number, a1: number,
|
|
278
|
+
} | null = null
|
|
279
|
+
|
|
280
|
+
readonly set: DecalSet
|
|
281
|
+
readonly options: DecalTrailOptions
|
|
282
|
+
|
|
283
|
+
constructor(set: DecalSet, options: DecalTrailOptions) {
|
|
284
|
+
this.set = set
|
|
285
|
+
this.options = options
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Extend the strip to `point` on a surface with `normal`, `opacity` (0..1) at that point. The
|
|
289
|
+
* first call after a start / `end()` only anchors the strip. Returns the new segment's slot, or
|
|
290
|
+
* −1 when nothing was drawn (the anchor, a point that did not move, no host support). */
|
|
291
|
+
add(point: Vec3Like, normal: Vec3Like, opacity = 1): number {
|
|
292
|
+
const p = new Vec3(point)
|
|
293
|
+
const n = new Vec3(normal).normalize()
|
|
294
|
+
const prev = this._prev
|
|
295
|
+
this._prev = { point: p, opacity }
|
|
296
|
+
if (!prev) return -1
|
|
297
|
+
// The segment, flattened onto the surface plane.
|
|
298
|
+
let d = p.sub(prev.point)
|
|
299
|
+
d = d.sub(n.scale(d.dot(n)))
|
|
300
|
+
const len = d.length()
|
|
301
|
+
if (len < 1e-3) { this._prev = prev; return -1 } // not moved: wait for a real step
|
|
302
|
+
const dir = d.scale(1 / len)
|
|
303
|
+
const right = dir.cross(n) // DecalSet.spawn's frame: right × up = normal, image unmirrored from the normal's side
|
|
304
|
+
const w = this.options.width
|
|
305
|
+
|
|
306
|
+
// Mitre with the previous segment: the joint's bisector is the sum of the two right normals,
|
|
307
|
+
// and each box takes it as a cap — the previous one at its END (rewritten now), this one at its
|
|
308
|
+
// START — with the slopes measured in each box's own frame. A trail's first segment, or one
|
|
309
|
+
// after a break, starts square.
|
|
310
|
+
let slopeStart = 0
|
|
311
|
+
const last = this._seg
|
|
312
|
+
if (last) {
|
|
313
|
+
const lastDir = last.dir.sub(n.scale(last.dir.dot(n))).normalize()
|
|
314
|
+
const lastRight = lastDir.cross(n)
|
|
315
|
+
const m = lastRight.add(right)
|
|
316
|
+
const denom = m.dot(right)
|
|
317
|
+
const slopeEnd = Math.abs(denom) > 1e-4 ? m.dot(lastDir) / denom : Infinity
|
|
318
|
+
if (Math.abs(slopeEnd) <= TRAIL_MAX_SLOPE) {
|
|
319
|
+
this._rewrite(last, slopeEnd)
|
|
320
|
+
slopeStart = -slopeEnd
|
|
321
|
+
} else {
|
|
322
|
+
this._rewrite(last, 0) // too sharp to mitre: leave the last one square, start afresh
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
const seg = { slot: -1, start: prev.point, end: p, dir, right, normal: n, slopeStart, a0: prev.opacity, a1: opacity }
|
|
326
|
+
seg.slot = this._box(seg, 0, -1)
|
|
327
|
+
this._seg = seg
|
|
328
|
+
return seg.slot
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Finish the strip. With a `point`, one last segment runs out to it at opacity 0 — a mark that
|
|
332
|
+
* tapers away instead of stopping dead. The next `add` anchors a new strip. */
|
|
333
|
+
end(point?: Vec3Like, normal: Vec3Like = Vec3.up): this {
|
|
334
|
+
if (point && this._prev) this.add(point, normal, 0)
|
|
335
|
+
this._prev = null
|
|
336
|
+
this._seg = null
|
|
337
|
+
return this
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private _rewrite(seg: NonNullable<DecalTrail["_seg"]>, slopeEnd: number): void {
|
|
341
|
+
if (seg.slot >= 0) this._box(seg, slopeEnd, seg.slot)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Write one segment's box: the strip between its joints, extended past each mitred joint by the
|
|
345
|
+
* mitre's reach (w/2 · |slope|) so the tilted cap still passes through the joint's centre. */
|
|
346
|
+
private _box(seg: NonNullable<DecalTrail["_seg"]>, slopeEnd: number, slot: number): number {
|
|
347
|
+
const w = this.options.width
|
|
348
|
+
const L = seg.end.sub(seg.start).length()
|
|
349
|
+
const extStart = 0.5 * w * Math.abs(seg.slopeStart)
|
|
350
|
+
const extEnd = 0.5 * w * Math.abs(slopeEnd)
|
|
351
|
+
const boxLen = L + extStart + extEnd
|
|
352
|
+
const centre = seg.start.add(seg.dir.scale(0.5 * boxLen - extStart))
|
|
353
|
+
// World slopes → unit-space slopes: x is measured in widths, y in box lengths.
|
|
354
|
+
const caps: [number, number] = [ seg.slopeStart * (w / boxLen), slopeEnd * (w / boxLen) ]
|
|
355
|
+
const { width: _w, ...rest } = this.options
|
|
356
|
+
const s = this.set._write(slot < 0 ? NO_SLOT : slot, seg.right, seg.dir, seg.normal, centre,
|
|
357
|
+
{ ...rest, size: [ w, boxLen ], caps, gradient: [ seg.a0, seg.a1 ] })
|
|
358
|
+
return slot < 0 ? s : slot
|
|
359
|
+
}
|
|
360
|
+
}
|