I'm trying to create a 3D experience using react-three-fiber where the user can interact with any part of the 3D canvas, including points on the canvas that both (a) intersect an object, and (b) where there is nothing intersecting the ray at all.
This means I can't use the pointer events abstractions built into <mesh /> etc., as those pointer events ONLY capture points that intersect objects.
I tried attaching handlers to the <Canvas onMouseDown={...} />, however I'm unable to access the THREE internals from there as the event passed by onMouseDown does not contain them, and the useThree hook must be deeper in the tree to access the THREE react context.
So I also tried creating a component nested inside <Canvas /> (see below) where I'd be able to use the useThree hook:
<Canvas>
<MouseHandler>
...
<mesh />
...
</MouseHandler>
</Canvas>
export function MouseHandler({ children }) {
const { Camera } = useThree()
return <Html><div onMouseDown={...}>{children}</div></Html>
}
...but then react-three-fiber complains that I have THREE objects inside HTML objects.
Anyone have any suggestions on how else I might be able to solve this?
Doesn't feel like a common enough use case that react-three-fiber would ever support, so what I ended up doing was hack it like this, using React Refs to yank a "pointer" to the three objects I needed from deeper into the tree.
const threeRef = useRef()
<MouseHandler threeRef={threeRef}>
<Canvas>
<ThreeConnect threeRef={threeRef}>
...
<mesh />
...
</ThreeConnect>
</Canvas>
</MouseHandler>
...
export function MouseHandler({ threeRef, children }) {
const { Camera } = threeRef.current
return <Html><div onMouseDown={...}>{children}</div></Html>
}
...
export default function ThreeConnect({ children, threeRef }) {
const { scene, camera } = useThree()
useEffect(() => {
threeRef.current = {
...threeRef.current,
camera,
scene,
}
}, [scene, camera])
return <>{children}</>
}
You need add onMouseDown in <mesh> for capture points that intersect objects.
And add onPointerMissed in <Canvas> for capture nothing intersecting.
Related
In my app I want to add texture to the loaded .obj model. I have the same situation with my .fbx loaded models. Below is my example code, but this works only with something like sphereGeometry not with a loaded model.
Thanks in Advance!
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader'
import { useTexture } from '#react-three/drei'
const OBJModel = ({ file }: { file: string }) => {
const obj = useLoader(OBJLoader, file)
const texture = useTexture(textureFile)
return (
<mesh>
<primitive object={obj} />
<meshStandardMaterial map={texture} attach="material" />
</mesh>
)
}
primitive is not a subset of mesh. it can be a children of group.
primitive requires both geometry and material as props. Mesh requires both geometry and material as props. it's pretty obvious both cannot be used as subset of each other.
to implement your idea, you need to use only one Mesh or primitive. I'd suggest using Mesh which has abundant documentations. primitive is not documented enough.
the OBJ acquired through useLoader may have complex group inside it. Models usually contain larger sets such as group or scene. Group and Scenes can't have textures. Mesh can.
OBJ(result of useLoader) = scene => group => mesh => geometry, texture
traversing is required to acquire geometry from mesh.
// I've implemented this with Typescript,
// but it is not necessary to use 'as' for type conversion.
const obj = useLoader(OBJLoader, "/rock.obj");
const texture = useTexture("/guide.png");
const geometry = useMemo(() => {
let g;
obj.traverse((c) => {
if (c.type === "Mesh") {
const _c = c as Mesh;
g = _c.geometry;
}
});
return g;
}, [obj]);
// I've used meshPhysicalMaterial because the texture needs lights to be seen properly.
return (
<mesh geometry={geometry} scale={0.04}>
<meshPhysicalMaterial map={texture} />
</mesh>
);
I've implemented it in codesandbox. here's the working code:
https://codesandbox.io/s/crazy-dawn-i6vzb?file=/src/components/Rock.tsx:257-550
i believe obj loader returns a group, or a mesh, it wouldn't make sense to put that into a top-level mesh, and giving the top level a texture wont change the loaded obj mesh.
there are three possible solutions:
use obj.traverse(...) and change the model by mutation, this is what people do in vanilla three and it is problematic because mutation is bad, you are destroying the source data and you won't be able to re-use the model
i would suggest to convert your model to a gltf, then you can use gltfjsx https://github.com/pmndrs/gltfjsx which can lay out the full model declaratively. click the video, this is exactly what you want
if you must use obj files you can create the declarative graph by hand. there is a hook that gives you { nodes, materials } https://docs.pmnd.rs/react-three-fiber/API/hooks#use-graph
I forked the Keeper app project from Angela Yu's course on Udemy and made some modifications. Here is the link: https://codesandbox.io/s/keeper-app-part-2-completed-forked-9zks1?file=/src/components/Note.js
I wanted to be able to move the notes around the canvas, and while I was able to do that, I'm having a problem with the stacking of the notes. I want the selected note to be on top of all the other notes. I've tried tinkering with the z-index value by creating a useRef called noteRef and typing:
noteRef.current.style.zIndex = 9999
inside the handleOnClick function, which is called during onMouseDown. However, it doesn't really do anything. I tried having that and then typing
noteRef.current.style.zIndex = 1
inside the handleOnUp function, and while I was able to have the selected note on top of the others while moving, obviously it just goes right back below the notes when I release the mouse.
I've also tried using useEffect but it also didn't change anything. I was wondering if there is a way to access functions from the App component (where the note components reside).
**EDITED
Here is my example code sandbox.
How about managing notes` z-index with state in parent component?
In below's my example, i used useState in App component and made stackNote function for handling child component's style.
function App () {
const [zIndex, setZindex] = useState(1);
const stackNote = (ref) => {
ref.current.style.zIndex = zIndex;
setZindex((zIndex) => zIndex + 1);
};
return (
<div>
<Header />
{notes.map((noteItem) => (
<Note
stackNote={stackNote}
key={noteItem.key}
title={noteItem.title}
content={noteItem.content}
/>
))}
<Footer />
</div>
);
}
Then, in the Note component, just call stackNote with noteRef in your handleOnClick.
function handleOnClick(e) {
e.preventDefault();
props.stackNote(noteRef);
offsetX = e.pageX - notePos.x;
offsetY = e.pageY - notePos.y;
console.log("Mouse is clicked!");
document.addEventListener("mousemove", handleOnMove);
document.addEventListener("mouseup", handleOnUp);
}
And don't forget should erase previous codes something like noteRef.current.style.zIndex = ...
Although ref existed in the original code, so i used as it is, but it doesn't seem necessary to use it.
I've started introducing some React hooks into my code, specifically the useEffect and I can't seem to find out whether what I'm doing is considered safe or not. Essentially I'm running animations on the DOM within the hook, and I want to ensure that's not going to break any DOM snapshots for example.
Here's an example, I've modified from my full example to try and be concise to illustrate what's happening:
export function GrowingCircle(props) {
const root = useRef(null); // This is the root element we draw to
// The actual rendering is done whenever the data changes
useEffect(() => {
const radius = props.width / 2;
d3.select(root.current)
.transition()
.duration(1000)
.attr("r", radius);
}, [props.width]);
return (
<svg width={props.width} height="100%">
<circle ref={root} cx="0" cy="0" r="0" fill="red" />
</svg>
);
}
The part I'm concerned about is the .transition() is going to run frequent updates on the DOM for 1 second, and I'm unsure if that's going to mess up the react rendering?
A follow up question (as often we don't have control of the animation rendering like in this example). Would the following where the circle is no longer within the JSX change things?
export function GrowingCircle(props) {
const root = useRef(null); // This is the root element we draw to
// The actual rendering is done whenever the data changes
useEffect(() => {
const radius = props.width / 2;
d3.select(root.current)
.append("circle")
.attr("cx", 0)
.attr("cy", 0)
.attr("fill", "red")
.transition()
.duration(1000)
.attr("r", radius);
}, [props.width]);
return (
<svg ref={root} width={props.width} height="100%">
</svg>
);
}
I have been in your same scenario, to give you a short answer, since there is no real D3 - React implementation, you need to draw your own boundaries of imperative versus declarative rendering. However in both your example, you aren't really breaking any rules.
In your second example you're passing the reins to D3 to do the full rendering, while React simple keeps a ref to the top-level svg element. While in your first example, you're rendering a single circle, and only managing its transition with D3.
However in the 1st example, since it looks like you never change anything in the DOM after declaration, React should geneally never interfere. Here's a third example achieving something similar to what you wrote :
export function GrowingCircle(props) {
const root = useRef(null); // This is the root element we draw to
const [radius,setRadius] = useState(0); //initial radius value
useEffect(() => {
d3.select(root.current)
.transition()
.duration(1000)
.attr("r", props.width / 2)
.on("end", () => {setRadius(props.width / 2)}); //this is necessary
}, [props.width]);
return (
<svg width={props.width} height="100%">
<circle ref={root} cx="0" cy="0" r={radius} fill="red" />
</svg>
);
}
I've ran into a similar problem myself, and from what I understood, during the transition d3 acts on your desired value directly, causing it not to trigger react's re-render mechanism. But once it's done, I'm assuming it directly tries to act on the radius value, and your circle snaps back to its 0 original value. So you simply add an .on('end') event to update its state, and there you have it!
I feel like this is as close as it gets to the react way, where d3 only selects elements rendered based on react state.
DOM elements rendered by React can be modified. However if React needs to re-render because the Virtual DOM doesn't match with the current DOM (the one that React has as the current) it might replace the modified parts, and changes made out of React could be lost.
React modifies the necessary parts so modifications might remain if React only modified a part of the element or they might be removed. So, as far as I understand, we can not trust modifications will remain. Only if we knew for sure the Component output will not change after the custom modifications.
My suggestion is to use React to keep track of any change:
Use useState and useEffect to modify style properties.
Use CSS classes and handle the animation using CSS.
Use an animation library which is made for React (take a look at Framer motion).
I'm attempting to completely recreate or reorganize the functionality of the LayersControl component in its own separate panel using react-leaflet.
I have several filtered into their own and it works fine, but I'd like to customize the look and location of the Control element.
I've hosted the current version of my Leaflet app on github pages here. You can see the control on the right, which is the basic Leaflet control, but I'd like to the Icon on the left (the layers icon) to accomplish the same thing instead with custom react components.
Just wondering if anyone can point me in the right direction to beginning to accomplish this!
This is my current render for my react-leaflet map:
render() {
const types = [...new Set(data.map(loc => loc.type))];
const group = types.map(type =>
data.filter(loc => loc.type === type)
.map(({id, lat, lng, name}) =>
<LayersControl.Overlay name={startCase(toLower(type))}>
<LayerGroup>
<Marker key={id} position={[lat, lng]} icon=
{locationIcon}>
<Tooltip permanent direction="bottom" opacity={.6}>
{name}
</Tooltip>
</Marker>
</LayerGroup>
</LayersControl.Overlay>
));
return (
<>
<ControlPanel />
<Map
zoomControl={false}
center={this.state.center}
zoom={this.state.zoom}
maxBounds={this.state.maxBounds}
maxZoom={10}
>
<LayersControl>
<TileLayer
url='https://cartocdn-gusc.global.ssl.fastly.net//ramirocartodb/api/v1/map/named/tpl_756aec63_3adb_48b6_9d14_331c6cbc47cf/all/{z}/{x}/{y}.png'
/>
<ZoomControl position="topright" />
{group}
</LayersControl>
</Map>
</>
);
}
So theres still a few bugs in this but i've managed get most of the way (self taught react) using material UI as an example, can be seen in this sandbox link:
https://codesandbox.io/embed/competent-edison-wt5pl?fontsize=14
The general bassis is that we extend MapControl which means we have to define createLeafletElement, this has to return a generic leaflet (not react) control from the original javascript leaflet package. Essentially making a div with the domutil provided by leaflet and then portaling our react components through that div with react portals.
Again with another class extension we extend some of the classes provided by react-leaflet for layers, i pulled it out and just made a generic layer that you could define a group for, that way you could render any layer (polygon, baselayer etc) and specify the group to tell it where to go in the layer control i.e no need for specific components or overlays. As we are extending the class we need implement and pass down the methods we want to use, like addLayer, remove layer etc. During these implementations i've just added them to state to track what layers are active and such.
Not sure if there are better practices throughout everything i've implemented but this is definitely a start, hopefully in the right direction.
Bugs - The first layer in each group won't turn on correctly without the 2nd item ticked, something to do with state i think but didn't have the time to track it down
Thanks Dylan and Peter for this nice React Leaflet custom control approach. I assumed there was still a bug in the toggleLayer function. It's checked multiple checkboxes and the layers won't change properly. So I restructered a little bit and now it should work fine.
toggleLayer = layerInput => {
const { name, group } = layerInput;
let layers = { ...this.state.layers };
layers[group] = layers[group].map(l => {
l.checked = false;
this.removeLayer(l.layer);
if (l.name === name) {
l.checked = !l.checked;
this.props.leaflet.map.addLayer(l.layer);
}
return l;
});
this.setState({
layers
});
};
Just to elaborate on the bug that is mentioned in Dylans answer...
If you have more then one ControlledLayerItem, none items are added to the map until the very last item is checked. To fix this, the toggleLayer method in ControlLayer2.js has to be slightly modified:
toggleLayer = layerInput => {
const { layer, name, checked, group } = layerInput;
let layers = { ...this.state.layers };
layers[group] = layers[group].map(l => {
if (l.name === name) {
l.checked = !l.checked;
l.checked
? this.props.leaflet.map.addLayer(layer)
: this.removeLayer(layer);
}
return l;
});
this.setState({
layers
});
};
Thanks Dylan for the code, it was really helpfull.
What I want to achieve:
Have a <Map><FeatureGroup><Circle />[1 or more]...</FeatureGroup></Map> hierarchy and fit the map bounds to the feature group so that all the circles are in the viewport.
If there is only one circle, it should fit the bounds (ie: zoom in on) to that circle.
What I've tried:
giving FeatureGroup a ref and calling getBounds on it to pass onto Map. Because of the lifecycle FeatureGroup doesn't exist at the time componentDidMount is called - it gets rendered later (https://github.com/PaulLeCam/react-leaflet/issues/106#issuecomment-161594328).
Storing Circle in state and calling getBounds on that (assuming, in this case, that there is only one circle. That didn't work either.
I think I might need to do something with the React Context but I'm not sure that I fully understand it right now, so I need some help.
Other information
I'm using react-leaflet#2.1.2
Thanks for any help offered!
Because the contents of the Map are unavailable at componentDidMount-time (https://github.com/PaulLeCam/react-leaflet/issues/106#issuecomment-161594328) you cannot get the bounds of the FeatureGroup at that point, and out of all the refs you assign, only the Map ref will be available in this.refs.
However, as per this GitHub comment: https://github.com/PaulLeCam/react-leaflet/issues/106#issuecomment-366263225 you can give a FeatureGroup an onAdd handler function:
<FeatureGroup ref="features" onAdd={this.onFeatureGroupAdd}>...
and you can then use the Map refs to access the leafletElement and call fitBounds with the bounds of the incoming event target, which will be the FeatureGroup:
onFeatureGroupAdd = (e) => {
this.refs.map.leafletElement.fitBounds(e.target.getBounds());
}
This will then "zoom" the map into the bounds of your FeatureGroup, as desired.
Update
I modified my React component so that zoom and centre are controlled by query parameters. The problem with the above solution was that if you zoomed in on a MarkerClusterGroup by clicking on it, for example, it would update the zoom in the url, re-render the map and re-call onFeatureGroupAdd, thus undoing all the marker cluster goodness.
What I needed was to access the zoom level required to keep the newly drawn circle nicely in bounds, then update the url with the correct zoom level and center.
onDrawCircle = (e) => {
...
var targetZoom = this.refs.map.leafletElement.getBoundsZoom(e.layer.getBounds());
// Call function to update url here:
functionToUpdateUrl(targetZoom, e.layer.getBounds().getCenter());
}
}
In order to be able to control the whole map I also call functionToUpdateUrl in onZoomEnd and onDragEnd event handlers, like so:
onChangeView = (e) => {
functionToUpdateUrl(e.target._zoom, this.refs.map.leafletElement.getCenter());
}
and one for handling cluster clicks:
onClusterClick = (e) => {
// This time we want the center of the layer, not the map?
functionToUpdateUrl(e.target._zoom, (e.layer ? e.layer.getBounds().getCenter() : e.target.getBounds().getCenter()));
}
Then, when rendering the Map element, pass these properties:
<Map
center={center}
ref='map'
zoom={zoom}
maxZoom={18}
onZoomEnd={this.onChangeView}
onDragEnd={this.onChangeView}
>
....
</Map>
And remember to give any MarkerClusterGroups their onClusterClick callback:
<MarkerClusterGroup onAdd={this.onMarkerGroupAdd} onClusterClick={this.onClusterClick}>
Have you tried doing getBounds in the componentDidMount function instead of componentWillMount? If that doesn't work then I'd suggest extending the FeatureGroup component and adding an onLoaded function as as prop and call that function in the componentDidMount function of your extended component. And by extending the FeatureGroup component I actually mean copying/pasting it from here. (if you care about why you need to copy that whole file check this thread)
This isn't tested but your code will probably look something like
import { FeatureGroup } from 'leaflet';
import { withLeaflet, Path } from 'react-leaflet';
class CustomFeatureGroup extends Path {
createLeafletElement(prop) {
const el = new FeatureGroup(this.getOptions(props));
this.contextValue = {
...props.leaflet,
layerContainer: el,
popupContainer: el,
};
return el;
}
componentDidMount() {
super.componentDidMount();
this.setStyle(this.props);
/*
Here you can do your centering logic with an onLoad callback or just
by using this.leafletElement.map or whatever
*/
this.props.onLoaded();
}
}
export default withLeaflet(CustomFeatureGroup)
Note: If you are using react-leaflet V1 this is actually way easier and I can edit this answer with that code if needed.