tinysim 0.0.1__py3-none-any.whl

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,430 @@
1
+ const keys = { forward: false, backward: false, left: false, right: false };
2
+
3
+ class ray {
4
+ constructor(start, end) {
5
+ this.start = start;
6
+ this.end = end;
7
+ }
8
+
9
+ yValueAt(x) {
10
+ return this.offsetY + this.slope * x;
11
+ }
12
+ xValueAt(y) {
13
+ return (y - this.offsetY) / this.slope;
14
+ }
15
+
16
+ pointInBounds(point) {
17
+ var minX = Math.min(this.start.x, this.end.x);
18
+ var maxX = Math.max(this.start.x, this.end.x);
19
+ var minY = Math.min(this.start.y, this.end.y);
20
+ var maxY = Math.max(this.start.y, this.end.y);
21
+ return (
22
+ point.x >= minX && point.x <= maxX && point.y >= minY && point.y <= maxY
23
+ );
24
+ }
25
+
26
+ get slope() {
27
+ var dif = this.end.minus(this.start);
28
+ return dif.y / dif.x;
29
+ }
30
+ get offsetY() {
31
+ return this.start.y - this.slope * this.start.x;
32
+ }
33
+ get isHorizontal() {
34
+ return compareNum(this.start.y, this.end.y);
35
+ }
36
+ get isVertical() {
37
+ return compareNum(this.start.x, this.end.x);
38
+ }
39
+
40
+ static intersect(rayA, rayB) {
41
+ if (rayA.isVertical && rayB.isVertical) return null;
42
+ if (rayA.isVertical)
43
+ return new vec2(rayA.start.x, rayB.yValueAt(rayA.start.x));
44
+ if (rayB.isVertical)
45
+ return new vec2(rayB.start.x, rayA.yValueAt(rayB.start.x));
46
+ if (compareNum(rayA.slope, rayB.slope)) return null;
47
+ if (rayA.isHorizontal)
48
+ return new vec2(rayB.xValueAt(rayA.start.y), rayA.start.y);
49
+ if (rayB.isHorizontal)
50
+ return new vec2(rayA.xValueAt(rayB.start.y), rayB.start.y);
51
+ var x = (rayB.offsetY - rayA.offsetY) / (rayA.slope - rayB.slope);
52
+ return new vec2(x, rayA.yValueAt(x));
53
+ }
54
+ static collisionPoint(rayA, rayB) {
55
+ var intersection = ray.intersect(rayA, rayB);
56
+ if (!intersection) return null;
57
+ if (!rayA.pointInBounds(intersection)) return null;
58
+ if (!rayB.pointInBounds(intersection)) return null;
59
+ return intersection;
60
+ }
61
+ static bodyEdges(body) {
62
+ var r = [];
63
+ for (var i = body.parts.length - 1; i >= 0; i--) {
64
+ for (var k = body.parts[i].vertices.length - 1; k >= 0; k--) {
65
+ var k2 = k + 1;
66
+ if (k2 >= body.parts[i].vertices.length) k2 = 0;
67
+ var tray = new ray(
68
+ vec2.fromOther(body.parts[i].vertices[k]),
69
+ vec2.fromOther(body.parts[i].vertices[k2])
70
+ );
71
+ tray.verts = [body.parts[i].vertices[k], body.parts[i].vertices[k2]];
72
+
73
+ r.push(tray);
74
+ }
75
+ }
76
+ return r;
77
+ }
78
+ static bodyCollisions(rayA, body) {
79
+ const r = [];
80
+ const edges = ray.bodyEdges(body);
81
+ for (let i = edges.length - 1; i >= 0; i--) {
82
+ const colpoint = ray.collisionPoint(rayA, edges[i]);
83
+ if (!colpoint) continue;
84
+ r.push({ body: body, point: colpoint });
85
+ }
86
+
87
+ return r;
88
+ }
89
+ }
90
+
91
+ function compareNum(a, b, eps = 0.00001) {
92
+ return Math.abs(b - a) <= eps;
93
+ }
94
+
95
+ class vec2 {
96
+ constructor(x = 0, y = x) {
97
+ this.x = x;
98
+ this.y = y;
99
+ }
100
+ normalized(magnitude = 1) {
101
+ return this.multiply(magnitude / this.distance());
102
+ }
103
+ multiply(factor) {
104
+ return new vec2(this.x * factor, this.y * factor);
105
+ }
106
+ plus(vec) {
107
+ return new vec2(this.x + vec.x, this.y + vec.y);
108
+ }
109
+ minus(vec) {
110
+ return this.plus(vec.multiply(-1));
111
+ }
112
+ rotate(rot) {
113
+ var ang = this.direction;
114
+ var mag = this.distance();
115
+ ang += rot;
116
+ return vec2.fromAng(ang, mag);
117
+ }
118
+ get direction() {
119
+ return Math.atan2(this.y, this.x);
120
+ }
121
+ distance(vec = new vec2()) {
122
+ var d = Math.sqrt(
123
+ Math.pow(this.x - vec.x, 2) + Math.pow(this.y - vec.y, 2)
124
+ );
125
+ return d;
126
+ }
127
+ static fromAng(angle, magnitude = 1) {
128
+ return new vec2(Math.cos(angle) * magnitude, Math.sin(angle) * magnitude);
129
+ }
130
+ static fromOther(vector) {
131
+ return new vec2(vector.x, vector.y);
132
+ }
133
+ }
134
+
135
+ const sensorData = {};
136
+
137
+ export default {
138
+ initialize({ model }) {
139
+ model.on("change:controls", () => {
140
+ const controls = model.get("controls");
141
+
142
+ if (controls.data) {
143
+ for (const [key, value] of Object.entries(controls.data)) {
144
+ keys[key] = value;
145
+ }
146
+ }
147
+
148
+ model.set("sensorData", {
149
+ angles: sensorData.angles || [],
150
+ hitPoints: sensorData.hitPoints || [],
151
+ labels: sensorData.labels || [],
152
+ scanId: controls.num_scans || 0,
153
+ });
154
+ model.save_changes();
155
+ });
156
+ },
157
+
158
+ async render({ model, el }) {
159
+ const { default: Matter } = await import(
160
+ "https://cdn.jsdelivr.net/npm/matter-js/+esm"
161
+ );
162
+ const {
163
+ Engine,
164
+ Render,
165
+ Runner,
166
+ World,
167
+ Bodies,
168
+ Body,
169
+ Events,
170
+ Query,
171
+ Vector,
172
+ Composite,
173
+ } = Matter;
174
+
175
+ const dimensions = model.get("_viewport_size") || [800, 600];
176
+ const width = dimensions[0];
177
+ const height = dimensions[1];
178
+ const engine = Engine.create();
179
+ engine.gravity.y = 0;
180
+
181
+ const container = document.createElement("div");
182
+ container.className = "sim-container";
183
+ el.appendChild(container);
184
+
185
+ const renderArea = document.createElement("div");
186
+ renderArea.className = "render-area";
187
+ container.appendChild(renderArea);
188
+
189
+ const renderInstance = Render.create({
190
+ element: renderArea,
191
+ engine,
192
+ options: {
193
+ width,
194
+ height,
195
+ wireframes: false,
196
+ background: "#0b1220",
197
+ },
198
+ });
199
+
200
+ Render.run(renderInstance);
201
+ const runner = Runner.create();
202
+ Runner.run(runner, engine);
203
+
204
+ const mapData = model.get("mapData") || {
205
+ map: [],
206
+ robot: { pos: [200, 200], angle: 0, speed: 0.01, turn_speed: 0.03 },
207
+ };
208
+
209
+
210
+ model.on("change:_reset_state", () => {
211
+ if (model.get("_reset_state")) {
212
+ World.clear(engine.world);
213
+ Engine.clear(engine);
214
+ buildWorld();
215
+ model.set("_reset_state", false);
216
+ }
217
+ });
218
+
219
+ let robot;
220
+ let headingIndicator;
221
+ const robotWidth = 54;
222
+ const robotHeight = 44;
223
+
224
+ function buildWorld() {
225
+ const bodies = [];
226
+ mapData.map.forEach((item) => {
227
+ if (item.type === "rectangle") {
228
+ item.bodyInfo.isStatic = item.bodyInfo.isStatic ?? true;
229
+ const rect = Bodies.rectangle(
230
+ item.x,
231
+ item.y,
232
+ item.width,
233
+ item.height,
234
+ item.bodyInfo
235
+ );
236
+ bodies.push(rect);
237
+ }
238
+ });
239
+ World.add(engine.world, bodies);
240
+
241
+ robot = Bodies.rectangle(
242
+ mapData.robot.pos[0],
243
+ mapData.robot.pos[1],
244
+ robotWidth,
245
+ robotHeight,
246
+ {
247
+ frictionAir: 0.12,
248
+ friction: 0,
249
+ restitution: 0.1,
250
+ inertia: Infinity,
251
+ angle: mapData.robot.angle,
252
+ render: { fillStyle: "#f0c808" },
253
+ }
254
+ );
255
+
256
+ // Add heading
257
+ headingIndicator = Bodies.polygon(
258
+ robot.position.x + 30,
259
+ robot.position.y,
260
+ 3,
261
+ 16,
262
+ {
263
+ isSensor: true,
264
+ render: {
265
+ fillStyle: "rgba(209,73,91,0.75)",
266
+ strokeStyle: "rgba(209,73,91,0.9)",
267
+ lineWidth: 1,
268
+ },
269
+ }
270
+ );
271
+ headingIndicator.isStatic = true;
272
+ World.add(engine.world, [robot, headingIndicator]);
273
+ }
274
+
275
+ buildWorld();
276
+
277
+ Events.on(engine, "beforeUpdate", () => {
278
+ Body.setPosition(headingIndicator, {
279
+ x: robot.position.x + Math.cos(robot.angle) * (robotWidth / 2 + 6),
280
+ y: robot.position.y + Math.sin(robot.angle) * (robotWidth / 2 + 6),
281
+ });
282
+ Body.setAngle(headingIndicator, robot.angle);
283
+ });
284
+
285
+ if (model.get("show_controls")) {
286
+ const controls = document.createElement("div");
287
+ controls.className = "controls";
288
+ controls.innerHTML = `
289
+ <button id="forward">↑</button>
290
+ <button id="left">←</button>
291
+ <button id="right">→</button>
292
+ <button id="backward">↓</button>
293
+ `;
294
+ container.appendChild(controls);
295
+
296
+ controls.addEventListener("mousedown", (event) => {
297
+ const dir = event.target.id;
298
+ if (dir) keys[dir] = true;
299
+ });
300
+ controls.addEventListener("mouseup", (event) => {
301
+ const dir = event.target.id;
302
+ if (dir) keys[dir] = false;
303
+ });
304
+ }
305
+
306
+ function applyControls() {
307
+ const thrust = mapData.robot.speed || 0.01;
308
+ const turn = mapData.robot.turn_speed || 0.04;
309
+
310
+ if (keys.forward) {
311
+ Body.applyForce(robot, robot.position, {
312
+ x: Math.cos(robot.angle) * thrust,
313
+ y: Math.sin(robot.angle) * thrust,
314
+ });
315
+ }
316
+ if (keys.backward) {
317
+ Body.applyForce(robot, robot.position, {
318
+ x: -Math.cos(robot.angle) * thrust * 0.6,
319
+ y: -Math.sin(robot.angle) * thrust * 0.6,
320
+ });
321
+ }
322
+
323
+ if (keys.left && !keys.right) {
324
+ Body.setAngularVelocity(robot, -turn);
325
+ } else if (keys.right && !keys.left) {
326
+ Body.setAngularVelocity(robot, turn);
327
+ } else {
328
+ Body.setAngularVelocity(robot, robot.angularVelocity * 0.9);
329
+ }
330
+
331
+ const maxSpeed = 8;
332
+ const v = robot.velocity;
333
+ const speed = Math.hypot(v.x, v.y);
334
+ if (speed > maxSpeed) {
335
+ Body.setVelocity(robot, {
336
+ x: (v.x * maxSpeed) / speed,
337
+ y: (v.y * maxSpeed) / speed,
338
+ });
339
+ }
340
+ }
341
+
342
+ function lidarScan(engine, origin, yaw, numBeams, fov, maxRange) {
343
+ const allBodies = Composite.allBodies(engine.world);
344
+ const bodies = allBodies.filter(
345
+ (b) => b !== robot && b !== headingIndicator
346
+ );
347
+
348
+ const angles = [];
349
+ const hitPoints = [];
350
+ const hitBodies = [];
351
+
352
+ for (let i = 0; i < numBeams; i++) {
353
+ const angle = yaw - fov / 2 + (fov * i) / (numBeams - 1);
354
+ const dir = { x: Math.cos(angle), y: Math.sin(angle) };
355
+
356
+ let start = Vector.clone(origin);
357
+ let end = {
358
+ x: origin.x + dir.x * maxRange,
359
+ y: origin.y + dir.y * maxRange,
360
+ };
361
+
362
+ start = vec2.fromOther(start);
363
+ end = vec2.fromOther(end);
364
+ var query = Query.ray(bodies, start, end);
365
+ var cols = [];
366
+
367
+ var raytest = new ray(start, end);
368
+ for (let i = query.length - 1; i >= 0; i--) {
369
+ var bcols = ray.bodyCollisions(raytest, query[i].body);
370
+ for (let k = bcols.length - 1; k >= 0; k--) {
371
+ cols.push(bcols[k]);
372
+ }
373
+ }
374
+
375
+ cols.sort(function (a, b) {
376
+ return a.point.distance(start) - b.point.distance(start);
377
+ });
378
+
379
+ if (cols.length > 0) {
380
+ hitPoints.push(cols[0].point);
381
+ hitBodies.push(cols[0].body);
382
+ angles.push(angle);
383
+ }
384
+ }
385
+
386
+ return { angles, hitPoints, hitBodies };
387
+ }
388
+
389
+ const {
390
+ numBeams = 30,
391
+ fov = 2 * Math.PI,
392
+ maxRange = 1000,
393
+ } = mapData.robot.lidar ?? {};
394
+
395
+ Events.on(engine, "afterUpdate", () => {
396
+ applyControls();
397
+
398
+ const origin = robot.position;
399
+ const yaw = robot.angle;
400
+ const scan = lidarScan(engine, origin, yaw, numBeams, fov, maxRange);
401
+
402
+ sensorData.angles = scan.angles;
403
+ sensorData.hitPoints = scan.hitPoints;
404
+ sensorData.labels = scan.hitBodies.map((body) => body.label);
405
+ sensorData.scanTime = Date.now();
406
+
407
+ if (model.get("debugDraw")) {
408
+ const ctx = renderInstance.context;
409
+ ctx.save();
410
+ ctx.strokeStyle = "rgba(255, 0, 0, 1)";
411
+ ctx.lineWidth = 1;
412
+
413
+ for (let i = 0; i < scan.hitPoints.length; i++) {
414
+ const p = scan.hitPoints[i];
415
+ ctx.beginPath();
416
+ ctx.moveTo(origin.x, origin.y);
417
+ ctx.lineTo(p.x, p.y);
418
+ ctx.stroke();
419
+ }
420
+
421
+ ctx.restore();
422
+ }
423
+ });
424
+
425
+ Render.lookAt(renderInstance, {
426
+ min: { x: 0, y: 0 },
427
+ max: { x: width, y: height },
428
+ });
429
+ },
430
+ };
@@ -0,0 +1,54 @@
1
+ .sim-container {
2
+ display: inline-flex;
3
+ align-items: flex-start;
4
+ gap: 20px;
5
+ margin: 20px auto;
6
+ width: fit-content;
7
+ }
8
+
9
+ .render-area canvas {
10
+ display: block;
11
+ border-radius: 8px;
12
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
13
+ }
14
+
15
+ .controls {
16
+ display: grid;
17
+ grid-template-areas:
18
+ ". forward ."
19
+ "left . right"
20
+ ". backward .";
21
+ gap: 10px;
22
+ background: rgba(30, 30, 30, 0.8);
23
+ padding: 20px 24px;
24
+ border-radius: 14px;
25
+ box-shadow: 0 6px 14px rgba(0, 0, 0, 0.35);
26
+ font-family: "Segoe UI", Roboto, sans-serif;
27
+ width: max-content;
28
+ height: max-content;
29
+ }
30
+
31
+ .controls button {
32
+ background: linear-gradient(180deg, #4e9af1, #1d6de6);
33
+ color: white;
34
+ border: none;
35
+ border-radius: 8px;
36
+ padding: 12px 18px;
37
+ font-size: 16px;
38
+ font-weight: 600;
39
+ cursor: pointer;
40
+ transition: all 0.2s ease;
41
+ }
42
+
43
+ .controls button:hover {
44
+ background: linear-gradient(180deg, #66b2ff, #3380ff);
45
+ }
46
+
47
+ .controls button:active {
48
+ transform: scale(0.94);
49
+ }
50
+
51
+ #forward { grid-area: forward; }
52
+ #backward { grid-area: backward; }
53
+ #left { grid-area: left; }
54
+ #right { grid-area: right; }
@@ -0,0 +1,73 @@
1
+ import pathlib
2
+ import anywidget
3
+ import traitlets
4
+ import asyncio
5
+ from IPython.display import display
6
+ from jupyter_ui_poll import ui_events
7
+
8
+
9
+ class RobotSim(anywidget.AnyWidget):
10
+ _esm = pathlib.Path(__file__).parent / "sim.js"
11
+ _css = pathlib.Path(__file__).parent / "styles.css"
12
+
13
+ mapData = traitlets.Dict().tag(sync=True)
14
+ show_controls = traitlets.Bool(default_value=False).tag(sync=True)
15
+ controls = traitlets.Dict().tag(sync=True)
16
+ sensorData = traitlets.Dict().tag(sync=True)
17
+ debugDraw = traitlets.Bool(default_value=False).tag(sync=True)
18
+ _reset_state = traitlets.Bool(default_value=False).tag(sync=True)
19
+ _viewport_size = traitlets.Tuple(
20
+ traitlets.Int(), traitlets.Int(), default_value=(800, 600)
21
+ ).tag(sync=True)
22
+
23
+ def __init__(
24
+ self, env_map, show_controls=False, debugDraw=False, viewport_size=(800, 600)
25
+ ):
26
+ """Initialize the robot simulation."""
27
+ super().__init__()
28
+ self.env_map = env_map
29
+ self.robot_data = self.env_map.get("robot", {"pos": [200, 200], "angle": 0})
30
+ self.mapData = {"map": self.env_map.get("map", []), "robot": self.robot_data}
31
+ self.show_controls = show_controls
32
+ self.debugDraw = debugDraw
33
+ self._viewport_size = viewport_size
34
+
35
+ self.num_scans = 0
36
+
37
+ def render(self):
38
+ display(self)
39
+ import time
40
+
41
+ time.sleep(1) # Allow time for the widget to initialize (TODO: better way?)
42
+
43
+ def move(self, **kwargs):
44
+ """Control robot from Python."""
45
+ data = dict(kwargs)
46
+ self.controls = {"data": data, "_sync": id(data), "num_scans": self.num_scans}
47
+
48
+ async def step(self, dt, **kwargs):
49
+ """Step the simulation forward in time."""
50
+ stop = {"forward": False, "backward": False, "left": False, "right": False}
51
+
52
+ self.move(**kwargs)
53
+ await asyncio.sleep(dt)
54
+
55
+ self.num_scans += 1
56
+ self.move(**stop)
57
+
58
+ try:
59
+ # https://github.com/Kirill888/jupyter-ui-poll/issues/23
60
+ # polling simulation state updates from the widget may not be supported by all jupyter kernels
61
+ async with ui_events() as ui_poll:
62
+ while self.sensorData.get("scanId", 0) < self.num_scans:
63
+ # print(self.sensorData)
64
+ await ui_poll(100)
65
+ except Exception:
66
+ # TODO: should raise... sensorData will be stale
67
+ pass
68
+
69
+ return self.sensorData
70
+
71
+ def reset(self):
72
+ """Reset the simulation state."""
73
+ self._reset_state = True