-
Notifications
You must be signed in to change notification settings - Fork 3.7k
Safeguards project from undefined cartographic value #12671
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
Thank you for the pull request, @mzschwartz5! ✅ We can confirm we have a CLA on file for you. |
const normalEndpointProjected = defined(normalEndpointCartographic) | ||
? projection.project(normalEndpointCartographic, result) | ||
: Cartesian3.ZERO; | ||
normalEndpointProjected.height = 0.0; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In order to use a ternary statement (consistent with other changes in this PR), I changed the "set height to 0" statement to occur after the projection step. This order change should not make a functional difference since projection does not depend on height, and simply sets the result height to the input height.
Open to push back here if this seems unnecessarily risky.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That should be fine, but let's make sure we're not normalizing (0, 0, 0) right below this.
primitive.destroy(); | ||
expect(primitive.isDestroyed()).toEqual(true); | ||
}); | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I only authored this one test for now, so I can get feedback before potentially going in the wrong direction.
Questions:
- Is this an appropriate way to test the changed behavior? I.e. should I add similar coverage for the other files I changed?
- Is there something I should be checking with
expects
that I'm not? (E.g. maybe something to do with the batch table code? Though that seems like internal implementation, the sort of thing that isn't usually covered by unit tests)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh, also, forgot to remove this line:
expect(frameState.commandList.length).toEqual(1);
which is causing failures. I'll address that with the next push.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This seems like a solid approach to test the issue you are addressing, locating primitives at the origin.
As to whether you need to add unit tests for the other areas you added the fix, I think the initial questions to ask are 1) do existing unit tests break and (if yes then fix) 2) without the fix are we able to reproduce the error by placing something at the origin. If yes to 2 I think a unit test makes sense.
The way how exactly the Disclaimer: I don't know whether this is relevant here! Figuring that out would require to read each affected part of the code veeery thoroughly, with lots of full text searches and such... But I'll just mention it for now:
But ... this really is constant, as in immutable. Consider the following: const scratchCartographic = new Cesium.Cartographic();
const projectedCenterScratch = new Cesium.Cartesian3();
const ellipsoid = Cesium.Ellipsoid.WGS84;
const projection = new Cesium.GeographicProjection(ellipsoid);
// Old implementation, without undefined check
function computeOld(center) {
const projectedCenter = projection.project(
ellipsoid.cartesianToCartographic(center, scratchCartographic),
projectedCenterScratch,
);
return projectedCenter;
}
// New implementation, with undefined check
function computeNew(center) {
const cartographic = ellipsoid.cartesianToCartographic(
center,
scratchCartographic,
);
const projectedCenter = Cesium.defined(cartographic)
? projection.project(cartographic, projectedCenterScratch)
: Cesium.Cartesian3.ZERO;
return projectedCenter;
}
const centerA = new Cesium.Cartesian3(0,0,0);
// Crashed before
//const resultOld = computeOld(centerA);
//console.log(resultOld);
// Does not crash now
const resultNew = computeNew(centerA);
console.log(resultNew);
// Crashes now
resultNew.x = 1.2; If someone stored the result of that (attempted) I think that in the places that are affected here (from quickly skimming over the code!), it should be possible to resolve that in most cases, by changing the pattern of const projectedCenter = Cesium.defined(cartographic)
? projection.project(cartographic, projectedCenterScratch)
: Cesium.Cartesian3.ZERO; to const projectedCenter = Cesium.defined(cartographic)
? projection.project(cartographic, projectedCenterScratch)
: Cartesian3.clone(Cesium.Cartesian3.ZERO, projectedCenterScratch); This will - in this example - have the effect that the (That may turn out to be a bit repetitive, and at some point, I'd consider some |
@javagl Good feedback, thanks. I like your approach better than what I did; less redundant and it promotes better unit test coverage by centralizing the changes. Next commit will retry things this way. |
I'm not sure what the "approach" referred to, but to avoid misunderstandings: When I mentioned something like |
I probably was to slow here 😬 ... let's see what Gabby says... |
😅 Well, we can always roll back to the previous commit + replace the usage of Cartesian3.ZERO with cloning. Though Gabby's comment on the issue does suggest a wrapper method, which I can see how modifying an interface is a big deal (breaking change), but is adding to an interface so bad? While it does clutter the interface, it's at least not as intrusive/breaking as returning a special value from |
Adding a new abstract function to an existing interface is a breaking change ... for implementors. If someone has implemented some But before "just doing that", it might make sense to wait for feedback from Gabby, about whether or not such a function should actually exist. (I don't want to further hold up what could have been a simple fix if I hadn't chimed in) |
So I agree with both of the following:
If we want to keep the same approach as One could argue that it's still a breaking change since it changes the interface. We can note that this is a breaking change in What are your thoughts? |
Not sure who that question was aimed at, but regardless of that, the clarification may be useful: What you proposed is to not introduce a new function, and not check the cartographic before passing it in, but just allow If this was the intention: Yes, this would not really be a "breaking change". (Technically, it would be as in No strong opinion for now, just something to keep in mind. |
I like Gabby's idea, from a standpoint of keeping changes minimal and non-breaking (for all intents and purposes). I see what @javagl's point though - can we perhaps log a (dev) warning (i.e. gets stripped out via debug pragma) in the case of an In my mind, that's a best-of-both worlds approach; avoid a complete crash, but don't be completely silent about it. edit- after more thought, if we go ^ this route, we should still address our own instances of potentially passing |
This reverts commit 78be243.
This would actually be functionally very similar to an "official" deprecation period, during which we'd log a developer error when the parameter is
Yes, definitely! |
To clarify, what I'm suggesting in my last comment is that we could:
The error could be either a This is not deprecating the function, but it is deprecating existing behavior. |
Wait, technically an error is already thrown when you pass an So I don't think we need to log a deprecation warning because no one is depending on the behavior of being able to pass in The only real question is whether is should be a |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the update here @mzschwartz5! I have a few more comments based on the in-context fixes.
); | ||
const center2D = defined(cartographic) | ||
? projection.project(cartographic, scratchBoundingSphereCenter2D) | ||
: Cartesian3.clone( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this intentionally cloning a clone?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nope, thought I caught that but seems I forgot to commit it.
? projection.project(cartographic, projectedCenterScratch) | ||
: Cartesian3.clone(Cartesian3.ZERO, projectedCenterScratch); | ||
|
||
const geodeticNormal = ellipsoid.scaleToGeodeticSurface( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Computing the surface normal can run into division by zero errors when the position in question is the origin.
I'd suggest restructuring the flow, both here and in those other similar cases in this file, to use a function something like the following (it also removes some redundant work):
function getProjectedPositionAndNormal(position, result = []) {
if (Cartesian3.equalsEpsilon(position, Cartesian3.ZERO, CesiumMath.EPSILON14)) {
result[0] = Cartesian3.clone(position, result[0]);
result[1] = Cartesian3.clone(Cartesian3.UNIT_Z, result[1]);
return result;
}
const cartographic = ellipsoid.cartesianToCartographic(
position,
scratchCartographic,
);
result[0] = projection.project(cartographic, result[0]);
result[1] = ellipsoid.geodeticSurfaceNormalCartographic(cartographic, result[1]);
return result;
}
const [projectedCenter, geodeticNormal] = getProjectedPositionAndNormal(center);
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It'd be helpful at this point to add test cases where the input is the origin to the unit tests for the fixes throughout this PR, assuming they're straightforward to create.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point... in fact, it's made me questions my overall approach. As I took a look through the other changed files for instances where we might want a getProjectedPositionAndNormal
function, I realized that it's pretty hard to tell what happens downstream in general.
What I mean is, in some cases (like this one), it's easy to see the value gets normalized immediately. In others, however, it's just a function that returns a value, and who knows how it gets consumed. I did some digging but it would be a non-trivial effort to trace all code paths for every file I changed here (some of which are very core, like Primitive
).
Maybe this PR should be more focused towards the case that actually causes the bug reported? Taking an even bigger step back - is there ever even a use case for spawning things at (0, 0, 0)? If we really want to support it, maybe we can just jitter objects spawned at the origin by some epsilon?
const normalEndpointProjected = defined(normalEndpointCartographic) | ||
? projection.project(normalEndpointCartographic, result) | ||
: Cartesian3.ZERO; | ||
normalEndpointProjected.height = 0.0; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That should be fine, but let's make sure we're not normalizing (0, 0, 0) right below this.
Description
#12150 discovered an issue with rendering objects (primitives, specifically) at the origin. The root cause comes from a cartesian -> cartographic conversion, where a cartesian value of
(0,0,0)
is undefined in cartographic coordinates. Many locations in code use this conversion function without checking for an undefined return value.This PR addresses a class of cases that consumed this return value without any safeguards: namely, the case where a projection function is called on undefined cartographic coordinates. In these cases, we can implement an easy workaround of conditionally projecting, if the cart coords are defined, and simply assigning a projected value of
(0, 0, 0)
when they are not.This PR does NOT address every site that calls
cartesianToCartographic
, only those which immediately feed the results into a projection. For a more complete list of vulnerable calls, see the list attached here.Issue number and link
#12150
Testing plan
Used the sandbox linked in the original issue to test the fix; primitives can now be spawned at the origin.
TODO: more unit test coverage? pending feedback from reviewer.
Author checklist
CONTRIBUTORS.md
CHANGES.md
with a short summary of my change