spoint 0.1.666 → 0.1.668
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.
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Gizmo mesh-building helpers for editor.js's createEditor: translate/rotate/scale handle geometry,
|
|
2
|
+
// hit-proxy construction, the closest-point-on-axis-line drag math, and the trigger-volume radius
|
|
3
|
+
// gizmo. Split out as editor.js's largest self-contained block -- each function here only touches
|
|
4
|
+
// THREE + its own params/module constants, never createEditor's own closure state (gizmoGroup,
|
|
5
|
+
// machine, scene, radiusGizmoGroup all stay in editor.js since attachGizmo/attachRadiusGizmo/
|
|
6
|
+
// _highlightAxis mutate them directly).
|
|
7
|
+
|
|
8
|
+
import * as THREE from 'three'
|
|
9
|
+
|
|
10
|
+
const _coarsePointer = () => (typeof matchMedia === 'function' && matchMedia('(pointer:coarse)').matches)
|
|
11
|
+
|
|
12
|
+
const RADIUS_HANDLE_COUNT = 4 // 4 draggable ring handles (N/E/S/W) for an easier target than the thin ring itself
|
|
13
|
+
const _RADIUS_COLOR = 0xffaa00
|
|
14
|
+
|
|
15
|
+
// Closest point on the 3D LINE through `origin` along direction `axis` to the mouse `ray` -- the
|
|
16
|
+
// standard closed-form for two skew lines' closest approach, well-conditioned for any camera
|
|
17
|
+
// angle except the ray running exactly parallel to axis (see onStart's comment for why this
|
|
18
|
+
// replaces plane-intersection for every single-axis dot-product-consumed drag response).
|
|
19
|
+
function _closestPointOnAxisLine(ray, origin, axis) {
|
|
20
|
+
// Line 1: ray.origin + t1*ray.direction (mouse ray). Line 2: origin + t2*axis (the handle's axis).
|
|
21
|
+
const w0 = ray.origin.clone().sub(origin)
|
|
22
|
+
const a = ray.direction.dot(ray.direction) // == 1 (ray.direction is unit length)
|
|
23
|
+
const b = ray.direction.dot(axis)
|
|
24
|
+
const c = axis.dot(axis) // == 1 (_axisVec always returns unit length)
|
|
25
|
+
const d = ray.direction.dot(w0)
|
|
26
|
+
const e = axis.dot(w0)
|
|
27
|
+
const denom = a * c - b * b
|
|
28
|
+
// denom -> 0 only when the ray runs parallel to axis (camera looking straight down the handle);
|
|
29
|
+
// fall back to the origin itself rather than dividing by ~0 into a huge/NaN point.
|
|
30
|
+
const t2 = Math.abs(denom) < 1e-8 ? 0 : (a * e - b * d) / denom
|
|
31
|
+
return origin.clone().addScaledVector(axis, t2)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Invisible enlarged pick proxy: coarse pointers get a bigger raycast target than the 0.04-thick visible handle.
|
|
35
|
+
function _addHitProxy(group, axis, geom, place) {
|
|
36
|
+
const proxy = new THREE.Mesh(geom, new THREE.MeshBasicMaterial({ visible: false }))
|
|
37
|
+
proxy.visible = false
|
|
38
|
+
proxy.userData.gizmoAxis = axis
|
|
39
|
+
proxy.userData.isHitProxy = true
|
|
40
|
+
proxy.renderOrder = 1000
|
|
41
|
+
place(proxy)
|
|
42
|
+
group.add(proxy)
|
|
43
|
+
}
|
|
44
|
+
function _axisHitProxy(group, axis) {
|
|
45
|
+
const fat = _coarsePointer() ? 0.34 : 0.14
|
|
46
|
+
const geom = new THREE.CylinderGeometry(fat, fat, 1.3, 6)
|
|
47
|
+
geom.translate(0, 0.65, 0)
|
|
48
|
+
_addHitProxy(group, axis, geom, (p) => {
|
|
49
|
+
if (axis === 'x') p.rotation.z = -Math.PI / 2
|
|
50
|
+
else if (axis === 'z') p.rotation.x = Math.PI / 2
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
function _ringHitProxy(group, axis, rx, ry) {
|
|
54
|
+
const fat = _coarsePointer() ? 0.2 : 0.08
|
|
55
|
+
const geom = new THREE.TorusGeometry(1, fat, 6, 24)
|
|
56
|
+
_addHitProxy(group, axis, geom, (p) => { p.rotation.x = rx; p.rotation.y = ry })
|
|
57
|
+
}
|
|
58
|
+
const _HIGHLIGHT = 0xffff00
|
|
59
|
+
function _tagBaseColor(mesh) { mesh.userData.baseColor = mesh.material.color.getHex(); return mesh }
|
|
60
|
+
function buildTranslateGizmo() {
|
|
61
|
+
const g = new THREE.Group(); g.userData.isGizmo = true; g.userData.mode = 'translate'
|
|
62
|
+
for (const [axis, color, rx, rz] of [['x',0xff2222,0,-Math.PI/2],['y',0x22ff22,0,0],['z',0x2222ff,Math.PI/2,0]]) {
|
|
63
|
+
const mat = new THREE.MeshBasicMaterial({ color, depthTest: false })
|
|
64
|
+
const shaft = _tagBaseColor(new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.04, 1, 8), mat))
|
|
65
|
+
shaft.geometry.translate(0, 0.5, 0); shaft.rotation.x = rx; shaft.rotation.z = rz
|
|
66
|
+
shaft.userData.gizmoAxis = axis; shaft.renderOrder = 999
|
|
67
|
+
const cap = _tagBaseColor(new THREE.Mesh(new THREE.ConeGeometry(0.1, 0.25, 8), mat))
|
|
68
|
+
cap.geometry.translate(0, 0.125, 0)
|
|
69
|
+
if (axis === 'x') { cap.rotation.z = -Math.PI/2; cap.position.set(1, 0, 0) }
|
|
70
|
+
else if (axis === 'y') cap.position.set(0, 1, 0)
|
|
71
|
+
else { cap.rotation.x = Math.PI/2; cap.position.set(0, 0, 1) }
|
|
72
|
+
cap.userData.gizmoAxis = axis; cap.renderOrder = 999
|
|
73
|
+
g.add(shaft); g.add(cap)
|
|
74
|
+
_axisHitProxy(g, axis)
|
|
75
|
+
}
|
|
76
|
+
return g
|
|
77
|
+
}
|
|
78
|
+
function buildRotateGizmo() {
|
|
79
|
+
const g = new THREE.Group(); g.userData.isGizmo = true; g.userData.mode = 'rotate'
|
|
80
|
+
for (const [axis,color,rx,ry] of [['x',0xff2222,0,Math.PI/2],['y',0x22ff22,Math.PI/2,0],['z',0x2222ff,0,0]]) {
|
|
81
|
+
const ring = _tagBaseColor(new THREE.Mesh(new THREE.TorusGeometry(1,0.04,8,32),new THREE.MeshBasicMaterial({color,depthTest:false,side:THREE.DoubleSide})))
|
|
82
|
+
ring.rotation.x=rx;ring.rotation.y=ry;ring.userData.gizmoAxis=axis;ring.renderOrder=999;g.add(ring)
|
|
83
|
+
_ringHitProxy(g, axis, rx, ry)
|
|
84
|
+
}
|
|
85
|
+
return g
|
|
86
|
+
}
|
|
87
|
+
function buildScaleGizmo() {
|
|
88
|
+
const g = new THREE.Group(); g.userData.isGizmo = true; g.userData.mode = 'scale'
|
|
89
|
+
for (const [axis,color,rx,rz,px,py,pz] of [['x',0xff2222,0,-Math.PI/2,1,0,0],['y',0x22ff22,0,0,0,1,0],['z',0x2222ff,Math.PI/2,0,0,0,1]]) {
|
|
90
|
+
const mat=new THREE.MeshBasicMaterial({color,depthTest:false})
|
|
91
|
+
const shaft=_tagBaseColor(new THREE.Mesh(new THREE.CylinderGeometry(0.04,0.04,1,8),mat));shaft.geometry.translate(0,0.5,0);shaft.rotation.x=rx;shaft.rotation.z=rz;shaft.userData.gizmoAxis=axis;shaft.renderOrder=999
|
|
92
|
+
const box=_tagBaseColor(new THREE.Mesh(new THREE.BoxGeometry(0.2,0.2,0.2),mat));box.position.set(px,py,pz);box.userData.gizmoAxis=axis;box.renderOrder=999
|
|
93
|
+
g.add(shaft);g.add(box)
|
|
94
|
+
_axisHitProxy(g, axis)
|
|
95
|
+
}
|
|
96
|
+
return g
|
|
97
|
+
}
|
|
98
|
+
function _highlightAxis(axis) {
|
|
99
|
+
if (!gizmoGroup) return
|
|
100
|
+
gizmoGroup.children.forEach(c => {
|
|
101
|
+
if (!c.userData.gizmoAxis || c.userData.isHitProxy || c.userData.baseColor === undefined) return
|
|
102
|
+
c.material.color.setHex(c.userData.gizmoAxis === axis ? _HIGHLIGHT : c.userData.baseColor)
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
function _buildGizmo() { return _mode()==='rotate'?buildRotateGizmo():_mode()==='scale'?buildScaleGizmo():buildTranslateGizmo() }
|
|
106
|
+
|
|
107
|
+
// Radius-drag handle (trigger-volume-radius-gizmo-handle): a flat horizontal ring at the entity's
|
|
108
|
+
// Y position, radius matching custom.radius, shown ADDITIONALLY alongside whichever translate/
|
|
109
|
+
// rotate/scale gizmo is currently active -- not a 4th gizmoMode, since a radius-shaped entity still
|
|
110
|
+
// wants normal move/rotate/scale on its position/transform too. Separate group (radiusGizmoGroup)
|
|
111
|
+
// so it survives independently of gizmoGroup's per-mode rebuild in _buildGizmo/attachGizmo.
|
|
112
|
+
function _entityHasRadiusGizmo(mesh) {
|
|
113
|
+
// Scoped to trigger-volume-shaped entities (custom._trigger, set by apps/trigger-volume/index.js
|
|
114
|
+
// setup()) for this slice -- capture-zone/shrinking-zone-center reuse is a separate PRD row
|
|
115
|
+
// (capture-zone-shrinking-zone-gizmo-followup-check) pending a check of whether their radius
|
|
116
|
+
// semantics (capture-zone: static; shrinking-zone-center: server-shrinks-over-time) allow the
|
|
117
|
+
// identical handle without a live-vs-authored-value conflict.
|
|
118
|
+
return !!(mesh && mesh.userData?.custom?._trigger)
|
|
119
|
+
}
|
|
120
|
+
function _entityRadius(mesh) {
|
|
121
|
+
const r = mesh?.userData?.custom?.radius
|
|
122
|
+
return (typeof r === 'number' && Number.isFinite(r) && r > 0) ? r : 3
|
|
123
|
+
}
|
|
124
|
+
function buildRadiusGizmo(radius) {
|
|
125
|
+
const g = new THREE.Group(); g.userData.isRadiusGizmo = true
|
|
126
|
+
const ringMat = new THREE.MeshBasicMaterial({ color: _RADIUS_COLOR, depthTest: false, transparent: true, opacity: 0.85 })
|
|
127
|
+
const ring = _tagBaseColor(new THREE.Mesh(new THREE.TorusGeometry(radius, 0.03, 6, 48), ringMat))
|
|
128
|
+
ring.rotation.x = Math.PI / 2 // lie flat in the XZ plane (horizontal, matching a ground-footprint radius)
|
|
129
|
+
ring.userData.gizmoAxis = 'radius'
|
|
130
|
+
ring.renderOrder = 999
|
|
131
|
+
g.add(ring)
|
|
132
|
+
// 4 cardinal handle knobs, each an enlarged hit target (coarse-pointer-aware like _axisHitProxy)
|
|
133
|
+
// sitting ON the ring so a drag can start from any of 4 directions, not just a thin-torus pick.
|
|
134
|
+
const fat = _coarsePointer() ? 0.22 : 0.1
|
|
135
|
+
for (let i = 0; i < RADIUS_HANDLE_COUNT; i++) {
|
|
136
|
+
const ang = (i / RADIUS_HANDLE_COUNT) * Math.PI * 2
|
|
137
|
+
const knob = _tagBaseColor(new THREE.Mesh(new THREE.SphereGeometry(fat, 10, 8), new THREE.MeshBasicMaterial({ color: _RADIUS_COLOR, depthTest: false, transparent: true, opacity: 0.85 })))
|
|
138
|
+
knob.position.set(Math.cos(ang) * radius, 0, Math.sin(ang) * radius)
|
|
139
|
+
knob.userData.gizmoAxis = 'radius'
|
|
140
|
+
knob.renderOrder = 1000
|
|
141
|
+
g.add(knob)
|
|
142
|
+
}
|
|
143
|
+
return g
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export {
|
|
147
|
+
_closestPointOnAxisLine, _addHitProxy, _axisHitProxy, _ringHitProxy, _tagBaseColor,
|
|
148
|
+
buildTranslateGizmo, buildRotateGizmo, buildScaleGizmo,
|
|
149
|
+
_entityHasRadiusGizmo, _entityRadius, buildRadiusGizmo, RADIUS_HANDLE_COUNT
|
|
150
|
+
}
|
package/client/editor/editor.js
CHANGED
|
@@ -4,8 +4,11 @@ import { MSG } from '/src/protocol/MessageTypes.js'
|
|
|
4
4
|
import { showToast } from './EditPanelDOM.js'
|
|
5
5
|
import { STRINGS } from '../core/strings.js'
|
|
6
6
|
import { createWaypointPathOverlay } from './WaypointPath.js'
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
import {
|
|
8
|
+
_closestPointOnAxisLine, _addHitProxy, _axisHitProxy, _ringHitProxy, _tagBaseColor,
|
|
9
|
+
buildTranslateGizmo, buildRotateGizmo, buildScaleGizmo,
|
|
10
|
+
_entityHasRadiusGizmo, _entityRadius, buildRadiusGizmo, RADIUS_HANDLE_COUNT
|
|
11
|
+
} from './EditorGizmoBuild.js'
|
|
9
12
|
|
|
10
13
|
// --- Camera bookmarks (editor-camera-bookmarks) -------------------------------------------
|
|
11
14
|
// localStorage-persisted per-world (keyed by the ?world= URL param, falling back to 'default'
|
|
@@ -76,90 +79,8 @@ export function createEditor({ scene, camera, renderer, client, entityMeshes, pl
|
|
|
76
79
|
// intersection -- applyGizmoDrag's per-move-frame sampling must reuse the SAME technique the
|
|
77
80
|
// drag started with, or the delta comparison mixes two different coordinate derivations.
|
|
78
81
|
let _dragUsesAxisLine = false
|
|
79
|
-
|
|
80
|
-
// Closest point on the 3D LINE through `origin` along direction `axis` to the mouse `ray` -- the
|
|
81
|
-
// standard closed-form for two skew lines' closest approach, well-conditioned for any camera
|
|
82
|
-
// angle except the ray running exactly parallel to axis (see onStart's comment for why this
|
|
83
|
-
// replaces plane-intersection for every single-axis dot-product-consumed drag response).
|
|
84
|
-
function _closestPointOnAxisLine(ray, origin, axis) {
|
|
85
|
-
// Line 1: ray.origin + t1*ray.direction (mouse ray). Line 2: origin + t2*axis (the handle's axis).
|
|
86
|
-
const w0 = ray.origin.clone().sub(origin)
|
|
87
|
-
const a = ray.direction.dot(ray.direction) // == 1 (ray.direction is unit length)
|
|
88
|
-
const b = ray.direction.dot(axis)
|
|
89
|
-
const c = axis.dot(axis) // == 1 (_axisVec always returns unit length)
|
|
90
|
-
const d = ray.direction.dot(w0)
|
|
91
|
-
const e = axis.dot(w0)
|
|
92
|
-
const denom = a * c - b * b
|
|
93
|
-
// denom -> 0 only when the ray runs parallel to axis (camera looking straight down the handle);
|
|
94
|
-
// fall back to the origin itself rather than dividing by ~0 into a huge/NaN point.
|
|
95
|
-
const t2 = Math.abs(denom) < 1e-8 ? 0 : (a * e - b * d) / denom
|
|
96
|
-
return origin.clone().addScaledVector(axis, t2)
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// Invisible enlarged pick proxy: coarse pointers get a bigger raycast target than the 0.04-thick visible handle.
|
|
100
|
-
function _addHitProxy(group, axis, geom, place) {
|
|
101
|
-
const proxy = new THREE.Mesh(geom, new THREE.MeshBasicMaterial({ visible: false }))
|
|
102
|
-
proxy.visible = false
|
|
103
|
-
proxy.userData.gizmoAxis = axis
|
|
104
|
-
proxy.userData.isHitProxy = true
|
|
105
|
-
proxy.renderOrder = 1000
|
|
106
|
-
place(proxy)
|
|
107
|
-
group.add(proxy)
|
|
108
|
-
}
|
|
109
|
-
function _axisHitProxy(group, axis) {
|
|
110
|
-
const fat = _coarsePointer() ? 0.34 : 0.14
|
|
111
|
-
const geom = new THREE.CylinderGeometry(fat, fat, 1.3, 6)
|
|
112
|
-
geom.translate(0, 0.65, 0)
|
|
113
|
-
_addHitProxy(group, axis, geom, (p) => {
|
|
114
|
-
if (axis === 'x') p.rotation.z = -Math.PI / 2
|
|
115
|
-
else if (axis === 'z') p.rotation.x = Math.PI / 2
|
|
116
|
-
})
|
|
117
|
-
}
|
|
118
|
-
function _ringHitProxy(group, axis, rx, ry) {
|
|
119
|
-
const fat = _coarsePointer() ? 0.2 : 0.08
|
|
120
|
-
const geom = new THREE.TorusGeometry(1, fat, 6, 24)
|
|
121
|
-
_addHitProxy(group, axis, geom, (p) => { p.rotation.x = rx; p.rotation.y = ry })
|
|
122
|
-
}
|
|
123
82
|
const _HIGHLIGHT = 0xffff00
|
|
124
|
-
|
|
125
|
-
function buildTranslateGizmo() {
|
|
126
|
-
const g = new THREE.Group(); g.userData.isGizmo = true; g.userData.mode = 'translate'
|
|
127
|
-
for (const [axis, color, rx, rz] of [['x',0xff2222,0,-Math.PI/2],['y',0x22ff22,0,0],['z',0x2222ff,Math.PI/2,0]]) {
|
|
128
|
-
const mat = new THREE.MeshBasicMaterial({ color, depthTest: false })
|
|
129
|
-
const shaft = _tagBaseColor(new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.04, 1, 8), mat))
|
|
130
|
-
shaft.geometry.translate(0, 0.5, 0); shaft.rotation.x = rx; shaft.rotation.z = rz
|
|
131
|
-
shaft.userData.gizmoAxis = axis; shaft.renderOrder = 999
|
|
132
|
-
const cap = _tagBaseColor(new THREE.Mesh(new THREE.ConeGeometry(0.1, 0.25, 8), mat))
|
|
133
|
-
cap.geometry.translate(0, 0.125, 0)
|
|
134
|
-
if (axis === 'x') { cap.rotation.z = -Math.PI/2; cap.position.set(1, 0, 0) }
|
|
135
|
-
else if (axis === 'y') cap.position.set(0, 1, 0)
|
|
136
|
-
else { cap.rotation.x = Math.PI/2; cap.position.set(0, 0, 1) }
|
|
137
|
-
cap.userData.gizmoAxis = axis; cap.renderOrder = 999
|
|
138
|
-
g.add(shaft); g.add(cap)
|
|
139
|
-
_axisHitProxy(g, axis)
|
|
140
|
-
}
|
|
141
|
-
return g
|
|
142
|
-
}
|
|
143
|
-
function buildRotateGizmo() {
|
|
144
|
-
const g = new THREE.Group(); g.userData.isGizmo = true; g.userData.mode = 'rotate'
|
|
145
|
-
for (const [axis,color,rx,ry] of [['x',0xff2222,0,Math.PI/2],['y',0x22ff22,Math.PI/2,0],['z',0x2222ff,0,0]]) {
|
|
146
|
-
const ring = _tagBaseColor(new THREE.Mesh(new THREE.TorusGeometry(1,0.04,8,32),new THREE.MeshBasicMaterial({color,depthTest:false,side:THREE.DoubleSide})))
|
|
147
|
-
ring.rotation.x=rx;ring.rotation.y=ry;ring.userData.gizmoAxis=axis;ring.renderOrder=999;g.add(ring)
|
|
148
|
-
_ringHitProxy(g, axis, rx, ry)
|
|
149
|
-
}
|
|
150
|
-
return g
|
|
151
|
-
}
|
|
152
|
-
function buildScaleGizmo() {
|
|
153
|
-
const g = new THREE.Group(); g.userData.isGizmo = true; g.userData.mode = 'scale'
|
|
154
|
-
for (const [axis,color,rx,rz,px,py,pz] of [['x',0xff2222,0,-Math.PI/2,1,0,0],['y',0x22ff22,0,0,0,1,0],['z',0x2222ff,Math.PI/2,0,0,0,1]]) {
|
|
155
|
-
const mat=new THREE.MeshBasicMaterial({color,depthTest:false})
|
|
156
|
-
const shaft=_tagBaseColor(new THREE.Mesh(new THREE.CylinderGeometry(0.04,0.04,1,8),mat));shaft.geometry.translate(0,0.5,0);shaft.rotation.x=rx;shaft.rotation.z=rz;shaft.userData.gizmoAxis=axis;shaft.renderOrder=999
|
|
157
|
-
const box=_tagBaseColor(new THREE.Mesh(new THREE.BoxGeometry(0.2,0.2,0.2),mat));box.position.set(px,py,pz);box.userData.gizmoAxis=axis;box.renderOrder=999
|
|
158
|
-
g.add(shaft);g.add(box)
|
|
159
|
-
_axisHitProxy(g, axis)
|
|
160
|
-
}
|
|
161
|
-
return g
|
|
162
|
-
}
|
|
83
|
+
|
|
163
84
|
function _highlightAxis(axis) {
|
|
164
85
|
if (!gizmoGroup) return
|
|
165
86
|
gizmoGroup.children.forEach(c => {
|
|
@@ -175,41 +96,7 @@ export function createEditor({ scene, camera, renderer, client, entityMeshes, pl
|
|
|
175
96
|
// wants normal move/rotate/scale on its position/transform too. Separate group (radiusGizmoGroup)
|
|
176
97
|
// so it survives independently of gizmoGroup's per-mode rebuild in _buildGizmo/attachGizmo.
|
|
177
98
|
let radiusGizmoGroup = null
|
|
178
|
-
const
|
|
179
|
-
const _RADIUS_COLOR = 0xffaa00, _RADIUS_HIGHLIGHT = 0xffff00
|
|
180
|
-
function _entityHasRadiusGizmo(mesh) {
|
|
181
|
-
// Scoped to trigger-volume-shaped entities (custom._trigger, set by apps/trigger-volume/index.js
|
|
182
|
-
// setup()) for this slice -- capture-zone/shrinking-zone-center reuse is a separate PRD row
|
|
183
|
-
// (capture-zone-shrinking-zone-gizmo-followup-check) pending a check of whether their radius
|
|
184
|
-
// semantics (capture-zone: static; shrinking-zone-center: server-shrinks-over-time) allow the
|
|
185
|
-
// identical handle without a live-vs-authored-value conflict.
|
|
186
|
-
return !!(mesh && mesh.userData?.custom?._trigger)
|
|
187
|
-
}
|
|
188
|
-
function _entityRadius(mesh) {
|
|
189
|
-
const r = mesh?.userData?.custom?.radius
|
|
190
|
-
return (typeof r === 'number' && Number.isFinite(r) && r > 0) ? r : 3
|
|
191
|
-
}
|
|
192
|
-
function buildRadiusGizmo(radius) {
|
|
193
|
-
const g = new THREE.Group(); g.userData.isRadiusGizmo = true
|
|
194
|
-
const ringMat = new THREE.MeshBasicMaterial({ color: _RADIUS_COLOR, depthTest: false, transparent: true, opacity: 0.85 })
|
|
195
|
-
const ring = _tagBaseColor(new THREE.Mesh(new THREE.TorusGeometry(radius, 0.03, 6, 48), ringMat))
|
|
196
|
-
ring.rotation.x = Math.PI / 2 // lie flat in the XZ plane (horizontal, matching a ground-footprint radius)
|
|
197
|
-
ring.userData.gizmoAxis = 'radius'
|
|
198
|
-
ring.renderOrder = 999
|
|
199
|
-
g.add(ring)
|
|
200
|
-
// 4 cardinal handle knobs, each an enlarged hit target (coarse-pointer-aware like _axisHitProxy)
|
|
201
|
-
// sitting ON the ring so a drag can start from any of 4 directions, not just a thin-torus pick.
|
|
202
|
-
const fat = _coarsePointer() ? 0.22 : 0.1
|
|
203
|
-
for (let i = 0; i < RADIUS_HANDLE_COUNT; i++) {
|
|
204
|
-
const ang = (i / RADIUS_HANDLE_COUNT) * Math.PI * 2
|
|
205
|
-
const knob = _tagBaseColor(new THREE.Mesh(new THREE.SphereGeometry(fat, 10, 8), new THREE.MeshBasicMaterial({ color: _RADIUS_COLOR, depthTest: false, transparent: true, opacity: 0.85 })))
|
|
206
|
-
knob.position.set(Math.cos(ang) * radius, 0, Math.sin(ang) * radius)
|
|
207
|
-
knob.userData.gizmoAxis = 'radius'
|
|
208
|
-
knob.renderOrder = 1000
|
|
209
|
-
g.add(knob)
|
|
210
|
-
}
|
|
211
|
-
return g
|
|
212
|
-
}
|
|
99
|
+
const _RADIUS_HIGHLIGHT = 0xffff00
|
|
213
100
|
function attachRadiusGizmo(mesh) {
|
|
214
101
|
if (radiusGizmoGroup) { scene.remove(radiusGizmoGroup); radiusGizmoGroup = null }
|
|
215
102
|
if (!_entityHasRadiusGizmo(mesh)) return
|
package/package.json
CHANGED
|
@@ -4,6 +4,7 @@ import { BIOME_PRESETS } from '../terrain/BiomeOverride.js'
|
|
|
4
4
|
import { createGrassDecal } from '../terrain/GrassDecal.js'
|
|
5
5
|
import { createEditOpLog } from './EditOpLog.js'
|
|
6
6
|
import { createAgentEditServer } from './AgentEditServer.js'
|
|
7
|
+
import { TEXT_EXTS, isTextFile, sanitizeFsError, WORLD_CONFIG_KEYS, serializeEntity, serializeWorld, serializeWorldSource } from './EditorHandlersSerialize.js'
|
|
7
8
|
|
|
8
9
|
const isNode = typeof process !== 'undefined' && process.versions?.node
|
|
9
10
|
let _fs = null, _path = null, _bakeMinimapIfMissing = null
|
|
@@ -46,25 +47,8 @@ const statSync = _fs?.statSync, mkdirSync = _fs?.mkdirSync
|
|
|
46
47
|
const realpathSync = _fs?.realpathSync
|
|
47
48
|
const unlinkSync = _fs?.unlinkSync, renameSync = _fs?.renameSync, rmSync = _fs?.rmSync
|
|
48
49
|
|
|
49
|
-
// Extensions read/written as text by the fs-browse panel; anything else is reported binary (size-only, no decode).
|
|
50
|
-
const TEXT_EXTS = new Set(['.js', '.mjs', '.json', '.md', '.txt', '.css', '.html', '.yml', '.yaml', '.svg'])
|
|
51
|
-
function isTextFile(name) {
|
|
52
|
-
const i = name.lastIndexOf('.')
|
|
53
|
-
return i >= 0 && TEXT_EXTS.has(name.slice(i).toLowerCase())
|
|
54
|
-
}
|
|
55
50
|
const resolvePath = _path?.resolve || (() => ''), joinPath = _path?.join || (() => ''), dirnamePath = _path?.dirname || (() => ''), pathSep = _path?.sep || '/'
|
|
56
51
|
|
|
57
|
-
// Node's raw fs error messages embed the server's ABSOLUTE filesystem path (e.g. "ENOENT: ...,
|
|
58
|
-
// mkdir 'C:\dev\spoint\apps\...'" or a null-byte TypeError quoting the full resolved path) -- sending
|
|
59
|
-
// that verbatim to an editor client leaks server directory layout to whatever authored the request.
|
|
60
|
-
// Found live via an adversarial VERIFY-phase sweep (null-byte path, overlong-path ENOENT) against the
|
|
61
|
-
// real server: both errors round-tripped with the absolute apps-root path intact. Strip it down to the
|
|
62
|
-
// operation-relevant leaf (Node error CODE + the client-relative path already known from payload.path)
|
|
63
|
-
// so the client still gets an actionable reason without the server's real directory structure.
|
|
64
|
-
function sanitizeFsError(e, clientRelativePath) {
|
|
65
|
-
const code = e && e.code ? e.code : (e && e.name) || 'ERROR'
|
|
66
|
-
return `${code}: operation failed on '${clientRelativePath}'`
|
|
67
|
-
}
|
|
68
52
|
|
|
69
53
|
// realpath-based containment check: a symlink inside rootDir could otherwise point outside it and let an editor client read/write arbitrary server files
|
|
70
54
|
function containedReal(filePath, rootDir) {
|
|
@@ -117,43 +101,6 @@ function containedRealCreateParent(filePath, rootDir) {
|
|
|
117
101
|
return joinPath(ancestorReal, ...missingSuffix, filePath.slice(dir.length + pathSep.length))
|
|
118
102
|
}
|
|
119
103
|
|
|
120
|
-
const WORLD_CONFIG_KEYS = ['port', 'tickRate', 'entityTickRate', 'gravity', 'relevanceRadius', 'physicsRadius', 'physicsBodyBudget', 'movement', 'player', 'scene', 'camera', 'animation', 'input', 'spawnPoint', 'spawnPoints', 'playerModel', 'trustedApps']
|
|
121
|
-
|
|
122
|
-
function serializeEntity(e) {
|
|
123
|
-
const out = { id: e.id }
|
|
124
|
-
if (e.model) out.model = e.model
|
|
125
|
-
out.position = [e.position[0], e.position[1], e.position[2]]
|
|
126
|
-
const r = e.rotation
|
|
127
|
-
if (r && !(r[0] === 0 && r[1] === 0 && r[2] === 0 && r[3] === 1)) out.rotation = [r[0], r[1], r[2], r[3]]
|
|
128
|
-
const s = e.scale
|
|
129
|
-
if (s && !(s[0] === 1 && s[1] === 1 && s[2] === 1)) out.scale = [s[0], s[1], s[2]]
|
|
130
|
-
if (e._appName) out.app = e._appName
|
|
131
|
-
if (e.bodyType && e.bodyType !== 'static') out.bodyType = e.bodyType
|
|
132
|
-
if (e._config) out.config = e._config
|
|
133
|
-
if (e.custom) out.custom = e.custom
|
|
134
|
-
if (e.parent) out.parent = e.parent
|
|
135
|
-
return out
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function serializeWorld(appRuntime, sourceWorldDef) {
|
|
139
|
-
const def = {}
|
|
140
|
-
const src = sourceWorldDef || {}
|
|
141
|
-
for (const k of WORLD_CONFIG_KEYS) if (src[k] !== undefined) def[k] = src[k]
|
|
142
|
-
const entities = []
|
|
143
|
-
for (const e of appRuntime.entities.values()) {
|
|
144
|
-
// Keep any entity carrying authored state: an app, a model, custom props (incl. a primitive's mesh/editorProp
|
|
145
|
-
// edits), a saved app config, OR a hierarchy parent (a reparented empty anchor). The old filter dropped an
|
|
146
|
-
// entity that had only a _config or only a parent, silently losing that authoring on save.
|
|
147
|
-
if (!e._appName && !e.model && !e.custom && !e._config && !e.parent) continue
|
|
148
|
-
entities.push(serializeEntity(e))
|
|
149
|
-
}
|
|
150
|
-
def.entities = entities
|
|
151
|
-
return def
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
function serializeWorldSource(def) {
|
|
155
|
-
return JSON.stringify(def, null, 2)
|
|
156
|
-
}
|
|
157
104
|
|
|
158
105
|
// In-memory prefab registry, best-effort mirrored to data/prefabs.json when a real filesystem is available
|
|
159
106
|
// (Node server), inert (memory-only, cleared on restart) under the Worker/singleplayer runtime -- mirrors
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Pure entity/world serialization + text-file-extension helpers for EditorHandlers.js's
|
|
2
|
+
// createEditorHandlers: no fs/path dependency, no closure state -- split out as the one genuinely
|
|
3
|
+
// self-contained block in this file (containedReal/containedRealCreateParent/prefab-persistence all
|
|
4
|
+
// depend on the Node-vs-Worker fs handle dance at this file's own top, and stay there).
|
|
5
|
+
|
|
6
|
+
const TEXT_EXTS = new Set(['.js', '.mjs', '.json', '.md', '.txt', '.css', '.html', '.yml', '.yaml', '.svg'])
|
|
7
|
+
function isTextFile(name) {
|
|
8
|
+
const i = name.lastIndexOf('.')
|
|
9
|
+
return i >= 0 && TEXT_EXTS.has(name.slice(i).toLowerCase())
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Node's raw fs error messages embed the server's ABSOLUTE filesystem path (e.g. "ENOENT: ...,
|
|
13
|
+
// mkdir 'C:\dev\spoint\apps\...'" or a null-byte TypeError quoting the full resolved path) -- sending
|
|
14
|
+
// that verbatim to an editor client leaks server directory layout to whatever authored the request.
|
|
15
|
+
// Found live via an adversarial VERIFY-phase sweep (null-byte path, overlong-path ENOENT) against the
|
|
16
|
+
// real server: both errors round-tripped with the absolute apps-root path intact. Strip it down to the
|
|
17
|
+
// operation-relevant leaf (Node error CODE + the client-relative path already known from payload.path)
|
|
18
|
+
// so the client still gets an actionable reason without the server's real directory structure.
|
|
19
|
+
function sanitizeFsError(e, clientRelativePath) {
|
|
20
|
+
const code = e && e.code ? e.code : (e && e.name) || 'ERROR'
|
|
21
|
+
return `${code}: operation failed on '${clientRelativePath}'`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const WORLD_CONFIG_KEYS = ['port', 'tickRate', 'entityTickRate', 'gravity', 'relevanceRadius', 'physicsRadius', 'physicsBodyBudget', 'movement', 'player', 'scene', 'camera', 'animation', 'input', 'spawnPoint', 'spawnPoints', 'playerModel', 'trustedApps']
|
|
25
|
+
|
|
26
|
+
function serializeEntity(e) {
|
|
27
|
+
const out = { id: e.id }
|
|
28
|
+
if (e.model) out.model = e.model
|
|
29
|
+
out.position = [e.position[0], e.position[1], e.position[2]]
|
|
30
|
+
const r = e.rotation
|
|
31
|
+
if (r && !(r[0] === 0 && r[1] === 0 && r[2] === 0 && r[3] === 1)) out.rotation = [r[0], r[1], r[2], r[3]]
|
|
32
|
+
const s = e.scale
|
|
33
|
+
if (s && !(s[0] === 1 && s[1] === 1 && s[2] === 1)) out.scale = [s[0], s[1], s[2]]
|
|
34
|
+
if (e._appName) out.app = e._appName
|
|
35
|
+
if (e.bodyType && e.bodyType !== 'static') out.bodyType = e.bodyType
|
|
36
|
+
if (e._config) out.config = e._config
|
|
37
|
+
if (e.custom) out.custom = e.custom
|
|
38
|
+
if (e.parent) out.parent = e.parent
|
|
39
|
+
return out
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function serializeWorld(appRuntime, sourceWorldDef) {
|
|
43
|
+
const def = {}
|
|
44
|
+
const src = sourceWorldDef || {}
|
|
45
|
+
for (const k of WORLD_CONFIG_KEYS) if (src[k] !== undefined) def[k] = src[k]
|
|
46
|
+
const entities = []
|
|
47
|
+
for (const e of appRuntime.entities.values()) {
|
|
48
|
+
// Keep any entity carrying authored state: an app, a model, custom props (incl. a primitive's mesh/editorProp
|
|
49
|
+
// edits), a saved app config, OR a hierarchy parent (a reparented empty anchor). The old filter dropped an
|
|
50
|
+
// entity that had only a _config or only a parent, silently losing that authoring on save.
|
|
51
|
+
if (!e._appName && !e.model && !e.custom && !e._config && !e.parent) continue
|
|
52
|
+
entities.push(serializeEntity(e))
|
|
53
|
+
}
|
|
54
|
+
def.entities = entities
|
|
55
|
+
return def
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function serializeWorldSource(def) {
|
|
59
|
+
return JSON.stringify(def, null, 2)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export { TEXT_EXTS, isTextFile, sanitizeFsError, WORLD_CONFIG_KEYS, serializeEntity, serializeWorld, serializeWorldSource }
|