matterbox / index.html
Ramesh-vani's picture
Update index.html
1ce3d25 verified
<div id="tool-box" class="overlay"></div>
<div id="inspector" class="overlay sidebar right scroll monospaced"></div>
<script>import * as polyDecomp from "https://cdn.skypack.dev/[email protected]";
const {
Bodies,
Body,
Bounds,
Common,
Composite,
Constraint,
Engine,
Events,
Mouse,
MouseConstraint,
Query,
Render,
Runner,
Vector,
Vertices,
} = Matter;
Common.setDecomp(polyDecomp);
const engine = Engine.create({
gravity: {
scale: 0.001,
x: 0,
y: 1,
},
});
const rect = document.documentElement.getBoundingClientRect();
const render = Render.create({
element: document.body,
engine: engine,
options: {
width: rect.height,
height: rect.height,
pixelRatio: 1,
hasBounds: true,
showConvexHulls: true,
showAxes: true,
},
});
const boxA = Bodies.rectangle(100, -50, 80, 80);
const boxB = Bodies.rectangle(400 * 0.60, -50, 80, 80);
const ground = Bodies.rectangle(0, 0, 1000, 50, { isStatic: true });
const mouse = Mouse.create(render.canvas);
const mouseConstraint = MouseConstraint.create(engine, {
mouse,
constraint: {
angularStiffness: 0.8,
render: {
visible: true,
},
},
collisionFilter: {
category: 0x0000,
mask: 0x00000000,
group: 0,
},
/*{ // Default
category: 0x0001,
mask: 0xFFFFFFFF,
group: 0,
},*/
});
render.mouse = mouse;
Composite.add(engine.world, [boxA, boxB, ground, mouseConstraint]);
const runner = Runner.create();
Runner.run(runner, engine);
Render.run(render);
// Setup
const KEYS_DOWN = new Set();
setInterval(() => console.log(KEYS_DOWN), 1000);
const SHAPE_FACTORIES = [
{ key: 'rectangle',
icon: 'square',
numPoints: 2,
factory: ([pointStart, pointEnd], isAlternateMode) => {
if (!pointStart || !pointEnd) return;
const min = Vector.create(Math.min(pointStart.position.x, pointEnd.position.x), Math.min(pointStart.position.y, pointEnd.position.y));
const max = Vector.create(Math.max(pointStart.position.x, pointEnd.position.x), Math.max(pointStart.position.y, pointEnd.position.y));
let width = max.x - min.x
let height = max.y - min.y
let position = Vector.div(Vector.add(pointStart.position, pointEnd.position), 2);
if (isAlternateMode) {
width *= 2;
height *= 2;
position = pointStart.position;
}
if (width > 0 && height > 0) {
return Bodies.rectangle(position.x, position.y, width, height, {});
}
return null;
},
},
{ key: 'circle',
icon: 'circle',
numPoints: 2,
factory: ([pointStart, pointEnd], isAlternateMode) => {
if (!pointStart || !pointEnd) return;
let radius = Vector.magnitude(Vector.sub(pointStart.position, pointEnd.position));
if (isAlternateMode) radius /= 2;
if (radius > 0) {
let position = pointStart.position;
if (isAlternateMode) position = Vector.div(Vector.add(pointStart.position, pointEnd.position), 2);
return Bodies.circle(position.x, position.y, radius, {}, 50);
}
return null;
},
},
{ key: 'capsule',
icon: 'capsules',
numPoints: 3,
factory: ([pointStart, pointEnd, pointTangent], isAlternateMode) => {
if (!pointStart || !pointEnd || !pointTangent) return;
// Note: There is no way any of this is efficient
const midpoint = Vector.div(Vector.add(pointStart.position, pointEnd.position), 2);
const delta = Vector.sub(pointEnd.position, pointStart.position);
const deltaMagSq = Vector.magnitudeSquared(delta);
const deltaMag = Math.sqrt(deltaMagSq);
const deltaDir = Vector.div(delta, deltaMag);
let radius = 0;
if (deltaMagSq <= (Number.EPSILON * Number.EPSILON)) {
radius = Vector.magnitude(Vector.sub(pointStart.position, pointTangent.position));
} else {
let proj, scale;
if (true) {
proj = Vector.mult(deltaDir, Vector.dot(Vector.sub(pointTangent.position, pointStart.position), deltaDir));
scale = Vector.magnitudeSquared(proj) / deltaMagSq * Math.sign(Vector.dot(deltaDir, Vector.normalise(Vector.sub(pointTangent.position, pointStart.position))));
}
if (scale < 0)
radius = Vector.magnitude(Vector.sub(pointStart.position, pointTangent.position));
else if (scale > 1)
radius = Vector.magnitude(Vector.sub(pointEnd.position, pointTangent.position));
else
radius = Math.abs((pointEnd.position.x - pointStart.position.x) * (pointStart.position.y - pointTangent.position.y) - (pointStart.position.x - pointTangent.position.x) * (pointEnd.position.y - pointStart.position.y)) / Vector.magnitude(Vector.sub(pointEnd.position, pointStart.position));
}
const width = !isAlternateMode ? deltaMag + radius * 2 : deltaMag;
const height = radius * 2;
if (width > 0 && height > 0 && radius > 0) {
const body = Bodies.rectangle(midpoint.x, midpoint.y, width, height, { chamfer: { radius } });
Body.setAngle(body, Vector.angle(pointStart.position, pointEnd.position));
return body;
}
return null;
},
},
{ key: 'polygon',
icon: 'draw-polygon',
numPoints: 0,
factory: (points, isAlternateMode) => {
if (points.length < 3) return;
const positions = points.map(point => point.position);
const body = Bodies.fromVertices(0, 0, positions, {}, true);
const positionBounds = Bounds.create(positions);
const offset = Vector.create(positionBounds.min.x - body.bounds.min.x, positionBounds.min.y - body.bounds.min.y);
Body.setPosition(body, Vector.add(body.position, offset));
console.log(offset);
return body;
},
},
{ key: 'clone',
icon: 'clone',
numPoints: 2,
doUnfreeze: false,
factory: ([original, placement]) => {
if (!original || !original.body || !placement) return;
const body = Object.assign(clone(original.body), {
id: Common.nextId(),
collisionFilter: { category: 0x0000,
mask: 0x00000000,
group: 0,
},
});
Body.setAngle(body, original.angle);
Body.setPosition(body, Vector.sub(placement.position, original.offset));
return body;
},
onFinalize: (body, [original, placement]) => {
body.collisionFilter = clone(original.body.collisionFilter);
Body.setStatic(body, original.body.isStatic);
return true; // Override default finalization
},
},
];
const SHAPE_TOOL_BUILDER = ({ key, icon, iconStyle, numPoints, doUnfreeze, factory, onFinalize }) => {
const createPoint = (position, bodies, ignoreBodies) => {
position = Vector.clone(position);
bodies = bodies || Composite.allBodies(engine.world);
ignoreBodies = !Array.isArray(ignoreBodies) ? [ignoreBodies] : ignoreBodies;
if (KEYS_DOWN.has('ControlLeft')) {
position.x = Math.floor(position.x / 10 + 0.5) * 10;
position.y = Math.floor(position.y / 10 + 0.5) * 10;
}
const queryResult = Query.point(bodies, position);
const body = _.first(ignoreBodies ? queryResult.filter(body => !ignoreBodies.includes(body)) : queryResult);
if (body && KEYS_DOWN.has('AltLeft')) {
position = Vector.clone(body.position);
}
return {
body,
position,
angle: body ? body.angle : 0,
offset: body? Vector.sub(position, body.position) : Vector.create(0, 0),
};
};
const updateBody = (state) => {
if (!state.isActive) return;
if (state.body) {
Composite.remove(engine.world, state.body);
state.body = null;
}
try {
state.body = factory(state.points, state.isAlternateMode);
if (state.body) {
Body.setStatic(state.body, true);
Composite.add(engine.world, [state.body]);
}
} catch (err) {
// TODO: Include points
console.error(`Unable to generate body from '${key}' factory: ${err.message}\n${err.stack || err}`);
}
};
const finalizeBody = (state) => {
if (!state.isActive) return;
state.points = state.points.slice(0, state.pointsCommitted);
updateBody(state);
if (state.body) {
if (!onFinalize || !onFinalize(state.body, state.points)) {
if (!KEYS_DOWN.has('ShiftLeft')) {
Body.setStatic(state.body, false);
} else {
Body.setStatic(state.body, true);
}
}
}
};
return {
key,
innerHTML: `
<i class="fa-solid fa-${icon} fa-xl" style="${iconStyle || ''}"></i>
<i class="fa-solid fa-plus"></i>
`,
onEvent: {
mousedown: (event, state) => {
const bodies = Composite.allBodies(engine.world);
if (!state.isActive) {
state.isActive = true;
state.isAlternateMode = event.mouse.button === 2;
state.points = [createPoint(event.mouse.position, bodies, state.body)];
state.pointsCommitted = 1;
state.body = null;
} else if (event.mouse.button === 0) {
state.points[state.pointsCommitted] = createPoint(event.mouse.position, bodies, state.body);
state.pointsCommitted++;
}
updateBody(state);
if ((numPoints > 0 && state.points.length >= numPoints) || (numPoints <= 0 && event.mouse.button === 2)) {
finalizeBody(state);
state.isActive = false;
state.body = null;
}
},
mousemove: (event, state) => {
if (!state.isActive) return;
state.points[state.pointsCommitted] = createPoint(event.mouse.position, null, state.body);
updateBody(state);
},
},
state: {
isActive: false,
points: null,
pointsCommitted: 0,
body: null,
},
};
};
const CONSTRAINT_FACTORIES = [
{ key: 'pin',
icon: 'thumbtack',
numPoints: 1,
factory: ([point]) => {
if (!point || !point.body) return;
return Constraint.create({
bodyB: point.body,
pointA: point.position,
pointB: point.offset,
length: 0,
});
},
},
...[1.00, 0.10, 0.01, 0.001].map(stiffness => ({
key: `connect-${stiffness.toPrecision(2)}`,
icon: stiffness !== 1.00 ? 'share-nodes' : 'circle-nodes',
iconStyle: stiffness !== 1.00 ? `color:rgba(${150 + 100 * (stiffness / 0.15)}, 255, ${150 + 100 * (1-(stiffness / 0.15))}, 1);` : '',
numPoints: 2,
factory: ([pointStart, pointEnd]) => {
if (!pointStart || !pointEnd) return;
if (!pointStart.body && !pointEnd.body) return;
return Constraint.create({
bodyA: pointStart.body,
bodyB: pointEnd.body,
pointA: pointStart.body ? pointStart.offset : pointStart.position,
pointB: pointEnd.body ? pointEnd.offset : pointEnd.position,
stiffness,
});
},
})),
{ key: 'union',
icon: 'object-group',
numPoints: 0,
factory: (points) => {
if (!points || points.length < 2) return;
const bodies = points.map(point => point.body).filter(Boolean);
for (let body of bodies) {
// Note: Deep search? Would be slow, right?
Composite.remove(engine.world, body);
}
// TODO: Clone only specific body properties needed
const parts = bodies.map(body => body.parts.length > 1 ? body.parts.filter(p => p !== body) : body.parts).flat();
const clones = parts.map(part => Object.assign(clone(part), { id: Common.nextId() }));
return Body.create({
parts: clones,
isStatic: clones.some(body => body.isStatic),
});
},
},
];
const CONSTRAINT_TOOL_BUILDER = ({ key, icon, iconStyle, numPoints, factory }) => {
const createPoint = (position, bodies, ignoreBodies) => {
position = Vector.clone(position);
bodies = bodies || Composite.allBodies(engine.world);
ignoreBodies = !Array.isArray(ignoreBodies) ? [ignoreBodies] : ignoreBodies;
if (KEYS_DOWN.has('ControlLeft')) {
position.x = Math.floor(position.x / 10 + 0.5) * 10;
position.y = Math.floor(position.y / 10 + 0.5) * 10;
}
const queryResult = Query.point(bodies, position);
const body = _.first(ignoreBodies ? queryResult.filter(body => !ignoreBodies.includes(body)) : queryResult);
if (body && KEYS_DOWN.has('AltLeft')) {
position = Vector.clone(body.position);
}
return {
body,
position,
angle: body ? body.angle : 0,
offset: body? Vector.sub(position, body.position) : Vector.create(0, 0),
};
};
const finalizeConstraint = (state) => {
if (!state.isActive) return;
try {
const result = factory(state.points);
if (result) {
Composite.add(engine.world, [result]);
}
} catch (err) {
// TODO: Include points
console.error(`Unable to generate constraint from '${key}' factory: ${err.message}\n${err.stack || err}`);
}
};
return {
key,
innerHTML: `
<i class="fa-solid fa-${icon} fa-xl" style="${iconStyle || ''}"></i>
<i class="fa-solid fa-link"></i>
`,
onEvent: {
mousedown: (event, state) => {
const bodies = Composite.allBodies(engine.world);
if (!state.isActive) {
state.isActive = true;
state.points = [createPoint(event.mouse.position, bodies)];
state.pointsCommitted = 1;
} else {
state.points[state.pointsCommitted] = createPoint(event.mouse.position, bodies);
state.pointsCommitted++;
}
if ((numPoints > 0 && state.points.length >= numPoints) || (numPoints <= 0 && event.mouse.button === 2)) {
finalizeConstraint(state);
state.isActive = false;
}
},
},
state: {
isActive: false,
points: null,
pointsCommitted: 0,
},
};
};
let INSPECTED_OBJECT = null;
let INSPECTED_OBJECT_PROPERTIES = null;
let INSPECTED_OBJECT_LAST_UPDATE = 0;
let ACTIVE_TOOL = null;
const TOOL_BOX = [
{ key: 'general',
tools: [
{ key: 'pointer',
innerHTML: `
<i class="fa-solid fa-arrow-pointer"></i>
`,
onDeactivate: (state) => {
if (state.isDown) {
state.isDown = false;
state.isExpired = true;
}
if (state.timeout) {
clearTimeout(state.timeout);
state.timeout = null;
}
if (state.contextMenu) {
state.contextMenu.hide();
state.contextMenu = null;
}
},
onEvent: {
mousedown: (event, state) => {
state.isDown = true;
state.isExpired = false;
state.position = Vector.clone(event.mouse.position);
state.bodies = Query.point(Composite.allBodies(engine.world), state.position);
if (state.timeout) {
clearTimeout(state.timeout);
state.timeout = null;
}
if (state.contextMenu) {
state.contextMenu.hide();
state.contextMenu = null;
}
state.timeout = setTimeout(() => {
state.isExpired = true;
const menuItems = state.bodies.map(body => ({
text: body.label,
hotkey: body.id,
onclick: () => inspectObject(body),
}));
const contextMenu = state.contextMenu = new ContextMenu(document.body, menuItems);
contextMenu.show(event.mouse.absolute.x, event.mouse.absolute.y);
}, 1500);
},
mouseup: (event, state) => {
if (!state.isDown || state.isExpired) return;
state.isDown = false;
if (state.timeout) {
clearTimeout(state.timeout);
state.timeout = null;
}
inspectObject(_.first(state.bodies));
},
},
state: {
isDown: false,
isExpired: false,
timeout: null,
contextMenu: null,
position: null,
bodies: null,
},
},
{ key: 'move',
innerHTML: `
<i class="fa-solid fa-arrows-up-down-left-right"></i>
`,
onEvent: {
mousedown: (event, state) => {
if (state.isDown) return;
state.isDown = true;
state.position = Vector.clone(event.mouse.position);
state.body = _.first(Query.point(Composite.allBodies(engine.world), state.position));
if (state.body) {
state.wasStatic = state.body.isStatic;
Body.setStatic(state.body, true);
state.localPosition = Vector.sub(state.body.position, state.position);
}
},
mousemove: (event, state) => {
if (!state.isDown) return;
if (state.body) {
Body.setPosition(state.body, Vector.add(event.mouse.position, state.localPosition));
}
},
mouseup: (event, state) => {
if (!state.isDown) return;
if (state.body) {
Body.setStatic(state.body, state.wasStatic);
}
state.isDown = false;
state.body = null;
state.wasStatic = false;
state.position = null;
state.localPosition = null;
},
},
state: { isDown: false, body: null, wasStatic: false, position: null, localPosition: null },
},
{ key: 'grab',
innerHTML: `
<i class="fa-solid fa-hand"></i>
`,
onActivate: () => {
mouseConstraint.collisionFilter = { // Default
category: 0x0001,
mask: 0xFFFFFFFF,
group: 0,
};
},
onDeactivate: () => {
mouseConstraint.collisionFilter = { // Empty
category: 0x0000,
mask: 0x00000000,
group: 0,
};
},
},
],
},
{ key: 'destructive',
tools: [
{ key: 'remove',
innerHTML: `
<i class="fa-solid fa-delete-left"></i>
`,
onEvent: {
mousedown: (event, state) => {
if (state.isDown) return;
state.isDown = true;
state.bodies = Query.point(Composite.allBodies(engine.world), event.mouse.position);
},
mouseup: (event, state) => {
if (!state.isDown) return;
const confirm = Query.point(Composite.allBodies(engine.world), event.mouse.position);
const body = _.first(state.bodies, _.intersection(state.bodies, confirm));
if (body) {
Composite.remove(engine.world, body);
}
state.isDown = false;
state.bodies = null;
},
},
state: { isDown: false, bodies: null },
},
],
},
{ key: 'create',
tools: SHAPE_FACTORIES.map(shapeFactory => SHAPE_TOOL_BUILDER(shapeFactory)),
},
{ key: 'constrain',
tools: CONSTRAINT_FACTORIES.map(constraintFactory => CONSTRAINT_TOOL_BUILDER(constraintFactory)),
},
];
// Functions
function updateCanvasDimensions() {
const rect = document.documentElement.getBoundingClientRect();
const size = Vector.create(rect.width, rect.height);
render.options.width = size.x;
render.options.height = size.y;
render.canvas.width = size.x;
render.canvas.height = size.y;
render.bounds.min.x = size.x * -0.5;
render.bounds.min.y = size.y * -0.90;
render.bounds.max.x = size.x * 0.5;
render.bounds.max.y = size.y * 0.10;
}
function buildToolbox() {
const elToolBox = document.getElementById('tool-box');
let firstTool = null;
for (let category of TOOL_BOX) {
const elCategory = category.element = elToolBox.appendChild(document.createElement('div'));
elCategory.id = `tool-category-${category.key}`;
elCategory.classList.add('container')
for (let tool of category.tools) {
if (!firstTool) firstTool = tool;
tool.category = category;
tool.state = tool.state || {};
tool._onEventCallbacks = {};
const elTool = tool.element = elCategory.appendChild(document.createElement('div'));
elTool.id = `tool-tool-${category.key}-${tool.key}`;
elTool.classList.add('tool');
elTool.innerHTML = tool.innerHTML;
elTool.addEventListener('click', event => {
activateTool(tool);
});
}
if (firstTool) {
activateTool(firstTool);
}
}
}
function activateTool(tool) {
if (tool === ACTIVE_TOOL) return;
if (ACTIVE_TOOL !== null) {
if (ACTIVE_TOOL.onEvent) {
for (let [eventName, eventHandler] of Object.entries(ACTIVE_TOOL.onEvent)) {
const callback = ACTIVE_TOOL._onEventCallbacks[eventName];
if (callback) {
Events.off(mouseConstraint, eventName, callback);
delete ACTIVE_TOOL._onEventCallbacks[eventName];
}
}
}
if (ACTIVE_TOOL.onDeactivate) {
try {
ACTIVE_TOOL.onDeactivate(ACTIVE_TOOL.state);
} catch (err) {
const message = `Error attempting to deactivate tool '${ACTIVE_TOOL.category.key}:${ACTIVE_TOOL.key}': ${err.stack || err}`;
console.error(message);
alert(message);
}
}
if (ACTIVE_TOOL.element) ACTIVE_TOOL.element.classList.remove('active');
ACTIVE_TOOL = null;
}
if (tool !== null) {
ACTIVE_TOOL = tool;
if (ACTIVE_TOOL.onEvent) {
for (let [eventName, eventHandler] of Object.entries(ACTIVE_TOOL.onEvent)) {
const _tool = ACTIVE_TOOL;
const callback = _tool._onEventCallbacks[eventName] = (event) => eventHandler(event, _tool.state);
Events.on(mouseConstraint, eventName, callback);
}
}
if (ACTIVE_TOOL.onActivate) {
try {
ACTIVE_TOOL.onActivate(ACTIVE_TOOL.state);
} catch (err) {
const message = `Error attempting to activate tool '${ACTIVE_TOOL.category.key}:${ACTIVE_TOOL.key}': ${err.stack || err}`;
console.error(message);
alert(message);
}
}
if (ACTIVE_TOOL.element) ACTIVE_TOOL.element.classList.add('active');
}
}
function inspectObject(object) {
INSPECTED_OBJECT = object;
INSPECTED_OBJECT_PROPERTIES = object ? {} : null;
const elInspector = document.getElementById('inspector');
if (!object) return elInspector.replaceChildren();
const elTable = elInspector.appendChild(document.createElement('table'));
elTable.appendChild(document.createElement('thead')).innerHTML = `
<tr>
<th>Key</th>
<th>Value</th>
</tr>
`;
const elTBody = elTable.appendChild(document.createElement('tbody'));
for (let [key, value] of Object.entries(object)) {
const elRow = elTBody.appendChild(document.createElement('tr'));
elRow.setAttribute('data-key', key);
elRow.classList.add('property');
const elKey = elRow.appendChild(document.createElement('td'));
elKey.classList.add('key');
elKey.append(_.toString(key));
const elValue = elRow.appendChild(document.createElement('td'));
elValue.classList.add('value');
elValue.append(_.toString(value));
INSPECTED_OBJECT_PROPERTIES[key] = { key, value, elements: { row: elRow, key: elKey, value: elValue } };
}
elInspector.replaceChildren(elTable);
}
function updateInspectedObject() {
if (!INSPECTED_OBJECT) return;
for (let [key, property] of Object.entries(INSPECTED_OBJECT_PROPERTIES)) {
const value = property.value = INSPECTED_OBJECT[key];
const elValue = property.elements.value;
elValue.replaceChildren();
elValue.append(_.toString(value));
}
}
Events.on(engine, 'afterUpdate', () => {
const now = Date.now();
if (now - INSPECTED_OBJECT_LAST_UPDATE < 100) return;
INSPECTED_OBJECT_LAST_UPDATE = now;
updateInspectedObject();
});
Events.on(render, 'beforeRender', () => {
});
// Init
document.addEventListener('DOMContentLoaded', () => {
window.addEventListener('resize', updateCanvasDimensions);
document.addEventListener('keydown', event => {
KEYS_DOWN.add(event.code);
if (event.key === 'Alt') event.preventDefault();
});
document.addEventListener('keyup', event => {
KEYS_DOWN.delete(event.code);
if (event.key === 'Alt') event.preventDefault();
});
updateCanvasDimensions();
buildToolbox();
});
</script>